mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user