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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user