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>
215 lines
7.9 KiB
TypeScript
215 lines
7.9 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { AppLayout } from '@/components/AppLayout';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
import { supabase } from '@/integrations/supabase/client';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { formatIDR, formatDate } from '@/lib/format';
|
|
import { Video, Calendar, BookOpen, ArrowRight } from 'lucide-react';
|
|
|
|
interface UserAccess {
|
|
id: string;
|
|
granted_at: string;
|
|
expires_at: string | null;
|
|
product: {
|
|
id: string;
|
|
title: string;
|
|
slug: string;
|
|
type: string;
|
|
meeting_link: string | null;
|
|
recording_url: string | null;
|
|
description: string;
|
|
};
|
|
}
|
|
|
|
interface Order {
|
|
id: string;
|
|
total_amount: number;
|
|
status: string;
|
|
payment_status: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export default function Dashboard() {
|
|
const { user, loading: authLoading } = useAuth();
|
|
const navigate = useNavigate();
|
|
const [access, setAccess] = useState<UserAccess[]>([]);
|
|
const [orders, setOrders] = useState<Order[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
if (!authLoading && !user) navigate('/auth');
|
|
else if (user) fetchData();
|
|
}, [user, authLoading, navigate]);
|
|
|
|
const fetchData = async () => {
|
|
const [accessRes, ordersRes] = await Promise.all([
|
|
supabase.from('user_access').select(`id, granted_at, expires_at, product:products (id, title, slug, type, meeting_link, recording_url, description)`).eq('user_id', user!.id),
|
|
supabase.from('orders').select('*').eq('user_id', user!.id).order('created_at', { ascending: false })
|
|
]);
|
|
if (accessRes.data) setAccess(accessRes.data as unknown as UserAccess[]);
|
|
if (ordersRes.data) setOrders(ordersRes.data);
|
|
setLoading(false);
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'paid': return 'bg-brand-accent text-white';
|
|
case 'pending': return 'bg-secondary';
|
|
case 'cancelled': return 'bg-destructive';
|
|
default: return 'bg-secondary';
|
|
}
|
|
};
|
|
|
|
const getPaymentStatusLabel = (status: string | null) => {
|
|
switch (status) {
|
|
case 'paid': return 'Lunas';
|
|
case 'pending': return 'Pending';
|
|
case 'failed': return 'Gagal';
|
|
default: return status || 'Pending';
|
|
}
|
|
};
|
|
|
|
const renderAccessActions = (item: UserAccess) => {
|
|
switch (item.product.type) {
|
|
case 'consulting':
|
|
return (
|
|
<Button asChild variant="outline" className="border-2">
|
|
<a href={item.product.meeting_link || '#'} target="_blank" rel="noopener noreferrer">
|
|
<Calendar className="w-4 h-4 mr-2" />
|
|
Jadwalkan Konsultasi
|
|
</a>
|
|
</Button>
|
|
);
|
|
case 'webinar':
|
|
return (
|
|
<div className="flex gap-2">
|
|
{item.product.meeting_link && (
|
|
<Button asChild variant="outline" className="border-2">
|
|
<a href={item.product.meeting_link} target="_blank" rel="noopener noreferrer">
|
|
<Video className="w-4 h-4 mr-2" />
|
|
Gabung Webinar
|
|
</a>
|
|
</Button>
|
|
)}
|
|
{item.product.recording_url && (
|
|
<Button asChild variant="outline" className="border-2">
|
|
<a href={item.product.recording_url} target="_blank" rel="noopener noreferrer">
|
|
<Video className="w-4 h-4 mr-2" />
|
|
Tonton Rekaman
|
|
</a>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
case 'bootcamp':
|
|
return (
|
|
<Button onClick={() => navigate(`/bootcamp/${item.product.slug}`)} className="shadow-sm">
|
|
<BookOpen className="w-4 h-4 mr-2" />
|
|
Lanjutkan Bootcamp
|
|
<ArrowRight className="w-4 h-4 ml-2" />
|
|
</Button>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
if (authLoading || loading) {
|
|
return (
|
|
<AppLayout>
|
|
<div className="container mx-auto px-4 py-8">
|
|
<Skeleton className="h-10 w-1/3 mb-8" />
|
|
<div className="grid gap-4">
|
|
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
|
|
</div>
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AppLayout>
|
|
<div className="container mx-auto px-4 py-8">
|
|
<h1 className="text-4xl font-bold mb-2">Dashboard</h1>
|
|
<p className="text-muted-foreground mb-8">Kelola akses dan riwayat pembelian Anda</p>
|
|
|
|
<Tabs defaultValue="access" className="space-y-6">
|
|
<TabsList className="border-2 border-border">
|
|
<TabsTrigger value="access">Akses Saya</TabsTrigger>
|
|
<TabsTrigger value="orders">Riwayat Order</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="access">
|
|
{access.length === 0 ? (
|
|
<Card className="border-2 border-border">
|
|
<CardContent className="py-12 text-center">
|
|
<p className="text-muted-foreground mb-4">Anda belum memiliki akses ke produk apapun</p>
|
|
<Button onClick={() => navigate('/products')} variant="outline" className="border-2">
|
|
Lihat Produk
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="grid gap-4">
|
|
{access.map((item) => (
|
|
<Card key={item.id} className="border-2 border-border">
|
|
<CardHeader>
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<CardTitle>{item.product.title}</CardTitle>
|
|
<CardDescription className="capitalize">{item.product.type}</CardDescription>
|
|
</div>
|
|
<Badge className="bg-accent">Aktif</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-muted-foreground mb-4 line-clamp-2">{item.product.description}</p>
|
|
{renderAccessActions(item)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="orders">
|
|
{orders.length === 0 ? (
|
|
<Card className="border-2 border-border">
|
|
<CardContent className="py-12 text-center">
|
|
<p className="text-muted-foreground">Belum ada order</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{orders.map((order) => (
|
|
<Card key={order.id} className="border-2 border-border">
|
|
<CardContent className="py-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="font-mono text-sm text-muted-foreground">{order.id.slice(0, 8)}</p>
|
|
<p className="text-sm text-muted-foreground">{formatDate(order.created_at)}</p>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<Badge className={getStatusColor(order.payment_status || order.status)}>
|
|
{getPaymentStatusLabel(order.payment_status || order.status)}
|
|
</Badge>
|
|
<span className="font-bold">{formatIDR(order.total_amount)}</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|