Files
starr-philproperties/src/modules/client/pages/Dashboard.jsx
T
kennethobsequio 182bd93d10 add: ver()
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-07-08 11:31:54 +08:00

445 lines
20 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 { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import UnitUpsellModal from "../components/UnitUpsellModal";
import { UnitCard, UnitCardSkeleton } from "../components/UnitCard";
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 { units, unitsLoading, getUnits } = useLibrary();
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 [unitModalOpen, setUnitModalOpen] = useState(false);
const [selectedUnit, setSelectedUnit] = 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,
});
window.history.replaceState({}, '');
}, []);
useEffect(() => {
getCourses();
getUnits();
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 featuredUnits = units.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}`);
}
};
const handleViewUnitDetails = (unit) => {
if (unit.is_locked) {
setSelectedUnit(unit);
setUnitModalOpen(true);
} else {
navigate(`/units/${unit.uuid}`);
}
};
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>
{/* ── Featured Units (first 3) ── */}
<div className="flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<h1 className="text-2xl font-medium">Units</h1>
<Button onClick={() => navigate(`/units`)}>View All</Button>
</div>
{unitsLoading ? (
<div className="grid lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<UnitCardSkeleton key={i} />
))}
</div>
) : featuredUnits.length === 0 ? (
<p className="text-sm text-muted-foreground">No units available yet.</p>
) : (
<div className="grid lg:grid-cols-3 gap-4">
{featuredUnits.map((unit) => (
<UnitCard
key={unit.unit_id}
unit={unit}
onViewDetails={handleViewUnitDetails}
/>
))}
</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>
{/* ── Upsell Modal — only for locked units ── */}
<UnitUpsellModal
open={unitModalOpen}
onOpenChange={setUnitModalOpen}
unit={selectedUnit}
tierMap={tierMap}
/>
</div>
);
};
export default Client;