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:
15
index.html
15
index.html
@@ -3,19 +3,18 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- TODO: Set the document title to the name of your application -->
|
||||
<title>Lovable App</title>
|
||||
<meta name="description" content="Lovable Generated Project" />
|
||||
<meta name="author" content="Lovable" />
|
||||
<!-- Title will be dynamically updated from branding settings -->
|
||||
<title>Loading...</title>
|
||||
<meta name="description" content="Learn. Grow. Succeed." />
|
||||
<meta name="author" content="LearnHub" />
|
||||
|
||||
<!-- TODO: Update og:title to match your application name -->
|
||||
<meta property="og:title" content="Lovable App" />
|
||||
<meta property="og:description" content="Lovable Generated Project" />
|
||||
<meta property="og:title" content="LearnHub" />
|
||||
<meta property="og:description" content="Learn. Grow. Succeed." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content="@Lovable" />
|
||||
<meta name="twitter:site" content="@LearnHub" />
|
||||
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
||||
</head>
|
||||
|
||||
|
||||
58
src/components/UnpaidOrderAlert.tsx
Normal file
58
src/components/UnpaidOrderAlert.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
interface UnpaidOrderAlertProps {
|
||||
orderId: string;
|
||||
expiresAt: string; // ISO timestamp
|
||||
}
|
||||
|
||||
export function UnpaidOrderAlert({ orderId, expiresAt }: UnpaidOrderAlertProps) {
|
||||
// Non-dismissable alert - NO onDismiss prop
|
||||
// Alert will auto-hide when QR expires via Dashboard logic
|
||||
|
||||
const formatExpiryTime = (isoString: string) => {
|
||||
try {
|
||||
return new Date(isoString).toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 border-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-orange-100 p-2 rounded-full flex-shrink-0">
|
||||
<AlertCircle className="w-5 h-5 text-orange-600" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-orange-900 mb-1 flex items-center gap-2">
|
||||
Pembayaran Belum Selesai
|
||||
<span className="text-xs bg-orange-200 text-orange-800 px-2 py-0.5 rounded">
|
||||
Segera
|
||||
</span>
|
||||
</h4>
|
||||
<AlertDescription className="text-orange-700">
|
||||
Anda memiliki pesanan konsultasi yang menunggu pembayaran. QRIS kode akan kedaluwarsa pada{" "}
|
||||
<strong>{formatExpiryTime(expiresAt)}</strong>.
|
||||
</AlertDescription>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="mt-3 bg-orange-600 hover:bg-orange-700 text-white shadow-md"
|
||||
>
|
||||
<Link to={`/orders/${orderId}`}>
|
||||
Lihat & Bayar Sekarang →
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Palette, Image, Mail, Home, Plus, Trash2 } from 'lucide-react';
|
||||
import { Palette, Image, Mail, Home, Plus, Trash2, Upload, X } from 'lucide-react';
|
||||
|
||||
interface HomepageFeature {
|
||||
icon: string;
|
||||
@@ -51,6 +51,11 @@ export function BrandingTab() {
|
||||
const [settings, setSettings] = useState<PlatformSettings>(emptySettings);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||
const [uploadingFavicon, setUploadingFavicon] = useState(false);
|
||||
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
const faviconInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
@@ -150,6 +155,108 @@ export function BrandingTab() {
|
||||
setSettings({ ...settings, homepage_features: newFeatures });
|
||||
};
|
||||
|
||||
// Handle logo upload with auto-delete
|
||||
const handleLogoUpload = async (file: File) => {
|
||||
setUploadingLogo(true);
|
||||
try {
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const filePath = `brand-assets/logo/logo-current.${fileExt}`;
|
||||
|
||||
// Step 1: Delete old logo if exists
|
||||
const { data: existingFiles } = await supabase.storage
|
||||
.from('content')
|
||||
.list('brand-assets/logo/');
|
||||
|
||||
if (existingFiles?.length > 0) {
|
||||
const oldFile = existingFiles.find(f => f.name.startsWith('logo-current'));
|
||||
if (oldFile) {
|
||||
await supabase.storage.from('content').remove([`brand-assets/logo/${oldFile.name}`]);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Upload new logo
|
||||
const { data, error } = await supabase.storage
|
||||
.from('content')
|
||||
.upload(filePath, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Step 3: Get public URL and update settings
|
||||
const { data: urlData } = supabase.storage.from('content').getPublicUrl(filePath);
|
||||
setSettings({ ...settings, brand_logo_url: urlData.publicUrl });
|
||||
|
||||
toast({ title: 'Berhasil', description: 'Logo berhasil diupload' });
|
||||
} catch (error) {
|
||||
console.error('Logo upload error:', error);
|
||||
toast({ title: 'Error', description: 'Gagal upload logo', variant: 'destructive' });
|
||||
} finally {
|
||||
setUploadingLogo(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle favicon upload with auto-delete
|
||||
const handleFaviconUpload = async (file: File) => {
|
||||
setUploadingFavicon(true);
|
||||
try {
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const filePath = `brand-assets/favicon/favicon-current.${fileExt}`;
|
||||
|
||||
// Step 1: Delete old favicon if exists
|
||||
const { data: existingFiles } = await supabase.storage
|
||||
.from('content')
|
||||
.list('brand-assets/favicon/');
|
||||
|
||||
if (existingFiles?.length > 0) {
|
||||
const oldFile = existingFiles.find(f => f.name.startsWith('favicon-current'));
|
||||
if (oldFile) {
|
||||
await supabase.storage.from('content').remove([`brand-assets/favicon/${oldFile.name}`]);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Upload new favicon
|
||||
const { data, error } = await supabase.storage
|
||||
.from('content')
|
||||
.upload(filePath, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Step 3: Get public URL and update settings
|
||||
const { data: urlData } = supabase.storage.from('content').getPublicUrl(filePath);
|
||||
setSettings({ ...settings, brand_favicon_url: urlData.publicUrl });
|
||||
|
||||
toast({ title: 'Berhasil', description: 'Favicon berhasil diupload' });
|
||||
} catch (error) {
|
||||
console.error('Favicon upload error:', error);
|
||||
toast({ title: 'Error', description: 'Gagal upload favicon', variant: 'destructive' });
|
||||
} finally {
|
||||
setUploadingFavicon(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleLogoUpload(file);
|
||||
};
|
||||
|
||||
const handleFaviconSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFaviconUpload(file);
|
||||
};
|
||||
|
||||
const handleRemoveLogo = () => {
|
||||
setSettings({ ...settings, brand_logo_url: '' });
|
||||
};
|
||||
|
||||
const handleRemoveFavicon = () => {
|
||||
setSettings({ ...settings, brand_favicon_url: '' });
|
||||
};
|
||||
|
||||
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
|
||||
|
||||
return (
|
||||
@@ -195,47 +302,141 @@ export function BrandingTab() {
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Image className="w-4 h-4" />
|
||||
Logo Utama (URL)
|
||||
Logo Utama
|
||||
</Label>
|
||||
<Input
|
||||
value={settings.brand_logo_url}
|
||||
onChange={(e) => setSettings({ ...settings, brand_logo_url: e.target.value })}
|
||||
placeholder="https://example.com/logo.png"
|
||||
className="border-2"
|
||||
|
||||
<input
|
||||
ref={logoInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/svg+xml,image/jpeg,image/webp"
|
||||
onChange={handleLogoSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
{settings.brand_logo_url && (
|
||||
<div className="mt-2 p-2 bg-muted rounded-md">
|
||||
<img
|
||||
src={settings.brand_logo_url}
|
||||
alt="Logo preview"
|
||||
className="h-12 object-contain"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{settings.brand_logo_url ? (
|
||||
<div className="relative">
|
||||
<div className="p-4 bg-muted rounded-md flex items-center justify-center">
|
||||
<img
|
||||
src={settings.brand_logo_url}
|
||||
alt="Logo preview"
|
||||
className="h-16 object-contain"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
toast({ title: 'Error', description: 'Gagal memuat logo', variant: 'destructive' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => logoInputRef.current?.click()}
|
||||
disabled={uploadingLogo}
|
||||
className="flex-1 border-2"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploadingLogo ? 'Mengupload...' : 'Ganti'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemoveLogo}
|
||||
disabled={uploadingLogo}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => logoInputRef.current?.click()}
|
||||
disabled={uploadingLogo}
|
||||
className="w-full border-2"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploadingLogo ? 'Mengupload...' : 'Upload Logo'}
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
PNG, SVG, JPG, atau WebP. Maks 2MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Image className="w-4 h-4" />
|
||||
Favicon (URL)
|
||||
Favicon
|
||||
</Label>
|
||||
<Input
|
||||
value={settings.brand_favicon_url}
|
||||
onChange={(e) => setSettings({ ...settings, brand_favicon_url: e.target.value })}
|
||||
placeholder="https://example.com/favicon.ico"
|
||||
className="border-2"
|
||||
|
||||
<input
|
||||
ref={faviconInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/svg+xml,image/jpeg,image/x-icon"
|
||||
onChange={handleFaviconSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
{settings.brand_favicon_url && (
|
||||
<div className="mt-2 p-2 bg-muted rounded-md">
|
||||
<img
|
||||
src={settings.brand_favicon_url}
|
||||
alt="Favicon preview"
|
||||
className="h-8 w-8 object-contain"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{settings.brand_favicon_url ? (
|
||||
<div className="relative">
|
||||
<div className="p-4 bg-muted rounded-md flex items-center justify-center">
|
||||
<img
|
||||
src={settings.brand_favicon_url}
|
||||
alt="Favicon preview"
|
||||
className="h-12 w-12 object-contain"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
toast({ title: 'Error', description: 'Gagal memuat favicon', variant: 'destructive' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => faviconInputRef.current?.click()}
|
||||
disabled={uploadingFavicon}
|
||||
className="flex-1 border-2"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploadingFavicon ? 'Mengupload...' : 'Ganti'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemoveFavicon}
|
||||
disabled={uploadingFavicon}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => faviconInputRef.current?.click()}
|
||||
disabled={uploadingFavicon}
|
||||
className="w-full border-2"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploadingFavicon ? 'Mengupload...' : 'Upload Favicon'}
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
PNG, SVG, JPG, atau ICO. Maks 1MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -84,11 +84,11 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return <Badge className="bg-accent">Selesai</Badge>;
|
||||
return <Badge className="bg-brand-accent text-white">Selesai</Badge>;
|
||||
case 'confirmed':
|
||||
return <Badge className="bg-primary">Terkonfirmasi</Badge>;
|
||||
case 'pending_payment':
|
||||
return <Badge className="bg-secondary">Menunggu Pembayaran</Badge>;
|
||||
return <Badge className="bg-secondary">Pending</Badge>;
|
||||
case 'cancelled':
|
||||
return <Badge variant="destructive">Dibatalkan</Badge>;
|
||||
default:
|
||||
|
||||
@@ -8,7 +8,7 @@ const badgeVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80 hover:text-white",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
|
||||
@@ -79,6 +79,11 @@ export function BrandingProvider({ children }: { children: ReactNode }) {
|
||||
homepage_features: features,
|
||||
});
|
||||
|
||||
// Update CSS variable for accent color
|
||||
if (data.brand_accent_color) {
|
||||
document.documentElement.style.setProperty('--brand-accent', data.brand_accent_color);
|
||||
}
|
||||
|
||||
// Update favicon if set
|
||||
if (data.brand_favicon_url) {
|
||||
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||
|
||||
@@ -159,3 +159,10 @@ All colors MUST be HSL.
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dynamic brand accent color for badges */
|
||||
@layer utilities {
|
||||
.bg-brand-accent {
|
||||
background-color: var(--brand-accent, hsl(var(--accent)));
|
||||
}
|
||||
}
|
||||
@@ -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