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 { useNavigate, useParams, Link } from "react-router-dom";
|
||||||
import { AppLayout } from "@/components/AppLayout";
|
import { AppLayout } from "@/components/AppLayout";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
@@ -8,8 +8,10 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { formatIDR, formatDate } from "@/lib/format";
|
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 {
|
interface OrderItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,6 +34,8 @@ interface Order {
|
|||||||
payment_method: string | null;
|
payment_method: string | null;
|
||||||
payment_provider: string | null;
|
payment_provider: string | null;
|
||||||
payment_url: string | null;
|
payment_url: string | null;
|
||||||
|
qr_string: string | null;
|
||||||
|
qr_expires_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
order_items: OrderItem[];
|
order_items: OrderItem[];
|
||||||
@@ -54,26 +58,13 @@ export default function OrderDetail() {
|
|||||||
const [consultingSlots, setConsultingSlots] = useState<ConsultingSlot[]>([]);
|
const [consultingSlots, setConsultingSlots] = useState<ConsultingSlot[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [timeRemaining, setTimeRemaining] = useState<string>("");
|
||||||
|
const [isPolling, setIsPolling] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
// Memoized fetchOrder to avoid recreating on every render
|
||||||
if (authLoading) return;
|
const fetchOrder = useCallback(async () => {
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
navigate("/auth");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
fetchOrder();
|
|
||||||
}
|
|
||||||
}, [user, authLoading, id]);
|
|
||||||
|
|
||||||
const fetchOrder = async () => {
|
|
||||||
if (!user || !id) return;
|
if (!user || !id) return;
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error: queryError } = await supabase
|
const { data, error: queryError } = await supabase
|
||||||
.from("orders")
|
.from("orders")
|
||||||
@@ -92,18 +83,33 @@ export default function OrderDetail() {
|
|||||||
|
|
||||||
if (queryError) {
|
if (queryError) {
|
||||||
console.error("Order fetch error:", queryError);
|
console.error("Order fetch error:", queryError);
|
||||||
setError("Gagal mengambil data order");
|
return null;
|
||||||
setLoading(false);
|
}
|
||||||
|
|
||||||
|
return data as Order;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Unexpected error:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [user, id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authLoading) return;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
navigate("/auth");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const loadOrder = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await fetchOrder();
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
setError("Order tidak ditemukan");
|
setError("Order tidak ditemukan");
|
||||||
setLoading(false);
|
} else {
|
||||||
return;
|
setOrder(data);
|
||||||
}
|
|
||||||
|
|
||||||
setOrder(data as Order);
|
|
||||||
|
|
||||||
// Fetch consulting slots if this is a consulting order
|
// Fetch consulting slots if this is a consulting order
|
||||||
const hasConsultingProduct = data.order_items.some(
|
const hasConsultingProduct = data.order_items.some(
|
||||||
@@ -121,14 +127,71 @@ export default function OrderDetail() {
|
|||||||
setConsultingSlots(slots as ConsultingSlot[]);
|
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) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "paid":
|
case "paid":
|
||||||
@@ -265,7 +328,54 @@ export default function OrderDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="pt-4">
|
||||||
<Button asChild className="w-full shadow-sm">
|
<Button asChild className="w-full shadow-sm">
|
||||||
<a href={order.payment_url} target="_blank" rel="noopener noreferrer">
|
<a href={order.payment_url} target="_blank" rel="noopener noreferrer">
|
||||||
|
|||||||
Reference in New Issue
Block a user