Files
meet-hub/src/pages/member/MemberDashboard.tsx

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