392 lines
15 KiB
TypeScript
392 lines
15 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 { formatIDR } from "@/lib/format";
|
|
import { Video, ArrowRight, Package, Receipt, ShoppingBag, Wallet } from "lucide-react";
|
|
import { WhatsAppBanner } from "@/components/WhatsAppBanner";
|
|
import { ConsultingHistory } from "@/components/reviews/ConsultingHistory";
|
|
import { UnpaidOrderAlert } from "@/components/UnpaidOrderAlert";
|
|
|
|
interface UserAccess {
|
|
id: string;
|
|
product: {
|
|
id: string;
|
|
title: string;
|
|
slug: string;
|
|
type: string;
|
|
meeting_link: string | null;
|
|
recording_url: string | null;
|
|
event_start: string | null;
|
|
duration_minutes: number | null;
|
|
};
|
|
}
|
|
|
|
interface Order {
|
|
id: string;
|
|
total_amount: number;
|
|
payment_status: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
interface UnpaidConsultingOrder {
|
|
order_id: string;
|
|
qr_expires_at: string;
|
|
}
|
|
|
|
interface ConsultingSlot {
|
|
id: string;
|
|
date: string;
|
|
start_time: string;
|
|
end_time: string;
|
|
status: string;
|
|
meet_link: string | null;
|
|
topic_category?: string | null;
|
|
}
|
|
|
|
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 [consultingSlots, setConsultingSlots] = useState<ConsultingSlot[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [hasWhatsApp, setHasWhatsApp] = useState(true);
|
|
const [isCollaborator, setIsCollaborator] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!authLoading && !user) navigate("/auth");
|
|
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('status', 'pending_payment')
|
|
.order('created_at', { ascending: false });
|
|
|
|
if (!error && data) {
|
|
// Filter in JavaScript: only include slots where order is pending AND not expired
|
|
const now = new Date().toISOString();
|
|
const validSlots = data.filter((item: any) =>
|
|
item.orders?.payment_status === 'pending' &&
|
|
item.orders?.qr_expires_at &&
|
|
item.orders.qr_expires_at > now
|
|
);
|
|
|
|
// Get unique order IDs
|
|
const uniqueOrders = Array.from(
|
|
new Set(validSlots.map((item: any) => item.order_id))
|
|
).map((orderId) => {
|
|
// Find the corresponding order data
|
|
const orderData = validSlots.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, slotsRes, walletRes, collaboratorProductRes] = await Promise.all([
|
|
supabase
|
|
.from("user_access")
|
|
.select(`id, product:products (id, title, slug, type, meeting_link, recording_url, event_start, duration_minutes)`)
|
|
.eq("user_id", user!.id),
|
|
supabase.from("orders").select("*").eq("user_id", user!.id).order("created_at", { ascending: false }).limit(3),
|
|
// Also get products from paid orders (via order_items)
|
|
supabase
|
|
.from("orders")
|
|
.select(
|
|
`
|
|
order_items (
|
|
product:products (id, title, slug, type, meeting_link, recording_url, event_start, duration_minutes)
|
|
)
|
|
`,
|
|
)
|
|
.eq("user_id", user!.id)
|
|
.eq("payment_status", "paid"),
|
|
supabase.from("profiles").select("whatsapp_number").eq("id", user!.id).single(),
|
|
// Fetch confirmed consulting slots for quick access
|
|
supabase
|
|
.from("consulting_slots")
|
|
.select("id, date, start_time, end_time, status, meet_link, topic_category")
|
|
.eq("user_id", user!.id)
|
|
.eq("status", "confirmed")
|
|
.order("date", { ascending: false }),
|
|
supabase.from("collaborator_wallets").select("user_id").eq("user_id", user!.id).maybeSingle(),
|
|
supabase.from("products").select("id").eq("collaborator_user_id", user!.id).limit(1),
|
|
]);
|
|
|
|
// Combine access from user_access and paid orders
|
|
const directAccess = (accessRes.data as unknown as UserAccess[]) || [];
|
|
const paidProductAccess: UserAccess[] = [];
|
|
|
|
if (paidOrdersRes.data) {
|
|
const existingIds = new Set(directAccess.map((a) => a.product.id));
|
|
paidOrdersRes.data.forEach((order: any) => {
|
|
order.order_items?.forEach((item: any) => {
|
|
if (item.product && !existingIds.has(item.product.id)) {
|
|
existingIds.add(item.product.id);
|
|
paidProductAccess.push({ id: `paid-${item.product.id}`, product: item.product });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
setAccess([...directAccess, ...paidProductAccess]);
|
|
if (ordersRes.data) setRecentOrders(ordersRes.data);
|
|
if (profileRes.data) setHasWhatsApp(!!profileRes.data.whatsapp_number);
|
|
if (slotsRes.data) setConsultingSlots(slotsRes.data as unknown as ConsultingSlot[]);
|
|
setIsCollaborator(!!walletRes?.data || !!(collaboratorProductRes?.data && collaboratorProductRes.data.length > 0));
|
|
setLoading(false);
|
|
};
|
|
|
|
const getQuickAction = (item: UserAccess) => {
|
|
const now = new Date();
|
|
|
|
switch (item.product.type) {
|
|
case "consulting": {
|
|
// Only show if user has a confirmed upcoming consulting slot
|
|
const upcomingSlot = consultingSlots.find(
|
|
(slot) =>
|
|
slot.status === "confirmed" &&
|
|
new Date(slot.date) >= new Date(now.setHours(0, 0, 0, 0))
|
|
);
|
|
|
|
if (upcomingSlot && upcomingSlot.meet_link) {
|
|
return { label: "Gabung Konsultasi", icon: Video, href: upcomingSlot.meet_link };
|
|
}
|
|
return null;
|
|
}
|
|
case "webinar": {
|
|
// Only show if webinar is joinable (hasn't ended yet)
|
|
if (!item.product.event_start) return null;
|
|
|
|
const eventStart = new Date(item.product.event_start);
|
|
const durationMs = (item.product.duration_minutes || 60) * 60 * 1000;
|
|
const eventEnd = new Date(eventStart.getTime() + durationMs);
|
|
|
|
// Only show if webinar hasn't ended
|
|
if (now <= eventEnd) {
|
|
return { label: "Gabung Webinar", icon: Video, href: item.product.meeting_link };
|
|
}
|
|
return null;
|
|
}
|
|
case "bootcamp":
|
|
// Don't show bootcamp in quick access - it's self-paced, not scheduled
|
|
return null;
|
|
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 md:grid-cols-2">
|
|
{[...Array(4)].map((_, i) => (
|
|
<Skeleton key={i} className="h-32" />
|
|
))}
|
|
</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">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">
|
|
<Card className="border-2 border-border">
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-4">
|
|
<Package className="w-10 h-10 text-primary" />
|
|
<div>
|
|
<p className="text-3xl font-bold">{access.length}</p>
|
|
<p className="text-muted-foreground">Produk Diakses</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="border-2 border-border">
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-4">
|
|
<Receipt className="w-10 h-10 text-primary" />
|
|
<div>
|
|
<p className="text-3xl font-bold">{recentOrders.length}</p>
|
|
<p className="text-muted-foreground">Order Terbaru</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="border-2 border-border col-span-full lg:col-span-1">
|
|
<CardContent className="pt-6 flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<ShoppingBag className="w-10 h-10 text-primary" />
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Jelajahi lebih banyak</p>
|
|
<p className="font-medium">Lihat semua produk</p>
|
|
</div>
|
|
</div>
|
|
<Button variant="outline" onClick={() => navigate("/products")} className="border-2">
|
|
<ArrowRight className="w-4 h-4" />
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
{isCollaborator && (
|
|
<Card className="border-2 border-border col-span-full">
|
|
<CardContent className="pt-6 flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Wallet className="w-10 h-10 text-primary" />
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Kolaborator Dashboard</p>
|
|
<p className="font-medium">Lihat profit & withdrawal</p>
|
|
</div>
|
|
</div>
|
|
<Button variant="outline" onClick={() => navigate("/profit")} className="border-2">
|
|
Buka Profit
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
|
|
{access.length > 0 && (
|
|
<div className="mb-8">
|
|
{(() => {
|
|
const quickAccessItems = access
|
|
.map((item) => ({ item, action: getQuickAction(item) }))
|
|
.filter(({ action }) => action !== null)
|
|
.slice(0, 3);
|
|
|
|
if (quickAccessItems.length === 0) return null;
|
|
|
|
return (
|
|
<>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-2xl font-bold">Akses Cepat</h2>
|
|
<Button variant="ghost" onClick={() => navigate("/access")}>
|
|
Lihat Semua
|
|
<ArrowRight className="w-4 h-4 ml-2" />
|
|
</Button>
|
|
</div>
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
{quickAccessItems.map(({ item, action }) => (
|
|
<Card key={item.id} className="border-2 border-border">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-lg">{item.product.title}</CardTitle>
|
|
<CardDescription className="capitalize">{item.product.type}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{action && action.href && (
|
|
<Button asChild variant="outline" className="w-full border-2">
|
|
<a href={action.href} target="_blank" rel="noopener noreferrer">
|
|
<action.icon className="w-4 h-4 mr-2" />
|
|
{action.label}
|
|
</a>
|
|
</Button>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
|
|
{recentOrders.length > 0 && (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-2xl font-bold">Order Terbaru</h2>
|
|
<Button variant="ghost" onClick={() => navigate("/orders")}>
|
|
Lihat Semua
|
|
<ArrowRight className="w-4 h-4 ml-2" />
|
|
</Button>
|
|
</div>
|
|
<Card className="border-2 border-border">
|
|
<CardContent className="p-0 divide-y divide-border">
|
|
{recentOrders.map((order) => (
|
|
<div key={order.id} className="flex items-center justify-between p-4">
|
|
<div>
|
|
<p className="font-mono text-sm">{order.id.slice(0, 8)}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{new Date(order.created_at).toLocaleDateString("id-ID")}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<Badge className={order.payment_status === "paid" ? "bg-brand-accent text-white rounded-full" : "bg-amber-500 text-white rounded-full"}>
|
|
{order.payment_status === "paid" ? "Lunas" : "Pending"}
|
|
</Badge>
|
|
<span className="font-bold">{formatIDR(order.total_amount)}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Consulting History with Review Prompts */}
|
|
<div className="mt-8">
|
|
<ConsultingHistory userId={user!.id} />
|
|
</div>
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|