ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:07:20 +08:00
parent 56d984a26a
commit fbef7cb6e6
283 changed files with 25961 additions and 1072 deletions
+409
View File
@@ -0,0 +1,409 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
TableOfContents, Users, Timer,
Tag, LockIcon, Check,
} from "lucide-react";
import { ThemeSwitcher } from "../components/ThemeSwitcher";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
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 { cn } from "@/lib/utils";
import { useGroup } from "@/contexts/ClientGroupContext";
import { useTask } from "@/contexts/ClientTaskContext";
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`;
}
// Mirrors the same access logic in CoursesList.jsx
function canAccess(userTier, planTier) {
if (!planTier || planTier === "free") return true;
if (planTier === "premium") return userTier === "premium" || userTier === "exclusive";
if (planTier === "exclusive") return userTier === "exclusive";
return false;
}
// ── Course Card ──────────────────────────────────────────────────────────────
const CourseCard = ({ course, onViewDetails }) => {
const type = course.plan_tier ?? "free";
const locked = course.is_locked;
const duration = formatDuration(course.duration_seconds);
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">
{type === "free" && (
<Badge className="bg-green-500 text-white">
<Tag /> Free
</Badge>
)}
{type === "premium" && (
<Badge className="bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white">
{locked ? <LockIcon /> : <Tag />} Premium
</Badge>
)}
{type === "exclusive" && (
<Badge className="bg-gradient-to-r from-rose-500 to-red-600 text-white">
<LockIcon /> Exclusive
</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>
);
// ─── Client Dashboard ─────────────────────────────────────────────────────────
const Client = () => {
const navigate = useNavigate();
const { state: navState } = useLocation();
const { courses, coursesLoading, getCourses } = useClientCourses();
const { myTier, getMyTier } = useClientTiers();
const { groups, fetchGroups, loading: groupLoading } = useGroup();
const { fetchTaskLists } = useTask();
const { advertisements, loading: adLoading, getActiveAdvertisement, trackClick } = useClientAdvertisements();
const [completedCount, setCompletedCount] = useState(0);
const [dueSoonCount, setDueSoonCount] = useState(0);
const [modalOpen, setModalOpen] = useState(false);
const [selectedCourse, setSelectedCourse] = useState(null);
const [popupOpen, setPopupOpen] = useState(false);
const userTier = myTier?.tier ?? "free";
const heroAd = advertisements.hero ?? null;
const popupAd = advertisements.popup ?? null;
// Show welcome toast on first registration
useEffect(() => {
if (!navState?.justRegistered) return;
toast.success('Welcome to Philproperties!', {
description: 'You earned the Early Access badge. Check your notifications for details.',
duration: 6000,
});
window.history.replaceState({}, '');
}, []);
useEffect(() => {
getCourses();
if (!myTier) getMyTier();
}, []);
useEffect(() => {
fetchGroups();
}, [])
// ── Resolve active hero + popup ads once on mount ────────────────────────
useEffect(() => {
getActiveAdvertisement("hero");
getActiveAdvertisement("popup").then((ad) => {
if (ad) setPopupOpen(true);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── CTA click — track then navigate ───────────────────────────────────────
const handleAdCtaClick = (ad, cta) => {
trackClick(ad.advertisement_id);
if (!cta?.link) return;
if (/^https?:\/\//.test(cta.link)) {
window.open(cta.link, "_blank", "noopener,noreferrer");
} else {
navigate(cta.link);
}
};
// Show only first 3
const featuredCourses = courses.slice(0, 3);
const myGroup = groups?.[0] ?? null;
const breadcrumbItems = [
{ label: "My Group", icon: <Users className="size-4" />, to: `` },
{ label: "Statistics" },
];
// ── Card click — mirrors CoursesList.jsx logic ────────────────────────────
const handleViewDetails = (course) => {
const accessible = canAccess(userTier, course.plan_tier);
if (!accessible) {
setSelectedCourse(course);
setModalOpen(true);
} else {
navigate(`/course/${course.course_id}`);
}
};
// ── Fetch all task lists once group is known, count individual tasks ─────
//
// "Completed Tasks" and "Due soon" are TASK-level counts (not task-list-level),
// so we fetch ALL task lists unfiltered, flatten every task across them, and
// count by each task's own has_completed flag — regardless of which bucket
// the task list as a whole falls into.
useEffect(() => {
if (!myGroup?.group_id) return;
fetchTaskLists(myGroup.group_id).then((data) => {
if (!data) return;
const allTasks = data.flatMap((taskList) => taskList.tasks ?? []);
// ── Completed Tasks: individual tasks with has_completed ───────────────
const completed = allTasks.filter((task) => task.has_completed).length;
setCompletedCount(completed);
// ── Due soon: incomplete tasks with deadline within 24h ────────────────
const now = Date.now();
const DAY = 24 * 60 * 60 * 1000;
let dueSoon = 0;
allTasks.forEach((task) => {
if (!task.deadline) return;
if (task.has_completed) return; // already submitted, skip
const deadline = new Date(task.deadline).getTime();
const diff = deadline - now;
if (diff > 0 && diff <= DAY) dueSoon += 1;
});
setDueSoonCount(dueSoon);
});
}, [myGroup?.group_id]);
return (
<div>
<div className="my-20">
<div className="flex flex-col gap-8 justify-between lg:container lg:mx-auto pt-8 px-16">
{/* ── Hero Advertisement ── */}
{adLoading.hero ? (
<HeroSkeleton />
) : (
<Hero ad={heroAd} onCtaClick={handleAdCtaClick} />
)}
{/* ── Group affiliated ── */}
<div className="flex flex-col gap-4">
<AppBreadcrumb items={breadcrumbItems} />
<div className="flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
{groupLoading ? (
<Skeleton className="h-7 w-40" />
) : (
<h1 className="text-2xl font-medium">{myGroup?.name ?? 'No Group'}</h1>
)}
<Button
onClick={() => myGroup && navigate(`/group/${myGroup.group_id}`)}
disabled={!myGroup}
>
View
</Button>
</div>
<div className="grid xs:grid-cols-2 lg:grid-cols-5 gap-4">
<div className="bg-card shadow-md border rounded-md space-y-4 p-4">
<div className="flex gap-2 items-center">
<Button size="sm" variant="secondary">
<TableOfContents />
</Button>
<h1>Completed Tasks</h1>
</div>
{groupLoading ? (
<Skeleton className="h-8 w-10" />
) : (
<h1 className="font-medium text-2xl">{completedCount}</h1>
)}
</div>
<div className="bg-card shadow-md border rounded-md space-y-4 p-4">
<div className="flex gap-2 items-center">
<Button size="sm" variant="secondary">
<Timer />
</Button>
<h1>Due soon</h1>
</div>
{groupLoading ? (
<Skeleton className="h-8 w-10" />
) : (
<h1 className="font-medium text-2xl">{dueSoonCount}</h1>
)}
</div>
</div>
</div>
</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}
/>
{/* ── 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">
{(selectedCourse?.plan_tier ?? "free") === "premium" && (
<div className="p-5 bg-gradient-to-r from-fuchsia-50 to-purple-50 dark:from-fuchsia-950/30 dark:to-purple-950/30 rounded-2xl border border-fuchsia-200 dark:border-fuchsia-800">
<div className="flex items-center gap-3 mb-3">
<Badge className="bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white">
<Tag className="size-4" /> Premium
</Badge>
</div>
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4">
<li className="flex items-center gap-2"><Check /> Lifetime access</li>
<li className="flex items-center gap-2"><Check /> Downloadable resources</li>
<li className="flex items-center gap-2"><Check /> Certificate of completion</li>
</ul>
<p className="text-sm text-muted-foreground">
Upgrade to a Premium plan to unlock this course and all other premium content.
</p>
</div>
)}
{(selectedCourse?.plan_tier ?? "free") === "exclusive" && (
<div className="p-5 bg-gradient-to-r from-rose-50 to-red-50 dark:from-rose-950/30 dark:to-red-950/30 rounded-2xl border border-rose-200 dark:border-rose-800">
<div className="flex items-center gap-3 mb-3">
<Badge className="bg-gradient-to-r from-rose-500 to-red-600 text-white">
<LockIcon className="size-4" /> Exclusive
</Badge>
</div>
<div className="bg-card border rounded-xl p-4 mb-4">
<p className="text-sm font-medium flex items-center gap-2 text-rose-600">
<LockIcon className="size-4" /> This is an exclusive course
</p>
<p className="text-xs text-muted-foreground mt-1">
Only available to members with exclusive access
</p>
</div>
<p className="text-sm text-muted-foreground">
Exclusive courses are granted through special programs, partnerships, or limited enrollment offerings.
</p>
</div>
)}
</div>
</ResponsiveModal>
</div>
);
};
export default Client;