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>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<!-- TODO: Set the document title to the name of your application -->
|
<!-- Title will be dynamically updated from branding settings -->
|
||||||
<title>Lovable App</title>
|
<title>Loading...</title>
|
||||||
<meta name="description" content="Lovable Generated Project" />
|
<meta name="description" content="Learn. Grow. Succeed." />
|
||||||
<meta name="author" content="Lovable" />
|
<meta name="author" content="LearnHub" />
|
||||||
|
|
||||||
<!-- TODO: Update og:title to match your application name -->
|
<meta property="og:title" content="LearnHub" />
|
||||||
<meta property="og:title" content="Lovable App" />
|
<meta property="og:description" content="Learn. Grow. Succeed." />
|
||||||
<meta property="og:description" content="Lovable Generated Project" />
|
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
||||||
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<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" />
|
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
||||||
</head>
|
</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 { supabase } from '@/integrations/supabase/client';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { toast } from '@/hooks/use-toast';
|
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 {
|
interface HomepageFeature {
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -51,6 +51,11 @@ export function BrandingTab() {
|
|||||||
const [settings, setSettings] = useState<PlatformSettings>(emptySettings);
|
const [settings, setSettings] = useState<PlatformSettings>(emptySettings);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
@@ -150,6 +155,108 @@ export function BrandingTab() {
|
|||||||
setSettings({ ...settings, homepage_features: newFeatures });
|
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" />;
|
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -195,47 +302,141 @@ export function BrandingTab() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="flex items-center gap-2">
|
<Label className="flex items-center gap-2">
|
||||||
<Image className="w-4 h-4" />
|
<Image className="w-4 h-4" />
|
||||||
Logo Utama (URL)
|
Logo Utama
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
|
||||||
value={settings.brand_logo_url}
|
<input
|
||||||
onChange={(e) => setSettings({ ...settings, brand_logo_url: e.target.value })}
|
ref={logoInputRef}
|
||||||
placeholder="https://example.com/logo.png"
|
type="file"
|
||||||
className="border-2"
|
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">
|
<div className="space-y-2">
|
||||||
<img
|
{settings.brand_logo_url ? (
|
||||||
src={settings.brand_logo_url}
|
<div className="relative">
|
||||||
alt="Logo preview"
|
<div className="p-4 bg-muted rounded-md flex items-center justify-center">
|
||||||
className="h-12 object-contain"
|
<img
|
||||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
src={settings.brand_logo_url}
|
||||||
/>
|
alt="Logo preview"
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="flex items-center gap-2">
|
<Label className="flex items-center gap-2">
|
||||||
<Image className="w-4 h-4" />
|
<Image className="w-4 h-4" />
|
||||||
Favicon (URL)
|
Favicon
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
|
||||||
value={settings.brand_favicon_url}
|
<input
|
||||||
onChange={(e) => setSettings({ ...settings, brand_favicon_url: e.target.value })}
|
ref={faviconInputRef}
|
||||||
placeholder="https://example.com/favicon.ico"
|
type="file"
|
||||||
className="border-2"
|
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">
|
<div className="space-y-2">
|
||||||
<img
|
{settings.brand_favicon_url ? (
|
||||||
src={settings.brand_favicon_url}
|
<div className="relative">
|
||||||
alt="Favicon preview"
|
<div className="p-4 bg-muted rounded-md flex items-center justify-center">
|
||||||
className="h-8 w-8 object-contain"
|
<img
|
||||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
src={settings.brand_favicon_url}
|
||||||
/>
|
alt="Favicon preview"
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -84,11 +84,11 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
|
|||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'done':
|
case 'done':
|
||||||
return <Badge className="bg-accent">Selesai</Badge>;
|
return <Badge className="bg-brand-accent text-white">Selesai</Badge>;
|
||||||
case 'confirmed':
|
case 'confirmed':
|
||||||
return <Badge className="bg-primary">Terkonfirmasi</Badge>;
|
return <Badge className="bg-primary">Terkonfirmasi</Badge>;
|
||||||
case 'pending_payment':
|
case 'pending_payment':
|
||||||
return <Badge className="bg-secondary">Menunggu Pembayaran</Badge>;
|
return <Badge className="bg-secondary">Pending</Badge>;
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
return <Badge variant="destructive">Dibatalkan</Badge>;
|
return <Badge variant="destructive">Dibatalkan</Badge>;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const badgeVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
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",
|
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
outline: "text-foreground",
|
outline: "text-foreground",
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ export function BrandingProvider({ children }: { children: ReactNode }) {
|
|||||||
homepage_features: features,
|
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
|
// Update favicon if set
|
||||||
if (data.brand_favicon_url) {
|
if (data.brand_favicon_url) {
|
||||||
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||||
|
|||||||
@@ -158,4 +158,11 @@ All colors MUST be HSL.
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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>Total</span>
|
||||||
<span className="font-bold">{formatIDR(total)}</span>
|
<span className="font-bold">{formatIDR(total)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleCheckout} className="w-full shadow-sm" disabled={loading}>
|
<div className="space-y-3 pt-2 border-t">
|
||||||
{loading ? (
|
<Button onClick={handleCheckout} className="w-full shadow-sm" disabled={loading}>
|
||||||
<>
|
{loading ? (
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<>
|
||||||
Memproses...
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
</>
|
Memproses...
|
||||||
) : user ? (
|
</>
|
||||||
<>
|
) : user ? (
|
||||||
<CreditCard className="w-4 h-4 mr-2" />
|
<>
|
||||||
Bayar dengan QRIS
|
<CreditCard className="w-4 h-4 mr-2" />
|
||||||
</>
|
Bayar dengan QRIS
|
||||||
) : (
|
</>
|
||||||
"Login untuk Checkout"
|
) : (
|
||||||
)}
|
"Login untuk Checkout"
|
||||||
</Button>
|
)}
|
||||||
<p className="text-xs text-muted-foreground text-center">Pembayaran diproses melalui Pakasir</p>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,7 +58,14 @@ export default function ConsultingBooking() {
|
|||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
|
|
||||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(addDays(new Date(), 1));
|
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 [selectedCategory, setSelectedCategory] = useState('');
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [whatsappInput, setWhatsappInput] = useState('');
|
const [whatsappInput, setWhatsappInput] = useState('');
|
||||||
@@ -147,15 +154,81 @@ export default function ConsultingBooking() {
|
|||||||
return slots;
|
return slots;
|
||||||
}, [selectedDate, workhours, confirmedSlots, settings]);
|
}, [selectedDate, workhours, confirmedSlots, settings]);
|
||||||
|
|
||||||
const toggleSlot = (slotStart: string) => {
|
// Helper: Get all slots between start and end (inclusive)
|
||||||
setSelectedSlots(prev =>
|
const getSlotsInRange = useMemo(() => {
|
||||||
prev.includes(slotStart)
|
if (!selectedRange.start || !selectedRange.end) return [];
|
||||||
? prev.filter(s => s !== slotStart)
|
|
||||||
: [...prev, slotStart]
|
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 totalPrice = totalBlocks * (settings?.consulting_block_price || 0);
|
||||||
const totalDuration = totalBlocks * (settings?.consulting_block_duration_minutes || 30);
|
const totalDuration = totalBlocks * (settings?.consulting_block_duration_minutes || 30);
|
||||||
|
|
||||||
@@ -166,7 +239,7 @@ export default function ConsultingBooking() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedSlots.length === 0) {
|
if (getSlotsInRange.length === 0) {
|
||||||
toast({ title: 'Pilih slot', description: 'Pilih minimal satu slot waktu', variant: 'destructive' });
|
toast({ title: 'Pilih slot', description: 'Pilih minimal satu slot waktu', variant: 'destructive' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -206,7 +279,7 @@ export default function ConsultingBooking() {
|
|||||||
if (orderError) throw orderError;
|
if (orderError) throw orderError;
|
||||||
|
|
||||||
// Create consulting slots
|
// Create consulting slots
|
||||||
const slotsToInsert = selectedSlots.map(slotStart => {
|
const slotsToInsert = getSlotsInRange.map(slotStart => {
|
||||||
const slotEnd = format(
|
const slotEnd = format(
|
||||||
addMinutes(parse(slotStart, 'HH:mm', new Date()), settings.consulting_block_duration_minutes),
|
addMinutes(parse(slotStart, 'HH:mm', new Date()), settings.consulting_block_duration_minutes),
|
||||||
'HH:mm'
|
'HH:mm'
|
||||||
@@ -320,7 +393,7 @@ export default function ConsultingBooking() {
|
|||||||
Slot Waktu - {format(selectedDate, 'EEEE, d MMMM yyyy', { locale: id })}
|
Slot Waktu - {format(selectedDate, 'EEEE, d MMMM yyyy', { locale: id })}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -329,18 +402,47 @@ export default function ConsultingBooking() {
|
|||||||
Tidak ada slot tersedia pada hari ini
|
Tidak ada slot tersedia pada hari ini
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-0">
|
||||||
{availableSlots.map((slot) => (
|
{availableSlots.map((slot, index) => {
|
||||||
<Button
|
const isSelected = getSlotsInRange.includes(slot.start);
|
||||||
key={slot.start}
|
const isStart = slot.start === selectedRange.start;
|
||||||
variant={selectedSlots.includes(slot.start) ? 'default' : 'outline'}
|
const isEnd = slot.start === selectedRange.end;
|
||||||
disabled={!slot.available}
|
const isMiddle = isSelected && !isStart && !isEnd;
|
||||||
onClick={() => slot.available && toggleSlot(slot.start)}
|
|
||||||
className="border-2"
|
// Determine button variant
|
||||||
>
|
let variant: "default" | "outline" = "outline";
|
||||||
{slot.start}
|
if (isSelected) variant = "default";
|
||||||
</Button>
|
|
||||||
))}
|
// 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -414,28 +516,37 @@ export default function ConsultingBooking() {
|
|||||||
{selectedDate ? format(selectedDate, 'd MMM yyyy', { locale: id }) : '-'}
|
{selectedDate ? format(selectedDate, 'd MMM yyyy', { locale: id }) : '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Kategori</span>
|
<span className="text-muted-foreground">Kategori</span>
|
||||||
<span className="font-medium">{selectedCategory || '-'}</span>
|
<span className="font-medium">{selectedCategory || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedSlots.length > 0 && (
|
{selectedRange.start && selectedRange.end && (
|
||||||
<div className="pt-4 border-t">
|
<div className="pt-4 border-t">
|
||||||
<p className="text-sm text-muted-foreground mb-2">Slot dipilih:</p>
|
<p className="text-sm text-muted-foreground mb-2">Waktu dipilih:</p>
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{selectedSlots.sort().map((slot) => (
|
{/* Show range */}
|
||||||
<span key={slot} className="px-2 py-1 bg-primary/10 text-primary rounded text-sm">
|
<div className="bg-primary/10 p-3 rounded-lg border-2 border-primary/20">
|
||||||
{slot}
|
<div className="flex items-center justify-between">
|
||||||
</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -452,7 +563,7 @@ export default function ConsultingBooking() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleBookNow}
|
onClick={handleBookNow}
|
||||||
disabled={submitting || selectedSlots.length === 0 || !selectedCategory}
|
disabled={submitting || getSlotsInRange.length === 0 || !selectedCategory}
|
||||||
className="w-full shadow-sm"
|
className="w-full shadow-sm"
|
||||||
>
|
>
|
||||||
{submitting ? 'Memproses...' : 'Booking Sekarang'}
|
{submitting ? 'Memproses...' : 'Booking Sekarang'}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'paid': return 'bg-accent';
|
case 'paid': return 'bg-brand-accent text-white';
|
||||||
case 'pending': return 'bg-secondary';
|
case 'pending': return 'bg-secondary';
|
||||||
case 'cancelled': return 'bg-destructive';
|
case 'cancelled': return 'bg-destructive';
|
||||||
default: return 'bg-secondary';
|
default: return 'bg-secondary';
|
||||||
@@ -68,7 +68,7 @@ export default function Dashboard() {
|
|||||||
const getPaymentStatusLabel = (status: string | null) => {
|
const getPaymentStatusLabel = (status: string | null) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'paid': return 'Lunas';
|
case 'paid': return 'Lunas';
|
||||||
case 'pending': return 'Menunggu Pembayaran';
|
case 'pending': return 'Pending';
|
||||||
case 'failed': return 'Gagal';
|
case 'failed': return 'Gagal';
|
||||||
default: return status || 'Pending';
|
default: return status || 'Pending';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export default function AdminDashboard() {
|
|||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="font-bold">{formatIDR(order.total_amount)}</p>
|
<p className="font-bold">{formatIDR(order.total_amount)}</p>
|
||||||
<span
|
<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"}
|
{order.payment_status === "paid" ? "Lunas" : "Pending"}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export default function AdminOrders() {
|
|||||||
const getStatusBadge = (status: string | null) => {
|
const getStatusBadge = (status: string | null) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "paid":
|
case "paid":
|
||||||
return <Badge className="bg-accent text-primary">Lunas</Badge>;
|
return <Badge className="bg-brand-accent text-white">Lunas</Badge>;
|
||||||
case "pending":
|
case "pending":
|
||||||
return <Badge className="bg-secondary text-primary">Pending</Badge>;
|
return <Badge className="bg-secondary text-primary">Pending</Badge>;
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { formatIDR } from "@/lib/format";
|
|||||||
import { Video, Calendar, BookOpen, ArrowRight, Package, Receipt, ShoppingBag } from "lucide-react";
|
import { Video, Calendar, BookOpen, ArrowRight, Package, Receipt, ShoppingBag } from "lucide-react";
|
||||||
import { WhatsAppBanner } from "@/components/WhatsAppBanner";
|
import { WhatsAppBanner } from "@/components/WhatsAppBanner";
|
||||||
import { ConsultingHistory } from "@/components/reviews/ConsultingHistory";
|
import { ConsultingHistory } from "@/components/reviews/ConsultingHistory";
|
||||||
|
import { UnpaidOrderAlert } from "@/components/UnpaidOrderAlert";
|
||||||
|
|
||||||
interface UserAccess {
|
interface UserAccess {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,11 +32,17 @@ interface Order {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UnpaidConsultingOrder {
|
||||||
|
order_id: string;
|
||||||
|
qr_expires_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function MemberDashboard() {
|
export default function MemberDashboard() {
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [access, setAccess] = useState<UserAccess[]>([]);
|
const [access, setAccess] = useState<UserAccess[]>([]);
|
||||||
const [recentOrders, setRecentOrders] = useState<Order[]>([]);
|
const [recentOrders, setRecentOrders] = useState<Order[]>([]);
|
||||||
|
const [unpaidConsultingOrders, setUnpaidConsultingOrders] = useState<UnpaidConsultingOrder[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [hasWhatsApp, setHasWhatsApp] = useState(true);
|
const [hasWhatsApp, setHasWhatsApp] = useState(true);
|
||||||
|
|
||||||
@@ -44,6 +51,57 @@ export default function MemberDashboard() {
|
|||||||
else if (user) fetchData();
|
else if (user) fetchData();
|
||||||
}, [user, authLoading]);
|
}, [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 fetchData = async () => {
|
||||||
const [accessRes, ordersRes, paidOrdersRes, profileRes] = await Promise.all([
|
const [accessRes, ordersRes, paidOrdersRes, profileRes] = await Promise.all([
|
||||||
supabase
|
supabase
|
||||||
@@ -123,6 +181,16 @@ export default function MemberDashboard() {
|
|||||||
<h1 className="text-4xl font-bold mb-2">Dashboard</h1>
|
<h1 className="text-4xl font-bold mb-2">Dashboard</h1>
|
||||||
<p className="text-muted-foreground mb-8">Selamat datang kembali!</p>
|
<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 />}
|
{!hasWhatsApp && <WhatsAppBanner />}
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mb-8">
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mb-8">
|
||||||
@@ -225,7 +293,7 @@ export default function MemberDashboard() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<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"}
|
{order.payment_status === "paid" ? "Lunas" : "Pending"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="font-bold">{formatIDR(order.total_amount)}</span>
|
<span className="font-bold">{formatIDR(order.total_amount)}</span>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function MemberOrders() {
|
|||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "paid":
|
case "paid":
|
||||||
return "bg-accent text-primary";
|
return "bg-brand-accent text-white";
|
||||||
case "pending":
|
case "pending":
|
||||||
return "bg-secondary text-primary";
|
return "bg-secondary text-primary";
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
@@ -57,7 +57,7 @@ export default function MemberOrders() {
|
|||||||
case "paid":
|
case "paid":
|
||||||
return "Lunas";
|
return "Lunas";
|
||||||
case "pending":
|
case "pending":
|
||||||
return "Menunggu Pembayaran";
|
return "Pending";
|
||||||
case "failed":
|
case "failed":
|
||||||
return "Gagal";
|
return "Gagal";
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
|
|||||||
@@ -60,6 +60,17 @@ export default function OrderDetail() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [timeRemaining, setTimeRemaining] = useState<string>("");
|
const [timeRemaining, setTimeRemaining] = useState<string>("");
|
||||||
const [isPolling, setIsPolling] = useState(false);
|
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
|
// Memoized fetchOrder to avoid recreating on every render
|
||||||
const fetchOrder = useCallback(async () => {
|
const fetchOrder = useCallback(async () => {
|
||||||
@@ -195,7 +206,7 @@ export default function OrderDetail() {
|
|||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "paid":
|
case "paid":
|
||||||
return "bg-accent text-primary";
|
return "bg-brand-accent text-white";
|
||||||
case "pending":
|
case "pending":
|
||||||
return "bg-secondary text-primary";
|
return "bg-secondary text-primary";
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
@@ -211,7 +222,7 @@ export default function OrderDetail() {
|
|||||||
case "paid":
|
case "paid":
|
||||||
return "Lunas";
|
return "Lunas";
|
||||||
case "pending":
|
case "pending":
|
||||||
return "Menunggu Pembayaran";
|
return "Pending";
|
||||||
case "failed":
|
case "failed":
|
||||||
return "Gagal";
|
return "Gagal";
|
||||||
case "cancelled":
|
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) {
|
if (authLoading || loading) {
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
@@ -329,7 +375,7 @@ export default function OrderDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* QR Code Display for pending QRIS payments */}
|
{/* 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">
|
<div className="pt-4">
|
||||||
<Alert className="mb-4">
|
<Alert className="mb-4">
|
||||||
<Clock className="h-4 w-4" />
|
<Clock className="h-4 w-4" />
|
||||||
@@ -362,6 +408,11 @@ export default function OrderDetail() {
|
|||||||
</div>
|
</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 && (
|
{order.payment_url && (
|
||||||
<Button asChild variant="outline" className="w-full">
|
<Button asChild variant="outline" className="w-full">
|
||||||
<a href={order.payment_url} target="_blank" rel="noopener noreferrer">
|
<a href={order.payment_url} target="_blank" rel="noopener noreferrer">
|
||||||
@@ -374,6 +425,70 @@ export default function OrderDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Expired QR Handling */}
|
||||||
|
{order.payment_status === "pending" && order.payment_method === "qris" && isQrExpired && (
|
||||||
|
<div className="pt-4">
|
||||||
|
<Alert className="mb-4 border-orange-200 bg-orange-50">
|
||||||
|
<AlertCircle className="h-4 w-4 text-orange-600" />
|
||||||
|
<AlertDescription className="text-orange-900">
|
||||||
|
{isConsultingOrder
|
||||||
|
? "Waktu pembayaran telah habis. Slot konsultasi telah dilepaskan. Silakan buat booking baru."
|
||||||
|
: "QR Code telah kadaluarsa. Anda dapat me-regenerate QR code untuk melanjutkan pembayaran."}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{isConsultingOrder ? (
|
||||||
|
// Consulting order - show booking button
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Order ini telah dibatalkan secara otomatis karena waktu pembayaran habis.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate("/consulting-booking")} className="shadow-sm">
|
||||||
|
<Calendar className="w-4 h-4 mr-2" />
|
||||||
|
Buat Booking Baru
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Product order - show regenerate button
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold">{formatIDR(order.total_amount)}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Order ID: {order.id.slice(0, 8)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleRegenerateQR}
|
||||||
|
disabled={regeneratingQR}
|
||||||
|
className="w-full shadow-sm"
|
||||||
|
>
|
||||||
|
{regeneratingQR ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Memproses...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Regenerate QR Code
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate("/products")}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Package className="w-4 h-4 mr-2" />
|
||||||
|
Kembali ke Produk
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Fallback button for pending payments without QR */}
|
{/* Fallback button for pending payments without QR */}
|
||||||
{order.payment_status === "pending" && !order.qr_string && order.payment_url && (
|
{order.payment_status === "pending" && !order.qr_string && order.payment_url && (
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
@@ -388,49 +503,109 @@ export default function OrderDetail() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Order Items */}
|
{/* Smart Item/Service Display */}
|
||||||
<Card className="border-2 border-border mb-6">
|
{order.order_items.length > 0 ? (
|
||||||
<CardHeader>
|
// === Product Orders ===
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<Card className="border-2 border-border mb-6">
|
||||||
<Package className="w-5 h-5" />
|
<CardHeader>
|
||||||
Item Pesanan
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
</CardTitle>
|
<Package className="w-5 h-5" />
|
||||||
</CardHeader>
|
Item Pesanan
|
||||||
<CardContent>
|
</CardTitle>
|
||||||
<div className="space-y-4">
|
</CardHeader>
|
||||||
{order.order_items.map((item) => (
|
<CardContent>
|
||||||
<div key={item.id} className="flex items-center justify-between py-2">
|
<div className="space-y-4">
|
||||||
<div className="flex-1">
|
{order.order_items.map((item) => (
|
||||||
<Link
|
<div key={item.id} className="flex items-center justify-between py-2">
|
||||||
to={`/products/${item.products.slug}`}
|
<div className="flex-1">
|
||||||
className="font-medium hover:underline"
|
<Link
|
||||||
>
|
to={`/products/${item.products.slug}`}
|
||||||
{item.products.title}
|
className="font-medium hover:underline"
|
||||||
</Link>
|
>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
{item.products.title}
|
||||||
<Badge variant="outline" className="text-xs">
|
</Link>
|
||||||
{getTypeLabel(item.products.type)}
|
<div className="flex items-center gap-2 mt-1">
|
||||||
</Badge>
|
<Badge variant="outline" className="text-xs">
|
||||||
<span className="text-sm text-muted-foreground">
|
{getTypeLabel(item.products.type)}
|
||||||
x{item.quantity}
|
</Badge>
|
||||||
</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
x{item.quantity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="font-medium">{formatIDR(item.products.sale_price || item.products.price)}</p>
|
||||||
</div>
|
</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>
|
||||||
</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">
|
{/* Consulting Slots Detail */}
|
||||||
<span>Total</span>
|
|
||||||
<span>{formatIDR(order.total_amount)}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Consulting Slots */}
|
|
||||||
{consultingSlots.length > 0 && (
|
{consultingSlots.length > 0 && (
|
||||||
<Card className="border-2 border-primary bg-primary/5">
|
<Card className="border-2 border-primary bg-primary/5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
Reference in New Issue
Block a user