Implement post-implementation refinements

Features implemented:
1. Expired QRIS order handling with dual-path approach
   - Product orders: QR regeneration button
   - Consulting orders: Immediate cancellation with slot release
2. Standardized status badge wording to "Pending"
3. Fixed TypeScript error in MemberDashboard
4. Dynamic badge colors from branding settings
5. Dynamic page title from branding settings
6. Logo/favicon file upload with auto-delete

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
dwindown
2025-12-24 11:42:20 +07:00
parent 4b8765885b
commit fb24e77e42
15 changed files with 779 additions and 149 deletions

View File

@@ -60,6 +60,17 @@ export default function OrderDetail() {
const [error, setError] = useState<string | null>(null);
const [timeRemaining, setTimeRemaining] = useState<string>("");
const [isPolling, setIsPolling] = useState(false);
const [regeneratingQR, setRegeneratingQR] = useState(false);
// Check if QR is expired
const isQrExpired = order?.qr_expires_at
? new Date(order.qr_expires_at) < new Date()
: false;
// Check if this is a consulting order
const isConsultingOrder = order?.order_items?.some(
(item: OrderItem) => item.products.type === "consulting"
) || false;
// Memoized fetchOrder to avoid recreating on every render
const fetchOrder = useCallback(async () => {
@@ -195,7 +206,7 @@ export default function OrderDetail() {
const getStatusColor = (status: string) => {
switch (status) {
case "paid":
return "bg-accent text-primary";
return "bg-brand-accent text-white";
case "pending":
return "bg-secondary text-primary";
case "cancelled":
@@ -211,7 +222,7 @@ export default function OrderDetail() {
case "paid":
return "Lunas";
case "pending":
return "Menunggu Pembayaran";
return "Pending";
case "failed":
return "Gagal";
case "cancelled":
@@ -234,6 +245,41 @@ export default function OrderDetail() {
}
};
// Handle QR regeneration for expired product orders
const handleRegenerateQR = async () => {
if (!order || isConsultingOrder) return;
setRegeneratingQR(true);
try {
// Call create-payment function with existing order_id
const { data, error } = await supabase.functions.invoke('create-payment', {
body: {
order_id: order.id,
amount: order.total_amount,
description: order.order_items.map((item: OrderItem) => item.products.title).join(", "),
},
});
if (error) {
throw error;
}
// Refresh order data to get new QR
const updatedOrder = await fetchOrder();
if (updatedOrder) {
setOrder(updatedOrder);
}
// Restart polling
setIsPolling(true);
} catch (error) {
console.error('QR regeneration error:', error);
setError('Gagal me-regenerate QR code. Silakan coba lagi atau buat order baru.');
} finally {
setRegeneratingQR(false);
}
};
if (authLoading || loading) {
return (
<AppLayout>
@@ -329,7 +375,7 @@ export default function OrderDetail() {
</div>
{/* QR Code Display for pending QRIS payments */}
{order.payment_status === "pending" && order.payment_method === "qris" && order.qr_string && (
{order.payment_status === "pending" && order.payment_method === "qris" && order.qr_string && !isQrExpired && (
<div className="pt-4">
<Alert className="mb-4">
<Clock className="h-4 w-4" />
@@ -362,6 +408,11 @@ export default function OrderDetail() {
</div>
)}
<div className="flex items-center justify-center gap-4 text-xs text-muted-foreground">
<span>🔒 Pembayaran Aman</span>
<span> QRIS Standar Bank Indonesia</span>
</div>
{order.payment_url && (
<Button asChild variant="outline" className="w-full">
<a href={order.payment_url} target="_blank" rel="noopener noreferrer">
@@ -374,6 +425,70 @@ export default function OrderDetail() {
</div>
)}
{/* Expired QR Handling */}
{order.payment_status === "pending" && order.payment_method === "qris" && isQrExpired && (
<div className="pt-4">
<Alert className="mb-4 border-orange-200 bg-orange-50">
<AlertCircle className="h-4 w-4 text-orange-600" />
<AlertDescription className="text-orange-900">
{isConsultingOrder
? "Waktu pembayaran telah habis. Slot konsultasi telah dilepaskan. Silakan buat booking baru."
: "QR Code telah kadaluarsa. Anda dapat me-regenerate QR code untuk melanjutkan pembayaran."}
</AlertDescription>
</Alert>
{isConsultingOrder ? (
// Consulting order - show booking button
<div className="text-center space-y-4">
<p className="text-sm text-muted-foreground">
Order ini telah dibatalkan secara otomatis karena waktu pembayaran habis.
</p>
<Button onClick={() => navigate("/consulting-booking")} className="shadow-sm">
<Calendar className="w-4 h-4 mr-2" />
Buat Booking Baru
</Button>
</div>
) : (
// Product order - show regenerate button
<div className="space-y-4">
<div className="text-center">
<p className="text-2xl font-bold">{formatIDR(order.total_amount)}</p>
<p className="text-sm text-muted-foreground">
Order ID: {order.id.slice(0, 8)}
</p>
</div>
<Button
onClick={handleRegenerateQR}
disabled={regeneratingQR}
className="w-full shadow-sm"
>
{regeneratingQR ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Memproses...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Regenerate QR Code
</>
)}
</Button>
<Button
onClick={() => navigate("/products")}
variant="outline"
className="w-full"
>
<Package className="w-4 h-4 mr-2" />
Kembali ke Produk
</Button>
</div>
)}
</div>
)}
{/* Fallback button for pending payments without QR */}
{order.payment_status === "pending" && !order.qr_string && order.payment_url && (
<div className="pt-4">
@@ -388,49 +503,109 @@ export default function OrderDetail() {
</CardContent>
</Card>
{/* Order Items */}
<Card className="border-2 border-border mb-6">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Package className="w-5 h-5" />
Item Pesanan
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{order.order_items.map((item) => (
<div key={item.id} className="flex items-center justify-between py-2">
<div className="flex-1">
<Link
to={`/products/${item.products.slug}`}
className="font-medium hover:underline"
>
{item.products.title}
</Link>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{getTypeLabel(item.products.type)}
</Badge>
<span className="text-sm text-muted-foreground">
x{item.quantity}
</span>
{/* Smart Item/Service Display */}
{order.order_items.length > 0 ? (
// === Product Orders ===
<Card className="border-2 border-border mb-6">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Package className="w-5 h-5" />
Item Pesanan
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{order.order_items.map((item) => (
<div key={item.id} className="flex items-center justify-between py-2">
<div className="flex-1">
<Link
to={`/products/${item.products.slug}`}
className="font-medium hover:underline"
>
{item.products.title}
</Link>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{getTypeLabel(item.products.type)}
</Badge>
<span className="text-sm text-muted-foreground">
x{item.quantity}
</span>
</div>
</div>
<p className="font-medium">{formatIDR(item.products.sale_price || item.products.price)}</p>
</div>
<p className="font-medium">{formatIDR(item.products.sale_price || item.products.price)}</p>
))}
</div>
<Separator className="my-4" />
<div className="flex items-center justify-between text-lg font-bold">
<span>Total</span>
<span>{formatIDR(order.total_amount)}</span>
</div>
</CardContent>
</Card>
) : consultingSlots.length > 0 ? (
// === Consulting Orders ===
<Card className="border-2 border-primary bg-primary/5 mb-6">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Video className="w-5 h-5" />
Detail Sesi Konsultasi
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Summary Card */}
<div className="bg-background p-4 rounded-lg border-2 border-border">
<div className="grid grid-cols-1 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Waktu Konsultasi</p>
<p className="font-bold text-lg">
{consultingSlots[0].start_time.substring(0,5)} - {consultingSlots[consultingSlots.length-1].end_time.substring(0,5)}
</p>
<p className="text-xs text-muted-foreground mt-1">
{consultingSlots.length} blok ({consultingSlots.length * 45} menit)
</p>
</div>
{consultingSlots[0]?.meet_link && (
<div>
<p className="text-muted-foreground text-sm">Google Meet Link</p>
<a
href={consultingSlots[0].meet_link}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary hover:underline text-sm"
>
{consultingSlots[0].meet_link.substring(0, 40)}...
</a>
</div>
)}
</div>
))}
</div>
</div>
<Separator className="my-4" />
{/* Status Alert */}
{order.payment_status === "paid" ? (
<Alert className="bg-green-50 border-green-200">
<Video className="h-4 w-4" />
<AlertDescription>
Pembayaran berhasil! Silakan bergabung sesuai jadwal di bawah.
</AlertDescription>
</Alert>
) : (
<Alert className="bg-yellow-50 border-yellow-200">
<Clock className="h-4 w-4" />
<AlertDescription>
Selesaikan pembayaran untuk mengkonfirmasi jadwal sesi konsultasi.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
) : null}
<div className="flex items-center justify-between text-lg font-bold">
<span>Total</span>
<span>{formatIDR(order.total_amount)}</span>
</div>
</CardContent>
</Card>
{/* Consulting Slots */}
{/* Consulting Slots Detail */}
{consultingSlots.length > 0 && (
<Card className="border-2 border-primary bg-primary/5">
<CardHeader>