Files
meet-hub/src/pages/member/OrderDetail.tsx
dwindown 5a05203f2b Update all pages to use centralized status helpers
Changes:
- Update MemberOrders to use getPaymentStatusLabel and getPaymentStatusColor
- Update OrderDetail to use centralized helpers
- Remove duplicate getStatusColor and getStatusLabel functions
- Dashboard.tsx already using imported helpers

Benefits:
- DRY principle - single source of truth
- Consistent Indonesian labels everywhere
- Easy to update status styling in one place

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-26 18:19:23 +07:00

660 lines
23 KiB
TypeScript

import { useEffect, useState, useCallback } from "react";
import { useNavigate, useParams, Link } from "react-router-dom";
import { AppLayout } from "@/components/AppLayout";
import { useAuth } from "@/hooks/useAuth";
import { supabase } from "@/integrations/supabase/client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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, Clock, RefreshCw } from "lucide-react";
import { QRCodeSVG } from "qrcode.react";
import { getPaymentStatusLabel, getPaymentStatusColor, getProductTypeLabel } from "@/lib/statusHelpers";
interface OrderItem {
id: string;
product_id: string;
quantity: number;
products: {
title: string;
type: string;
slug: string;
price: number;
sale_price: number | null;
};
}
interface Order {
id: string;
total_amount: number;
status: string;
payment_status: string | null;
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[];
}
interface ConsultingSlot {
id: string;
date: string;
start_time: string;
end_time: string;
status: string;
meet_link?: string;
}
export default function OrderDetail() {
const { id } = useParams<{ id: string }>();
const { user, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [order, setOrder] = useState<Order | null>(null);
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);
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 () => {
if (!user || !id) return;
try {
const { data, error: queryError } = await supabase
.from("orders")
.select(`
*,
order_items (
id,
product_id,
quantity,
products (title, type, slug, price, sale_price)
)
`)
.eq("id", id)
.eq("user_id", user.id)
.single();
if (queryError) {
console.error("Order fetch error:", queryError);
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");
} else {
setOrder(data);
// Fetch consulting slots if this is a consulting order
const hasConsultingProduct = data.order_items.some(
(item: OrderItem) => item.products.type === "consulting"
);
if (hasConsultingProduct) {
const { data: slots } = await supabase
.from("consulting_slots")
.select("*")
.eq("order_id", id)
.order("date", { ascending: true });
if (slots) {
setConsultingSlots(slots as ConsultingSlot[]);
}
}
}
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 getTypeLabel = (type: string) => {
switch (type) {
case "consulting":
return "Konsultasi";
case "webinar":
return "Webinar";
case "bootcamp":
return "Bootcamp";
default:
return type;
}
};
// 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>
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-1/3 mb-8" />
<Skeleton className="h-64 w-full" />
</div>
</AppLayout>
);
}
if (error) {
return (
<AppLayout>
<div className="container mx-auto px-4 py-8 max-w-3xl">
<Button
variant="ghost"
onClick={() => navigate("/orders")}
className="mb-4"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Kembali ke Riwayat Order
</Button>
<Card className="border-2 border-destructive">
<CardContent className="py-8 text-center">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-destructive" />
<h2 className="text-xl font-bold mb-2">Error</h2>
<p className="text-muted-foreground mb-4">{error}</p>
<Button onClick={() => navigate("/orders")}>
Kembali ke Riwayat Order
</Button>
</CardContent>
</Card>
</div>
</AppLayout>
);
}
if (!order) return null;
return (
<AppLayout>
<div className="container mx-auto px-4 py-8 max-w-3xl">
<Button
variant="ghost"
onClick={() => navigate("/orders")}
className="mb-4"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Kembali ke Riwayat Order
</Button>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold">Detail Order</h1>
<p className="text-muted-foreground font-mono">#{order.id.slice(0, 8)}</p>
</div>
<Badge className={`${getPaymentStatusColor(order.payment_status || order.status)} rounded-full`}>
{getPaymentStatusLabel(order.payment_status || order.status)}
</Badge>
</div>
{/* Order Info */}
<Card className="border-2 border-border mb-6">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Calendar className="w-5 h-5" />
Informasi Order
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Tanggal Order</p>
<p className="font-medium">{formatDate(order.created_at)}</p>
</div>
<div>
<p className="text-muted-foreground">Terakhir Update</p>
<p className="font-medium">{formatDate(order.updated_at)}</p>
</div>
{order.payment_method && (
<div>
<p className="text-muted-foreground">Metode Pembayaran</p>
<p className="font-medium uppercase">{order.payment_method}</p>
</div>
)}
{order.payment_provider && (
<div>
<p className="text-muted-foreground">Provider</p>
<p className="font-medium capitalize">{order.payment_provider}</p>
</div>
)}
</div>
{/* QR Code Display for pending QRIS payments */}
{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" />
<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>
)}
<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">
<CreditCard className="w-4 h-4 mr-2" />
Bayar di Halaman Pembayaran
</a>
</Button>
)}
</div>
</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">
<Button asChild className="w-full shadow-sm">
<a href={order.payment_url} target="_blank" rel="noopener noreferrer">
<CreditCard className="w-4 h-4 mr-2" />
Lanjutkan Pembayaran
</a>
</Button>
</div>
)}
</CardContent>
</Card>
{/* 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>
))}
</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>
{/* 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}
{/* Consulting Slots Detail */}
{consultingSlots.length > 0 && (
<Card className="border-2 border-primary bg-primary/5">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Video className="w-5 h-5" />
Jadwal Konsultasi
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{consultingSlots.map((slot) => (
<div key={slot.id} className="border-2 border-border rounded-lg p-4 bg-background">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Badge variant={slot.status === "confirmed" ? "default" : "secondary"}>
{slot.status === "confirmed" ? "Terkonfirmasi" : slot.status}
</Badge>
</div>
<p className="font-medium">
{new Date(slot.date).toLocaleDateString("id-ID", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric"
})}
</p>
<p className="text-sm text-muted-foreground">
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)} WIB
</p>
</div>
{slot.meet_link && order.payment_status === "paid" && (
<Button asChild className="shadow-sm">
<a
href={slot.meet_link}
target="_blank"
rel="noopener noreferrer"
>
<Video className="w-4 h-4 mr-2" />
Join Meet
</a>
</Button>
)}
{slot.meet_link && order.payment_status !== "paid" && (
<p className="text-sm text-muted-foreground">
Link tersedia setelah pembayaran
</p>
)}
{!slot.meet_link && (
<p className="text-sm text-muted-foreground">
Link akan dikirim via email
</p>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Access Info */}
{order.payment_status === "paid" && (
<Card className="border-2 border-primary bg-primary/5">
<CardContent className="py-4">
<p className="text-sm">
Pembayaran berhasil! Akses produk Anda tersedia di halaman{" "}
<Link to="/access" className="font-medium underline">
Akses Saya
</Link>
.
</p>
</CardContent>
</Card>
)}
</div>
</AppLayout>
);
}