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:
@@ -8,7 +8,7 @@ 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 { Video, ArrowRight, Package, Receipt, ShoppingBag } from "lucide-react";
|
||||
import { WhatsAppBanner } from "@/components/WhatsAppBanner";
|
||||
import { ConsultingHistory } from "@/components/reviews/ConsultingHistory";
|
||||
import { UnpaidOrderAlert } from "@/components/UnpaidOrderAlert";
|
||||
@@ -22,6 +22,8 @@ interface UserAccess {
|
||||
type: string;
|
||||
meeting_link: string | null;
|
||||
recording_url: string | null;
|
||||
event_start: string | null;
|
||||
duration_minutes: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,12 +39,23 @@ interface UnpaidConsultingOrder {
|
||||
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() {
|
||||
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);
|
||||
|
||||
@@ -109,10 +122,10 @@ export default function MemberDashboard() {
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
const [accessRes, ordersRes, paidOrdersRes, profileRes] = await Promise.all([
|
||||
const [accessRes, ordersRes, paidOrdersRes, profileRes, slotsRes] = await Promise.all([
|
||||
supabase
|
||||
.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),
|
||||
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)
|
||||
@@ -121,13 +134,20 @@ export default function MemberDashboard() {
|
||||
.select(
|
||||
`
|
||||
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("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, product_id, meet_link")
|
||||
.eq("user_id", user!.id)
|
||||
.eq("status", "confirmed")
|
||||
.order("date", { ascending: false }),
|
||||
]);
|
||||
|
||||
// Combine access from user_access and paid orders
|
||||
@@ -149,20 +169,45 @@ export default function MemberDashboard() {
|
||||
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[]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const getQuickAction = (item: UserAccess) => {
|
||||
const now = new Date();
|
||||
|
||||
switch (item.product.type) {
|
||||
case "consulting":
|
||||
return { label: "Jadwalkan", icon: Calendar, href: item.product.meeting_link };
|
||||
case "webinar":
|
||||
if (item.product.recording_url) {
|
||||
return { label: "Tonton", icon: Video, route: `/webinar/${item.product.slug}` };
|
||||
case "consulting": {
|
||||
// Only show if user has a confirmed upcoming consulting slot for this product
|
||||
const upcomingSlot = consultingSlots.find(
|
||||
(slot) =>
|
||||
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 { label: "Gabung", icon: Video, href: item.product.meeting_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":
|
||||
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:
|
||||
return null;
|
||||
}
|
||||
@@ -250,33 +295,28 @@ export default function MemberDashboard() {
|
||||
</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 (
|
||||
{access
|
||||
.map((item) => ({ item, action: getQuickAction(item) }))
|
||||
.filter(({ action }) => action !== null)
|
||||
.slice(0, 3)
|
||||
.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.route ? (
|
||||
<Button onClick={() => navigate(action.route!)} className="w-full shadow-sm">
|
||||
{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}
|
||||
</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)}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user