mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
395 lines
18 KiB
React
395 lines
18 KiB
React
import { useEffect, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Users, Timer,
|
|
Tag, LockIcon, Check,
|
|
} from "lucide-react";
|
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import {
|
|
Table, TableHeader, TableBody,
|
|
TableHead, TableRow, TableCell,
|
|
} from "@/components/ui/table";
|
|
import { useNavigate, useLocation } from "react-router-dom";
|
|
import { toast } from "sonner";
|
|
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
|
import { useClientCourses } from "@/contexts/ClientCoursesContext";
|
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
|
import { cn } from "@/lib/utils";
|
|
import { useGroup } from "@/contexts/ClientGroupContext";
|
|
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
|
|
import { Hero, HeroSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Hero";
|
|
import { Popup } from "@/components/generic/Blocks/Client/Advertisements/Popup";
|
|
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function formatDuration(seconds = 0) {
|
|
if (!seconds) return null;
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
if (h && m) return `${h}h ${m}m`;
|
|
if (h) return `${h}h`;
|
|
return `${m}m`;
|
|
}
|
|
|
|
// Rank-based access: user's rank must be >= course's required rank.
|
|
function canAccess(userTier, planTier, tierMap) {
|
|
const courseRank = tierMap[planTier]?.rank ?? (planTier && planTier !== "free" ? Infinity : 0);
|
|
const userRank = tierMap[userTier]?.rank ?? 0;
|
|
return userRank >= courseRank;
|
|
}
|
|
|
|
// ── Course Card ──────────────────────────────────────────────────────────────
|
|
|
|
const CourseCard = ({ course, onViewDetails }) => {
|
|
const { tierMap } = useClientTiers();
|
|
const slug = course.subscription ?? "free";
|
|
const locked = course.is_locked;
|
|
const duration = formatDuration(course.duration_seconds);
|
|
const { rank, label, cls } = resolveTierBadge(slug, tierMap);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"bg-card rounded-2xl border p-4 flex flex-col gap-2.5 transition-all cursor-pointer group",
|
|
"hover:shadow-sm",
|
|
locked
|
|
? "opacity-80 hover:opacity-100 hover:border-muted-foreground/40"
|
|
: "hover:bg-muted/60 dark:hover:border-blue-500"
|
|
)}
|
|
onClick={() => onViewDetails(course)}
|
|
>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<Badge className={cls}>
|
|
{rank > 0 || locked ? <LockIcon className="size-3" /> : <Tag className="size-3" />}
|
|
{label}
|
|
</Badge>
|
|
{course.level && (
|
|
<Badge variant="outline">
|
|
{course.level.charAt(0).toUpperCase() + course.level.slice(1)}
|
|
</Badge>
|
|
)}
|
|
{locked && (
|
|
<Badge variant="secondary" className="ml-auto">
|
|
<LockIcon className="size-3" /> Locked
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<h1 className="text-lg font-medium leading-snug line-clamp-3 transition-colors group-hover:text-blue-700 dark:group-hover:text-blue-400">
|
|
{course.title}
|
|
</h1>
|
|
{course.description && (
|
|
<p className="text-sm leading-relaxed line-clamp-2 text-muted-foreground">
|
|
{course.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between pt-2 mt-auto border-t">
|
|
<div className={`flex items-center gap-1 text-sm [&_svg]:size-4 ${locked ? "text-muted-foreground" : "text-card-foreground group-hover:text-blue-700 dark:group-hover:text-blue-400"}`}>
|
|
<Timer />
|
|
{duration ?? "—"}
|
|
</div>
|
|
{locked && (
|
|
<span className="text-xs text-muted-foreground">Upgrade to unlock</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── Course Card Skeleton ─────────────────────────────────────────────────────
|
|
|
|
const CourseCardSkeleton = () => (
|
|
<div className="bg-card rounded-2xl border p-4 flex flex-col gap-2.5">
|
|
<div className="flex gap-1.5">
|
|
<Skeleton className="h-5 w-16 rounded-full" />
|
|
<Skeleton className="h-5 w-20 rounded-full" />
|
|
</div>
|
|
<Skeleton className="h-6 w-3/4" />
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-2/3" />
|
|
<div className="pt-2 mt-auto border-t">
|
|
<Skeleton className="h-4 w-16" />
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// ─── Groups Table ─────────────────────────────────────────────────────────────
|
|
|
|
const GroupsTable = ({ groups, onView }) => (
|
|
<div className="bg-card border rounded-xl overflow-hidden">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="hover:bg-transparent">
|
|
<TableHead className="w-10 text-center px-4">#</TableHead>
|
|
<TableHead className="px-4">Group Name</TableHead>
|
|
<TableHead className="px-4">Code</TableHead>
|
|
<TableHead className="px-4">Description</TableHead>
|
|
<TableHead className="px-4">Task Lists</TableHead>
|
|
<TableHead className="px-4" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{groups.map((g, i) => {
|
|
const isDefault = g.group_code === 'NOGRP';
|
|
const taskListCount = Number(g.task_list_count ?? g.taskLists?.length ?? 0);
|
|
return (
|
|
<TableRow key={g.group_id}>
|
|
<TableCell className="text-center px-4 text-muted-foreground tabular-nums">
|
|
{i + 1}
|
|
</TableCell>
|
|
<TableCell className="px-4 font-medium">{g.name}</TableCell>
|
|
<TableCell className="px-4">
|
|
<Badge variant="outline" className="font-mono text-xs">{g.group_code}</Badge>
|
|
</TableCell>
|
|
<TableCell className="px-4 text-muted-foreground ">
|
|
{isDefault
|
|
? <span className="text-xs italic">Awaiting assignment by admin</span>
|
|
: (g.description ?? <span className="text-xs text-muted-foreground/50">—</span>)
|
|
}
|
|
</TableCell>
|
|
<TableCell className="px-4">
|
|
{taskListCount}
|
|
</TableCell>
|
|
<TableCell className="px-4 text-right">
|
|
<Button size="sm" variant="outline" onClick={() => onView(g)}>
|
|
View
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
);
|
|
|
|
const GroupsTableSkeleton = () => (
|
|
<div className="bg-card border rounded-xl overflow-hidden">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="hover:bg-transparent">
|
|
<TableHead className="w-10 text-center px-4">#</TableHead>
|
|
<TableHead className="px-4">Group Name</TableHead>
|
|
<TableHead className="px-4">Code</TableHead>
|
|
<TableHead className="px-4 w-full">Description</TableHead>
|
|
<TableHead className="px-4 text-center whitespace-nowrap">Task Lists</TableHead>
|
|
<TableHead className="px-4" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{[1, 2].map((i) => (
|
|
<TableRow key={i}>
|
|
<TableCell className="text-center px-4"><Skeleton className="h-4 w-4 mx-auto" /></TableCell>
|
|
<TableCell className="px-4"><Skeleton className="h-4 w-32" /></TableCell>
|
|
<TableCell className="px-4"><Skeleton className="h-5 w-16 rounded-full" /></TableCell>
|
|
<TableCell className="px-4"><Skeleton className="h-4 w-48" /></TableCell>
|
|
<TableCell className="px-4"><Skeleton className="h-4 w-8 mx-auto" /></TableCell>
|
|
<TableCell className="px-4 text-right"><Skeleton className="h-8 w-14 ml-auto" /></TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
);
|
|
|
|
// ─── Client Dashboard ─────────────────────────────────────────────────────────
|
|
|
|
const Client = () => {
|
|
const navigate = useNavigate();
|
|
const { state: navState } = useLocation();
|
|
const { courses, coursesLoading, getCourses } = useClientCourses();
|
|
const { myTier, getMyTier, tierMap } = useClientTiers();
|
|
const { groups, fetchGroups, loading: groupLoading } = useGroup();
|
|
const {
|
|
advertisements, getActiveAdvertisements,
|
|
adLists, listLoading, getActiveAdvertisementList,
|
|
handleAdCtaClick, dismissPopupForever,
|
|
} = useClientAdvertisements();
|
|
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [selectedCourse, setSelectedCourse] = useState(null);
|
|
|
|
const [popupOpen, setPopupOpen] = useState(false);
|
|
|
|
const userTier = myTier?.tier ?? "free";
|
|
|
|
const heroAds = adLists["dashboard.hero"] ?? [];
|
|
const popupAd = advertisements["dashboard.popup"] ?? null;
|
|
|
|
// Show welcome toast on first registration
|
|
useEffect(() => {
|
|
if (!navState?.justRegistered) return;
|
|
toast('Welcome to Philproperties!', {
|
|
description: 'You earned the Early Access badge. Check your notifications for details.',
|
|
duration: 6000,
|
|
action: {
|
|
label: "Close",
|
|
onClick: () => {}
|
|
}
|
|
});
|
|
window.history.replaceState({}, '');
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
getCourses();
|
|
if (!myTier) getMyTier();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchGroups();
|
|
}, [])
|
|
|
|
// ── Resolve active popup ad + hero ad carousel once on mount ─────────────
|
|
useEffect(() => {
|
|
getActiveAdvertisements(["dashboard.popup"]).then((result) => {
|
|
if (result["dashboard.popup"]) setPopupOpen(true);
|
|
});
|
|
getActiveAdvertisementList("dashboard.hero");
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Show only first 3
|
|
const featuredCourses = courses.slice(0, 3);
|
|
|
|
const breadcrumbItems = [
|
|
{ label: "My Groups", icon: <Users className="size-4" /> },
|
|
];
|
|
|
|
// ── Card click — mirrors CoursesList.jsx logic ────────────────────────────
|
|
const handleViewDetails = (course) => {
|
|
const accessible = canAccess(userTier, course.subscription, tierMap);
|
|
if (!accessible) {
|
|
setSelectedCourse(course);
|
|
setModalOpen(true);
|
|
} else {
|
|
navigate(`/course/${course.course_id}`);
|
|
}
|
|
};
|
|
|
|
|
|
return (
|
|
<div>
|
|
<div className="my-20">
|
|
<div className="flex flex-col gap-8 justify-between lg:container lg:mx-auto xs:pt-2 lg:pt-8 lg:px-16 xs:px-4 sm:px-6">
|
|
|
|
{/* ── Hero Advertisement Carousel ── */}
|
|
{listLoading["dashboard.hero"] ? (
|
|
<HeroSkeleton />
|
|
) : (
|
|
<Hero ads={heroAds} onCtaClick={handleAdCtaClick} />
|
|
)}
|
|
|
|
{/* ── My Groups ── */}
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center justify-between">
|
|
<AppBreadcrumb items={breadcrumbItems} />
|
|
{!groupLoading && groups.length > 0 && (
|
|
<span className="text-sm text-muted-foreground">
|
|
{groups.length} group{groups.length !== 1 ? 's' : ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{groupLoading ? (
|
|
<GroupsTableSkeleton />
|
|
) : groups.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">You are not assigned to any group.</p>
|
|
) : (
|
|
<GroupsTable
|
|
groups={groups}
|
|
onView={(g) => navigate(`/group/${g.group_id}`)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Featured Courses (first 3) ── */}
|
|
<div className="flex flex-col gap-4">
|
|
<div className="w-full flex items-center justify-between">
|
|
<h1 className="text-2xl font-medium">Courses</h1>
|
|
<Button onClick={() => navigate(`/course`)}>View All</Button>
|
|
</div>
|
|
|
|
{coursesLoading ? (
|
|
<div className="grid lg:grid-cols-3 gap-4">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<CourseCardSkeleton key={i} />
|
|
))}
|
|
</div>
|
|
) : featuredCourses.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No courses available yet.</p>
|
|
) : (
|
|
<div className="grid lg:grid-cols-3 gap-4">
|
|
{featuredCourses.map((course) => (
|
|
<CourseCard
|
|
key={course.course_id}
|
|
course={course}
|
|
onViewDetails={handleViewDetails}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Popup Advertisement ── */}
|
|
<Popup
|
|
ad={popupAd}
|
|
open={popupOpen}
|
|
onOpenChange={setPopupOpen}
|
|
onCtaClick={handleAdCtaClick}
|
|
onDismissForever={dismissPopupForever}
|
|
/>
|
|
|
|
{/* ── Upsell Modal — only for locked courses ── */}
|
|
<ResponsiveModal
|
|
open={modalOpen}
|
|
onOpenChange={setModalOpen}
|
|
title={selectedCourse?.title ?? "Course Details"}
|
|
description="Upgrade your plan to access this course."
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setModalOpen(false)}>Close</Button>
|
|
<Button onClick={() => { setModalOpen(false); navigate("/plans"); }}>
|
|
<LockIcon /> View Plans
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-6 py-2">
|
|
{(() => {
|
|
const slug = selectedCourse?.subscription ?? "free";
|
|
const { rank, label, cls, panel } = resolveTierBadge(slug, tierMap);
|
|
if (rank === 0) return null;
|
|
return (
|
|
<div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}>
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<Badge className={cls}>
|
|
<LockIcon className="size-3" /> {label}
|
|
</Badge>
|
|
</div>
|
|
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4">
|
|
<li className="flex items-center gap-2"><Check /> Access to {label} content</li>
|
|
<li className="flex items-center gap-2"><Check /> Certificates & achievements</li>
|
|
</ul>
|
|
<p className="text-sm text-muted-foreground">
|
|
Upgrade to a <span className="font-medium">{label}</span> plan to unlock this course.
|
|
</p>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
</ResponsiveModal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Client;
|