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:
@@ -191,22 +191,28 @@ export default function Checkout() {
|
||||
<span>Total</span>
|
||||
<span className="font-bold">{formatIDR(total)}</span>
|
||||
</div>
|
||||
<Button onClick={handleCheckout} className="w-full shadow-sm" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Memproses...
|
||||
</>
|
||||
) : user ? (
|
||||
<>
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
Bayar dengan QRIS
|
||||
</>
|
||||
) : (
|
||||
"Login untuk Checkout"
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center">Pembayaran diproses melalui Pakasir</p>
|
||||
<div className="space-y-3 pt-2 border-t">
|
||||
<Button onClick={handleCheckout} className="w-full shadow-sm" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Memproses...
|
||||
</>
|
||||
) : user ? (
|
||||
<>
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
Bayar dengan QRIS
|
||||
</>
|
||||
) : (
|
||||
"Login untuk Checkout"
|
||||
)}
|
||||
</Button>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground text-center">Pembayaran aman dengan standar QRIS dari Bank Indonesia</p>
|
||||
<p className="text-xs text-muted-foreground text-center">Diproses oleh mitra pembayaran terpercaya</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center pt-1">Didukung oleh Pakasir | QRIS terdaftar oleh Bank Indonesia</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,14 @@ export default function ConsultingBooking() {
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(addDays(new Date(), 1));
|
||||
const [selectedSlots, setSelectedSlots] = useState<string[]>([]);
|
||||
|
||||
// NEW: Range selection instead of array
|
||||
interface TimeRange {
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
}
|
||||
const [selectedRange, setSelectedRange] = useState<TimeRange>({ start: null, end: null });
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [whatsappInput, setWhatsappInput] = useState('');
|
||||
@@ -147,15 +154,81 @@ export default function ConsultingBooking() {
|
||||
return slots;
|
||||
}, [selectedDate, workhours, confirmedSlots, settings]);
|
||||
|
||||
const toggleSlot = (slotStart: string) => {
|
||||
setSelectedSlots(prev =>
|
||||
prev.includes(slotStart)
|
||||
? prev.filter(s => s !== slotStart)
|
||||
: [...prev, slotStart]
|
||||
);
|
||||
// Helper: Get all slots between start and end (inclusive)
|
||||
const getSlotsInRange = useMemo(() => {
|
||||
if (!selectedRange.start || !selectedRange.end) return [];
|
||||
|
||||
const startIndex = availableSlots.findIndex(s => s.start === selectedRange.start);
|
||||
const endIndex = availableSlots.findIndex(s => s.start === selectedRange.end);
|
||||
|
||||
if (startIndex === -1 || endIndex === -1 || startIndex > endIndex) return [];
|
||||
|
||||
return availableSlots
|
||||
.slice(startIndex, endIndex + 1)
|
||||
.map(s => s.start);
|
||||
}, [selectedRange, availableSlots]);
|
||||
|
||||
// NEW: Range selection handler
|
||||
const handleSlotClick = (slotStart: string) => {
|
||||
const slot = availableSlots.find(s => s.start === slotStart);
|
||||
if (!slot || !slot.available) return;
|
||||
|
||||
setSelectedRange(prev => {
|
||||
// CASE 1: No selection yet → Set start time
|
||||
if (!prev.start) {
|
||||
return { start: slotStart, end: null };
|
||||
}
|
||||
|
||||
// CASE 2: Only start selected → Set end time
|
||||
if (!prev.end) {
|
||||
if (slotStart === prev.start) {
|
||||
// Clicked same slot → Clear selection
|
||||
return { start: null, end: null };
|
||||
}
|
||||
// Ensure end is after start
|
||||
const startIndex = availableSlots.findIndex(s => s.start === prev.start);
|
||||
const clickIndex = availableSlots.findIndex(s => s.start === slotStart);
|
||||
|
||||
if (clickIndex < startIndex) {
|
||||
// Clicked before start → Make new start, old start becomes end
|
||||
return { start: slotStart, end: prev.start };
|
||||
}
|
||||
|
||||
return { start: prev.start, end: slotStart };
|
||||
}
|
||||
|
||||
// CASE 3: Both selected (changing range)
|
||||
const startIndex = availableSlots.findIndex(s => s.start === prev.start);
|
||||
const endIndex = availableSlots.findIndex(s => s.start === prev.end);
|
||||
const clickIndex = availableSlots.findIndex(s => s.start === slotStart);
|
||||
|
||||
// Clicked start time → Clear all
|
||||
if (slotStart === prev.start) {
|
||||
return { start: null, end: null };
|
||||
}
|
||||
|
||||
// Clicked end time → Update end
|
||||
if (slotStart === prev.end) {
|
||||
return { start: prev.start, end: null };
|
||||
}
|
||||
|
||||
// Clicked before start → New start, old start becomes end
|
||||
if (clickIndex < startIndex) {
|
||||
return { start: slotStart, end: prev.start };
|
||||
}
|
||||
|
||||
// Clicked after end → New end
|
||||
if (clickIndex > endIndex) {
|
||||
return { start: prev.start, end: slotStart };
|
||||
}
|
||||
|
||||
// Clicked within range → Update end to clicked slot
|
||||
return { start: prev.start, end: slotStart };
|
||||
});
|
||||
};
|
||||
|
||||
const totalBlocks = selectedSlots.length;
|
||||
// Calculate total blocks from range
|
||||
const totalBlocks = getSlotsInRange.length;
|
||||
const totalPrice = totalBlocks * (settings?.consulting_block_price || 0);
|
||||
const totalDuration = totalBlocks * (settings?.consulting_block_duration_minutes || 30);
|
||||
|
||||
@@ -166,7 +239,7 @@ export default function ConsultingBooking() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedSlots.length === 0) {
|
||||
if (getSlotsInRange.length === 0) {
|
||||
toast({ title: 'Pilih slot', description: 'Pilih minimal satu slot waktu', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
@@ -206,7 +279,7 @@ export default function ConsultingBooking() {
|
||||
if (orderError) throw orderError;
|
||||
|
||||
// Create consulting slots
|
||||
const slotsToInsert = selectedSlots.map(slotStart => {
|
||||
const slotsToInsert = getSlotsInRange.map(slotStart => {
|
||||
const slotEnd = format(
|
||||
addMinutes(parse(slotStart, 'HH:mm', new Date()), settings.consulting_block_duration_minutes),
|
||||
'HH:mm'
|
||||
@@ -320,7 +393,7 @@ export default function ConsultingBooking() {
|
||||
Slot Waktu - {format(selectedDate, 'EEEE, d MMMM yyyy', { locale: id })}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Klik slot untuk memilih. {settings.consulting_block_duration_minutes} menit per blok.
|
||||
Klik slot awal dan akhir untuk memilih rentang waktu. {settings.consulting_block_duration_minutes} menit per blok.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -329,18 +402,47 @@ export default function ConsultingBooking() {
|
||||
Tidak ada slot tersedia pada hari ini
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
||||
{availableSlots.map((slot) => (
|
||||
<Button
|
||||
key={slot.start}
|
||||
variant={selectedSlots.includes(slot.start) ? 'default' : 'outline'}
|
||||
disabled={!slot.available}
|
||||
onClick={() => slot.available && toggleSlot(slot.start)}
|
||||
className="border-2"
|
||||
>
|
||||
{slot.start}
|
||||
</Button>
|
||||
))}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-0">
|
||||
{availableSlots.map((slot, index) => {
|
||||
const isSelected = getSlotsInRange.includes(slot.start);
|
||||
const isStart = slot.start === selectedRange.start;
|
||||
const isEnd = slot.start === selectedRange.end;
|
||||
const isMiddle = isSelected && !isStart && !isEnd;
|
||||
|
||||
// Determine button variant
|
||||
let variant: "default" | "outline" = "outline";
|
||||
if (isSelected) variant = "default";
|
||||
|
||||
// Determine border radius for seamless connection
|
||||
let className = "border-2 h-10";
|
||||
|
||||
if (isStart) {
|
||||
// First selected slot - right side should connect
|
||||
className += index < availableSlots.length - 1 && availableSlots[index + 1]?.start === getSlotsInRange[1]
|
||||
? " rounded-r-none border-r-0"
|
||||
: "";
|
||||
} else if (isEnd) {
|
||||
// Last selected slot - left side should connect
|
||||
className += " rounded-l-none border-l-0";
|
||||
} else if (isMiddle) {
|
||||
// Middle slot - seamless
|
||||
className += " rounded-none border-x-0";
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={slot.start}
|
||||
variant={variant}
|
||||
disabled={!slot.available}
|
||||
onClick={() => slot.available && handleSlotClick(slot.start)}
|
||||
className={className}
|
||||
>
|
||||
{isStart && <span className="text-xs opacity-70">Mulai</span>}
|
||||
{!isStart && !isEnd && slot.start}
|
||||
{isEnd && <span className="text-xs opacity-70">Selesai</span>}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -414,28 +516,37 @@ export default function ConsultingBooking() {
|
||||
{selectedDate ? format(selectedDate, 'd MMM yyyy', { locale: id }) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Jumlah Blok</span>
|
||||
<span className="font-medium">{totalBlocks} blok</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total Durasi</span>
|
||||
<span className="font-medium">{totalDuration} menit</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Kategori</span>
|
||||
<span className="font-medium">{selectedCategory || '-'}</span>
|
||||
</div>
|
||||
|
||||
{selectedSlots.length > 0 && (
|
||||
{selectedRange.start && selectedRange.end && (
|
||||
<div className="pt-4 border-t">
|
||||
<p className="text-sm text-muted-foreground mb-2">Slot dipilih:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedSlots.sort().map((slot) => (
|
||||
<span key={slot} className="px-2 py-1 bg-primary/10 text-primary rounded text-sm">
|
||||
{slot}
|
||||
</span>
|
||||
))}
|
||||
<p className="text-sm text-muted-foreground mb-2">Waktu dipilih:</p>
|
||||
|
||||
{/* Show range */}
|
||||
<div className="bg-primary/10 p-3 rounded-lg border-2 border-primary/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Mulai</p>
|
||||
<p className="font-bold text-lg">{selectedRange.start}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-2xl">→</p>
|
||||
<p className="text-xs text-muted-foreground">{totalBlocks} blok</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-muted-foreground">Selesai</p>
|
||||
<p className="font-bold text-lg">{selectedRange.end}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm mt-2 text-primary font-medium">
|
||||
{totalDuration} menit ({formatIDR(totalPrice)})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -452,7 +563,7 @@ export default function ConsultingBooking() {
|
||||
|
||||
<Button
|
||||
onClick={handleBookNow}
|
||||
disabled={submitting || selectedSlots.length === 0 || !selectedCategory}
|
||||
disabled={submitting || getSlotsInRange.length === 0 || !selectedCategory}
|
||||
className="w-full shadow-sm"
|
||||
>
|
||||
{submitting ? 'Memproses...' : 'Booking Sekarang'}
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function Dashboard() {
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'bg-accent';
|
||||
case 'paid': return 'bg-brand-accent text-white';
|
||||
case 'pending': return 'bg-secondary';
|
||||
case 'cancelled': return 'bg-destructive';
|
||||
default: return 'bg-secondary';
|
||||
@@ -68,7 +68,7 @@ export default function Dashboard() {
|
||||
const getPaymentStatusLabel = (status: string | null) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'Lunas';
|
||||
case 'pending': return 'Menunggu Pembayaran';
|
||||
case 'pending': return 'Pending';
|
||||
case 'failed': return 'Gagal';
|
||||
default: return status || 'Pending';
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function AdminDashboard() {
|
||||
<div className="text-right">
|
||||
<p className="font-bold">{formatIDR(order.total_amount)}</p>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 ${order.payment_status === "paid" ? "bg-accent text-accent-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
className={`text-xs px-2 py-0.5 ${order.payment_status === "paid" ? "bg-brand-accent text-white" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{order.payment_status === "paid" ? "Lunas" : "Pending"}
|
||||
</span>
|
||||
|
||||
@@ -149,7 +149,7 @@ export default function AdminOrders() {
|
||||
const getStatusBadge = (status: string | null) => {
|
||||
switch (status) {
|
||||
case "paid":
|
||||
return <Badge className="bg-accent text-primary">Lunas</Badge>;
|
||||
return <Badge className="bg-brand-accent text-white">Lunas</Badge>;
|
||||
case "pending":
|
||||
return <Badge className="bg-secondary text-primary">Pending</Badge>;
|
||||
case "cancelled":
|
||||
|
||||
@@ -11,6 +11,7 @@ import { formatIDR } from "@/lib/format";
|
||||
import { Video, Calendar, BookOpen, ArrowRight, Package, Receipt, ShoppingBag } from "lucide-react";
|
||||
import { WhatsAppBanner } from "@/components/WhatsAppBanner";
|
||||
import { ConsultingHistory } from "@/components/reviews/ConsultingHistory";
|
||||
import { UnpaidOrderAlert } from "@/components/UnpaidOrderAlert";
|
||||
|
||||
interface UserAccess {
|
||||
id: string;
|
||||
@@ -31,11 +32,17 @@ interface Order {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface UnpaidConsultingOrder {
|
||||
order_id: string;
|
||||
qr_expires_at: string;
|
||||
}
|
||||
|
||||
export default function MemberDashboard() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [access, setAccess] = useState<UserAccess[]>([]);
|
||||
const [recentOrders, setRecentOrders] = useState<Order[]>([]);
|
||||
const [unpaidConsultingOrders, setUnpaidConsultingOrders] = useState<UnpaidConsultingOrder[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasWhatsApp, setHasWhatsApp] = useState(true);
|
||||
|
||||
@@ -44,6 +51,57 @@ export default function MemberDashboard() {
|
||||
else if (user) fetchData();
|
||||
}, [user, authLoading]);
|
||||
|
||||
// Fetch unpaid consulting orders
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const fetchUnpaidOrders = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('consulting_slots')
|
||||
.select(`
|
||||
order_id,
|
||||
orders (
|
||||
id,
|
||||
payment_status,
|
||||
qr_expires_at
|
||||
)
|
||||
`)
|
||||
.eq('orders.payment_status', 'pending')
|
||||
.eq('status', 'pending_payment')
|
||||
.gt('orders.qr_expires_at', new Date().toISOString())
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!error && data) {
|
||||
// Get unique order IDs
|
||||
const uniqueOrders = Array.from(
|
||||
new Set(data.map((item: any) => item.order_id))
|
||||
).map((orderId) => {
|
||||
// Find the corresponding order data
|
||||
const orderData = data.find((item: any) => item.order_id === orderId);
|
||||
return {
|
||||
order_id: orderId,
|
||||
qr_expires_at: (orderData as any)?.orders?.qr_expires_at || ''
|
||||
};
|
||||
});
|
||||
setUnpaidConsultingOrders(uniqueOrders);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUnpaidOrders();
|
||||
}, [user]);
|
||||
|
||||
// Auto-hide expired orders every 30 seconds
|
||||
useEffect(() => {
|
||||
const checkExpiry = () => {
|
||||
setUnpaidConsultingOrders(prev =>
|
||||
prev.filter(order => new Date(order.qr_expires_at) > new Date())
|
||||
);
|
||||
};
|
||||
|
||||
const interval = setInterval(checkExpiry, 30000); // Check every 30s
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
const [accessRes, ordersRes, paidOrdersRes, profileRes] = await Promise.all([
|
||||
supabase
|
||||
@@ -123,6 +181,16 @@ export default function MemberDashboard() {
|
||||
<h1 className="text-4xl font-bold mb-2">Dashboard</h1>
|
||||
<p className="text-muted-foreground mb-8">Selamat datang kembali!</p>
|
||||
|
||||
{/* Unpaid Order Alert - shown when user has unpaid consulting orders */}
|
||||
{unpaidConsultingOrders.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<UnpaidOrderAlert
|
||||
orderId={unpaidConsultingOrders[0].order_id}
|
||||
expiresAt={unpaidConsultingOrders[0].qr_expires_at}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasWhatsApp && <WhatsAppBanner />}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mb-8">
|
||||
@@ -225,7 +293,7 @@ export default function MemberDashboard() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge className={order.payment_status === "paid" ? "bg-accent" : "bg-muted"}>
|
||||
<Badge className={order.payment_status === "paid" ? "bg-brand-accent text-white" : "bg-muted text-primary"}>
|
||||
{order.payment_status === "paid" ? "Lunas" : "Pending"}
|
||||
</Badge>
|
||||
<span className="font-bold">{formatIDR(order.total_amount)}</span>
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function MemberOrders() {
|
||||
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":
|
||||
@@ -57,7 +57,7 @@ export default function MemberOrders() {
|
||||
case "paid":
|
||||
return "Lunas";
|
||||
case "pending":
|
||||
return "Menunggu Pembayaran";
|
||||
return "Pending";
|
||||
case "failed":
|
||||
return "Gagal";
|
||||
case "cancelled":
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user