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:
dwindown
2025-12-24 11:42:20 +07:00
parent 4b8765885b
commit fb24e77e42
15 changed files with 779 additions and 149 deletions

View File

@@ -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>

View 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>
);
}

View File

@@ -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>

View File

@@ -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:

View File

@@ -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",

View File

@@ -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;

View File

@@ -158,4 +158,11 @@ All colors MUST be HSL.
body {
@apply bg-background text-foreground;
}
}
/* Dynamic brand accent color for badges */
@layer utilities {
.bg-brand-accent {
background-color: var(--brand-accent, hsl(var(--accent)));
}
}

View File

@@ -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>

View File

@@ -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'}

View File

@@ -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';
}

View File

@@ -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>

View File

@@ -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":

View File

@@ -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>

View File

@@ -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":

View File

@@ -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>