Files
meet-hub/src/pages/member/MemberDashboard.tsx
dwindown 52190ff26d Fix Tiptap editor visual formatting and improve badge contrast
Tiptap Editor Improvements:
- Active toolbar buttons now use primary background (black) instead of accent (gray) for better visibility
- Added visual formatting for headings (h1: 2xl bold, h2: xl bold with proper spacing)
- Added visual styling for blockquotes (left border, italic, muted foreground)

Badge Contrast Fixes:
- Product detail page badges now use primary background (black with white text) instead of secondary/accent (gray)
- Fixed product type badge and "Anda memiliki akses" badge
- Fixed "Rekaman segera tersedia" badge

API Query Fix:
- Fixed consulting_slots 400 error by removing unsupported nested relationship filter
- Changed to filter in JavaScript after fetching data from Supabase

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-25 11:51:46 +07:00

322 lines
12 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, 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;
product: {
id: string;
title: string;
slug: string;
type: string;
meeting_link: string | null;
recording_url: string | null;
};
}
interface Order {
id: string;
total_amount: number;
payment_status: string | null;
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);
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] = await Promise.all([
supabase
.from("user_access")
.select(`id, product:products (id, title, slug, type, meeting_link, recording_url)`)
.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)
)
`,
)
.eq("user_id", user!.id)
.eq("payment_status", "paid")
.eq("payment_provider", "pakasir"),
supabase.from("profiles").select("whatsapp_number").eq("id", user!.id).single(),
]);
// 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);
setLoading(false);
};
const getQuickAction = (item: UserAccess) => {
switch (item.product.type) {
case "consulting":
return { label: "Jadwalkan", icon: Calendar, href: item.product.meeting_link };
case "webinar":
return { label: "Tonton", icon: Video, href: item.product.recording_url || item.product.meeting_link };
case "bootcamp":
return { label: "Lanjutkan", icon: BookOpen, route: `/bootcamp/${item.product.slug}` };
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>
</div>
{access.length > 0 && (
<div className="mb-8">
<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">
{access.slice(0, 3).map((item) => {
const action = getQuickAction(item);
return (
<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.route ? (
<Button onClick={() => navigate(action.route!)} className="w-full shadow-sm">
<action.icon className="w-4 h-4 mr-2" />
{action.label}
</Button>
) : 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>
) : null)}
</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" : "bg-muted text-primary"}>
{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>
);
}