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
+415
View File
@@ -0,0 +1,415 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, Timer, ChevronLeft, ChevronRight, LockIcon, Tag, Check, ShoppingCart } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Input } from "@/components/ui/input";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { useClientCourses } from "@/contexts/ClientCoursesContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { cn } from "@/lib/utils";
import { Fragment } from "react";
import { PageMeta } from "@/contexts/MetadataContext";
// ─── 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`;
}
const ITEMS_PER_PAGE = 10;
// ─── Tier access check ────────────────────────────────────────────────────────
// Returns true if the user's active tier can access the course's plan_tier
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" && !locked && (
<Badge className="bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white">
<Tag /> Premium
</Badge>
)}
{type === "premium" && locked && (
<Badge className="bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white">
<LockIcon /> Premium
</Badge>
)}
{type === "exclusive" && !locked && (
<Badge className="bg-gradient-to-r from-rose-500 to-red-600 text-white">
<LockIcon /> Exclusive
</Badge>
)}
{type === "exclusive" && locked && (
<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 group-hover:text-blue-700 dark:group-hover:text-blue-400">
{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>
);
};
// ─── Skeleton Card ────────────────────────────────────────────────────────────
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>
);
// ─── Pagination ───────────────────────────────────────────────────────────────
const Pagination = ({ currentPage, totalPages, totalItems, itemsPerPage, onPageChange }) => {
const start = (currentPage - 1) * itemsPerPage + 1;
const end = Math.min(currentPage * itemsPerPage, totalItems);
const getPages = () => {
const pages = [];
if (totalPages <= 5) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (currentPage > 3) pages.push("...");
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) pages.push(i);
if (currentPage < totalPages - 2) pages.push("...");
pages.push(totalPages);
}
return pages;
};
return (
<div className="flex items-center justify-between w-full pt-4 border-t">
<div className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{start}–{end}</span> of{" "}
<span className="font-medium text-foreground">{totalItems}</span> courses
</div>
<div className="flex items-center gap-1">
<Button onClick={() => onPageChange(currentPage - 1)} disabled={currentPage === 1} variant="ghost" size="sm">
<ChevronLeft />
</Button>
{getPages().map((page, i) =>
page === "..." ? (
<span key={`ellipsis-${i}`} className="w-7 h-7 flex items-center justify-center text-xs text-muted-foreground">···</span>
) : (
<Button
key={page}
size="sm"
variant={currentPage === page ? "default" : "outline"}
onClick={() => onPageChange(page)}
>
{page}
</Button>
)
)}
<Button onClick={() => onPageChange(currentPage + 1)} disabled={currentPage === totalPages} variant="ghost" size="sm">
<ChevronRight />
</Button>
</div>
</div>
);
};
// ─── Main Page ────────────────────────────────────────────────────────────────
const CoursesList = () => {
const navigate = useNavigate();
const { courses, coursesLoading, getCourses } = useClientCourses();
const { myTier, getMyTier } = useClientTiers();
const [currentPage, setCurrentPage] = useState(1);
const [search, setSearch] = useState("");
const [levelFilter, setLevelFilter] = useState("All");
const [subFilter, setSubFilter] = useState("All");
const [categoryFilter, setCategoryFilter] = useState("All");
const [modalOpen, setModalOpen] = useState(false);
const [selectedCourse, setSelectedCourse] = useState(null);
// Collect unique categories from loaded courses
const allCategories = useMemo(() => {
const map = new Map();
courses.forEach((c) => (c.categories ?? []).forEach((cat) => map.set(cat.id, cat)));
return [...map.values()];
}, [courses]);
const userTier = myTier?.tier ?? "free";
useEffect(() => {
getCourses();
if (!myTier) getMyTier();
}, []);
// ── Filter ────────────────────────────────────────────────────────────────
const filtered = courses.filter((c) => {
const matchSearch = c.title.toLowerCase().includes(search.toLowerCase()) ||
(c.description ?? "").toLowerCase().includes(search.toLowerCase());
const matchLevel = levelFilter === "All" || (c.level ?? "").toLowerCase() === levelFilter.toLowerCase();
const matchSub = subFilter === "All" || c.subscription === subFilter.toLowerCase();
const matchCategory = categoryFilter === "All" || (c.categories ?? []).some((cat) => String(cat.id) === categoryFilter);
return matchSearch && matchLevel && matchSub && matchCategory;
});
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
const paginated = filtered.slice(
(currentPage - 1) * ITEMS_PER_PAGE,
currentPage * ITEMS_PER_PAGE
);
// ── Card click ────────────────────────────────────────────────────────────
const handleViewDetails = (course) => {
// Re-check access using live tier — ignore is_locked from backend if tier has changed
const accessible = canAccess(userTier, course.plan_tier);
if (!accessible) {
setSelectedCourse(course);
setModalOpen(true);
} else {
navigate(`/course/${course.course_id}`);
}
};
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
{ label: "Courses" },
];
return (
<div>
<PageMeta title="Courses - STARR" description="Browse your available training courses." />
<div className="py-24 bg-accent/70 min-h-screen">
<div className="flex flex-col gap-8 justify-between lg:container lg:mx-auto pt-2">
<AppBreadcrumb items={items} />
{/* Search & Filters */}
<div className="fixed top-[67px] left-0 right-0 z-10 bg-card border-b lg:static lg:bg-transparent lg:border-none py-3 xs:px-4 md:px-6 lg:px-0">
<div className="flex items-center xs:flex-col lg:flex-row gap-4">
<Input
placeholder="Search courses..."
className="w-full bg-card lg:max-w-64"
value={search}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
/>
<div className="flex gap-4 items-start w-full">
<Select value={levelFilter} onValueChange={(v) => { setLevelFilter(v); setCurrentPage(1); }}>
<SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All">All Levels</SelectItem>
<SelectItem value="Beginner">Beginner</SelectItem>
<SelectItem value="Intermediate">Intermediate</SelectItem>
<SelectItem value="Advanced">Advanced</SelectItem>
</SelectContent>
</Select>
<Select value={subFilter} onValueChange={(v) => { setSubFilter(v); setCurrentPage(1); }}>
<SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Subscription" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All">All</SelectItem>
<SelectItem value="Free">Free</SelectItem>
<SelectItem value="Premium">Premium</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Category chips */}
{allCategories.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<button
className={`px-3 py-1 rounded-full text-sm border transition-colors ${categoryFilter === "All" ? "bg-primary text-primary-foreground border-primary" : "bg-card border-border hover:bg-muted"}`}
onClick={() => { setCategoryFilter("All"); setCurrentPage(1); }}
>
All
</button>
{allCategories.map((cat) => (
<button
key={cat.id}
className={`px-3 py-1 rounded-full text-sm border transition-colors ${categoryFilter === String(cat.id) ? "bg-primary text-primary-foreground border-primary" : "bg-card border-border hover:bg-muted"}`}
onClick={() => { setCategoryFilter(String(cat.id)); setCurrentPage(1); }}
>
{cat.name}
</button>
))}
</div>
)}
{/* Course Grid */}
{coursesLoading ? (
<div className="xs:pt-12 lg:pt-0 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => <CourseCardSkeleton key={i} />)}
</div>
) : paginated.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<svg className="w-10 h-10 mb-3" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm">No courses found</p>
</div>
) : (
<div className="xs:pt-12 lg:pt-0 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-4">
{paginated.map((course) => (
<CourseCard key={course.course_id} course={course} onViewDetails={handleViewDetails} />
))}
</div>
)}
{!coursesLoading && filtered.length > ITEMS_PER_PAGE && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={filtered.length}
itemsPerPage={ITEMS_PER_PAGE}
onPageChange={setCurrentPage}
/>
)}
</div>
</div>
{/* Upsell Modal — only for locked courses */}
<ResponsiveModal
open={modalOpen}
onOpenChange={setModalOpen}
title={selectedCourse?.title ?? "Course Details"}
description={selectedCourse?.product ? "Purchase this course or upgrade your plan." : "Upgrade your plan to access this course."}
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Close</Button>
{selectedCourse?.product?.is_active && (
<Button
variant="outline"
onClick={() => { setModalOpen(false); navigate(`/course/${selectedCourse.course_id}/checkout`); }}
>
<ShoppingCart className="size-4" />
Buy {new Intl.NumberFormat("en-US", { style: "currency", currency: selectedCourse.product.currency ?? "USD" }).format(selectedCourse.product.price ?? 0)}
</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 CoursesList;