Add QR code display and polling to OrderDetail page

- Add qr_string and qr_expires_at to Order interface
- Implement 10-second polling for payment status
- Add countdown timer for QR expiration
- Display QR code inline for pending QRIS payments
- Show "Menunggu pembayaran" with spinner while polling
- Add fallback button for payments without QR

Features:
- QR code rendered with qrcode.react library
- Real-time countdown timer (minutes:seconds)
- Auto-refresh when payment detected
- Clean up polling interval on unmount
- Memoized fetchOrder to prevent excessive re-renders

🤖 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 00:25:27 +07:00
parent eba37df4d7
commit 35a003e35c

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useCallback } from "react";
import { useNavigate, useParams, Link } from "react-router-dom";
import { AppLayout } from "@/components/AppLayout";
import { useAuth } from "@/hooks/useAuth";
@@ -8,8 +8,10 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { formatIDR, formatDate } from "@/lib/format";
import { ArrowLeft, Package, CreditCard, Calendar, AlertCircle, Video } from "lucide-react";
import { ArrowLeft, Package, CreditCard, Calendar, AlertCircle, Video, Clock, RefreshCw } from "lucide-react";
import { QRCodeSVG } from "qrcode.react";
interface OrderItem {
id: string;
@@ -32,6 +34,8 @@ interface Order {
payment_method: string | null;
payment_provider: string | null;
payment_url: string | null;
qr_string: string | null;
qr_expires_at: string | null;
created_at: string;
updated_at: string;
order_items: OrderItem[];
@@ -54,26 +58,13 @@ export default function OrderDetail() {
const [consultingSlots, setConsultingSlots] = useState<ConsultingSlot[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [timeRemaining, setTimeRemaining] = useState<string>("");
const [isPolling, setIsPolling] = useState(false);
useEffect(() => {
if (authLoading) return;
if (!user) {
navigate("/auth");
return;
}
if (id) {
fetchOrder();
}
}, [user, authLoading, id]);
const fetchOrder = async () => {
// Memoized fetchOrder to avoid recreating on every render
const fetchOrder = useCallback(async () => {
if (!user || !id) return;
setLoading(true);
setError(null);
try {
const { data, error: queryError } = await supabase
.from("orders")
@@ -92,18 +83,33 @@ export default function OrderDetail() {
if (queryError) {
console.error("Order fetch error:", queryError);
setError("Gagal mengambil data order");
setLoading(false);
return null;
}
return data as Order;
} catch (err) {
console.error("Unexpected error:", err);
return null;
}
}, [user, id]);
useEffect(() => {
if (authLoading) return;
if (!user) {
navigate("/auth");
return;
}
if (id) {
const loadOrder = async () => {
setLoading(true);
const data = await fetchOrder();
if (!data) {
setError("Order tidak ditemukan");
setLoading(false);
return;
}
setOrder(data as Order);
} else {
setOrder(data);
// Fetch consulting slots if this is a consulting order
const hasConsultingProduct = data.order_items.some(
@@ -121,14 +127,71 @@ export default function OrderDetail() {
setConsultingSlots(slots as ConsultingSlot[]);
}
}
} catch (err) {
console.error("Unexpected error:", err);
setError("Terjadi kesalahan");
} finally {
setLoading(false);
}
setLoading(false);
};
loadOrder();
}
}, [user, authLoading, id, fetchOrder]);
// Poll for payment status if order is pending and has QR string
useEffect(() => {
if (!order || order.payment_status === "paid") return;
// Only poll if there's a QR string or it's a pending QRIS payment
const shouldPoll = order.qr_string || (order.payment_status === "pending" && order.payment_method === "qris");
if (!shouldPoll) return;
setIsPolling(true);
const interval = setInterval(async () => {
const updatedOrder = await fetchOrder();
if (updatedOrder) {
setOrder(updatedOrder);
// Stop polling if paid
if (updatedOrder.payment_status === "paid") {
clearInterval(interval);
setIsPolling(false);
}
}
}, 10000); // Poll every 10 seconds
return () => {
clearInterval(interval);
setIsPolling(false);
};
}, [order, fetchOrder]);
// Countdown timer for QR expiration
useEffect(() => {
if (!order?.qr_expires_at) return;
const updateCountdown = () => {
const now = new Date().getTime();
const expiresAt = new Date(order.qr_expires_at!).getTime();
const distance = expiresAt - now;
if (distance < 0) {
setTimeRemaining("QR Code kadaluarsa");
return;
}
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
setTimeRemaining(`${minutes}m ${seconds}s`);
};
updateCountdown();
const timer = setInterval(updateCountdown, 1000);
return () => clearInterval(timer);
}, [order?.qr_expires_at]);
const getStatusColor = (status: string) => {
switch (status) {
case "paid":
@@ -265,7 +328,54 @@ export default function OrderDetail() {
)}
</div>
{order.payment_status === "pending" && order.payment_url && (
{/* QR Code Display for pending QRIS payments */}
{order.payment_status === "pending" && order.payment_method === "qris" && order.qr_string && (
<div className="pt-4">
<Alert className="mb-4">
<Clock className="h-4 w-4" />
<AlertDescription>
Scan QR code ini dengan aplikasi e-wallet atau mobile banking Anda
{timeRemaining && (
<span className="ml-2 font-medium">
(Kadaluarsa dalam {timeRemaining})
</span>
)}
</AlertDescription>
</Alert>
<div className="bg-white p-6 rounded-lg border-2 border-border flex flex-col items-center justify-center space-y-4">
<div className="bg-white p-2 rounded">
<QRCodeSVG value={order.qr_string} size={200} />
</div>
<div className="text-center space-y-2">
<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>
{isPolling && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RefreshCw className="w-4 h-4 animate-spin" />
Menunggu pembayaran...
</div>
)}
{order.payment_url && (
<Button asChild variant="outline" className="w-full">
<a href={order.payment_url} target="_blank" rel="noopener noreferrer">
<CreditCard className="w-4 h-4 mr-2" />
Bayar di Halaman Pembayaran
</a>
</Button>
)}
</div>
</div>
)}
{/* Fallback button for pending payments without QR */}
{order.payment_status === "pending" && !order.qr_string && order.payment_url && (
<div className="pt-4">
<Button asChild className="w-full shadow-sm">
<a href={order.payment_url} target="_blank" rel="noopener noreferrer">