Refactor quick access section to only show scheduled events

Changes:
- Add consulting slots fetching to get confirmed upcoming sessions
- Update getQuickAction logic:
  * Consulting: Only show if has confirmed upcoming slot with meet_link
  * Webinar: Only show if event_start + duration hasn't ended
  * Bootcamp: Removed from quick access (self-paced, not scheduled)
- Filter out items without valid quick actions
- Remove unused Calendar and BookOpen imports

Quick access now truly means "it is scheduled, here's the shortcut to join"

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
dwindown
2025-12-26 01:40:52 +07:00
parent 91bec42c4b
commit 734aa967ac

View File

@@ -8,7 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { formatIDR } from "@/lib/format"; import { formatIDR } from "@/lib/format";
import { Video, Calendar, BookOpen, ArrowRight, Package, Receipt, ShoppingBag } from "lucide-react"; import { Video, 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"; import { UnpaidOrderAlert } from "@/components/UnpaidOrderAlert";
@@ -22,6 +22,8 @@ interface UserAccess {
type: string; type: string;
meeting_link: string | null; meeting_link: string | null;
recording_url: string | null; recording_url: string | null;
event_start: string | null;
duration_minutes: number | null;
}; };
} }
@@ -37,12 +39,23 @@ interface UnpaidConsultingOrder {
qr_expires_at: string; qr_expires_at: string;
} }
interface ConsultingSlot {
id: string;
date: string;
start_time: string;
end_time: string;
status: string;
product_id: string | null;
meet_link: string | null;
}
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 [unpaidConsultingOrders, setUnpaidConsultingOrders] = useState<UnpaidConsultingOrder[]>([]);
const [consultingSlots, setConsultingSlots] = useState<ConsultingSlot[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [hasWhatsApp, setHasWhatsApp] = useState(true); const [hasWhatsApp, setHasWhatsApp] = useState(true);
@@ -109,10 +122,10 @@ export default function MemberDashboard() {
}, []); }, []);
const fetchData = async () => { const fetchData = async () => {
const [accessRes, ordersRes, paidOrdersRes, profileRes] = await Promise.all([ const [accessRes, ordersRes, paidOrdersRes, profileRes, slotsRes] = await Promise.all([
supabase supabase
.from("user_access") .from("user_access")
.select(`id, product:products (id, title, slug, type, meeting_link, recording_url)`) .select(`id, product:products (id, title, slug, type, meeting_link, recording_url, event_start, duration_minutes)`)
.eq("user_id", user!.id), .eq("user_id", user!.id),
supabase.from("orders").select("*").eq("user_id", user!.id).order("created_at", { ascending: false }).limit(3), 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) // Also get products from paid orders (via order_items)
@@ -121,13 +134,20 @@ export default function MemberDashboard() {
.select( .select(
` `
order_items ( order_items (
product:products (id, title, slug, type, meeting_link, recording_url) product:products (id, title, slug, type, meeting_link, recording_url, event_start, duration_minutes)
) )
`, `,
) )
.eq("user_id", user!.id) .eq("user_id", user!.id)
.eq("payment_status", "paid"), .eq("payment_status", "paid"),
supabase.from("profiles").select("whatsapp_number").eq("id", user!.id).single(), 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, product_id, meet_link")
.eq("user_id", user!.id)
.eq("status", "confirmed")
.order("date", { ascending: false }),
]); ]);
// Combine access from user_access and paid orders // Combine access from user_access and paid orders
@@ -149,20 +169,45 @@ export default function MemberDashboard() {
setAccess([...directAccess, ...paidProductAccess]); setAccess([...directAccess, ...paidProductAccess]);
if (ordersRes.data) setRecentOrders(ordersRes.data); if (ordersRes.data) setRecentOrders(ordersRes.data);
if (profileRes.data) setHasWhatsApp(!!profileRes.data.whatsapp_number); if (profileRes.data) setHasWhatsApp(!!profileRes.data.whatsapp_number);
if (slotsRes.data) setConsultingSlots(slotsRes.data as unknown as ConsultingSlot[]);
setLoading(false); setLoading(false);
}; };
const getQuickAction = (item: UserAccess) => { const getQuickAction = (item: UserAccess) => {
const now = new Date();
switch (item.product.type) { switch (item.product.type) {
case "consulting": case "consulting": {
return { label: "Jadwalkan", icon: Calendar, href: item.product.meeting_link }; // Only show if user has a confirmed upcoming consulting slot for this product
case "webinar": const upcomingSlot = consultingSlots.find(
if (item.product.recording_url) { (slot) =>
return { label: "Tonton", icon: Video, route: `/webinar/${item.product.slug}` }; slot.product_id === item.product.id &&
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;
} }
return { label: "Gabung", icon: Video, href: item.product.meeting_link };
case "bootcamp": case "bootcamp":
return { label: "Lanjutkan", icon: BookOpen, route: `/bootcamp/${item.product.slug}` }; // Don't show bootcamp in quick access - it's self-paced, not scheduled
return null;
default: default:
return null; return null;
} }
@@ -250,33 +295,28 @@ export default function MemberDashboard() {
</Button> </Button>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{access.slice(0, 3).map((item) => { {access
const action = getQuickAction(item); .map((item) => ({ item, action: getQuickAction(item) }))
return ( .filter(({ action }) => action !== null)
.slice(0, 3)
.map(({ item, action }) => (
<Card key={item.id} className="border-2 border-border"> <Card key={item.id} className="border-2 border-border">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-lg">{item.product.title}</CardTitle> <CardTitle className="text-lg">{item.product.title}</CardTitle>
<CardDescription className="capitalize">{item.product.type}</CardDescription> <CardDescription className="capitalize">{item.product.type}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{action && {action && action.href && (
(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"> <Button asChild variant="outline" className="w-full border-2">
<a href={action.href} target="_blank" rel="noopener noreferrer"> <a href={action.href} target="_blank" rel="noopener noreferrer">
<action.icon className="w-4 h-4 mr-2" /> <action.icon className="w-4 h-4 mr-2" />
{action.label} {action.label}
</a> </a>
</Button> </Button>
) : null)} )}
</CardContent> </CardContent>
</Card> </Card>
); ))}
})}
</div> </div>
</div> </div>
)} )}