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

407 lines
20 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, Timer, ChevronLeft, ChevronRight, LockIcon, Tag, Tags, 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 { cn } from "@/lib/utils";
import { Fragment } from "react";
import { PageMeta } from "@/contexts/MetadataContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { Building2 } from "lucide-react";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
import { GitBranch } from "lucide-react";
// ─── 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;
// ─── Course Card ──────────────────────────────────────────────────────────────
const CourseCard = ({ course, tierMap, onViewDetails }) => {
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}>
{locked ? <LockIcon className="size-3" /> : <Tag className="size-3" />}
{label}
</Badge>
{course.level && !locked && (
<Badge variant="outline"><GitBranch />{course.level.charAt(0).toUpperCase() + course.level.slice(1)}</Badge>
)}
{(course.categories ?? []).map((cat) => (
<Badge key={cat.id} variant="outline"><Tags /> {cat.name}</Badge>
))}
{locked && (
<Badge variant="secondary">
<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 { fmtCurrency } = useDateFormat();
const { advertisements, loading: adLoading, getActiveAdvertisement, handleAdCtaClick } = useClientAdvertisements();
const [tierCategories, setTierCategories] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const [search, setSearch] = useState("");
const [subFilter, setSubFilter] = useState("All");
const [categoryFilter, setCategoryFilter] = useState("All");
const [modalOpen, setModalOpen] = useState(false);
const [selectedCourse, setSelectedCourse] = useState(null);
const [allCategories, setAllCategories] = useState([]);
useEffect(() => {
getCourses();
api.get("/client/tiers/categories")
.then(({ data }) => setTierCategories(data.data ?? []))
.catch(() => { });
api.get("/client/courses/categories")
.then(({ data }) => setAllCategories(data.data ?? []))
.catch(() => { });
getActiveAdvertisement("course_list.banner");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const bannerAd = advertisements["course_list.banner"] ?? null;
// slug → category info map
const tierMap = useMemo(() => {
const m = {};
tierCategories.forEach((c) => { m[c.slug] = c; });
return m;
}, [tierCategories]);
const filtered = useMemo(() =>
courses
.filter((c) => {
const matchSearch = c.title.toLowerCase().includes(search.toLowerCase()) ||
(c.description ?? "").toLowerCase().includes(search.toLowerCase());
const matchSub = subFilter === "All" || c.subscription === subFilter;
const matchCategory = categoryFilter === "All" || (c.categories ?? []).some((cat) => String(cat.id) === categoryFilter);
return matchSearch && matchSub && matchCategory;
})
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)),
[courses, search, subFilter, categoryFilter]
);
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);
const handleViewDetails = (course) => {
if (course.is_locked) {
setSelectedCourse(course);
setModalOpen(true);
} else {
navigate(`/course/${course.course_id}`);
}
};
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
{ label: "Courses" },
];
// Upsell modal tier panel
const upsellTier = selectedCourse ? tierMap[selectedCourse.subscription] : null;
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-4 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 text-sm"
value={search}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
/>
<div className="flex gap-4 items-start w-full">
<Select value="courses" onValueChange={(v) => { if (v === "units") navigate("/units"); }}>
<SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Browse" />
</SelectTrigger>
<SelectContent>
<SelectItem value="courses">Courses</SelectItem>
<SelectItem value="units">Units</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>
{tierCategories.map((cat) => (
<SelectItem key={cat.slug} value={cat.slug}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
{allCategories.length > 0 && (
<Select value={categoryFilter} onValueChange={(v) => { setCategoryFilter(v); setCurrentPage(1); }}>
<SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All">All Categories</SelectItem>
{allCategories.map((cat) => (
<SelectItem key={cat.id} value={String(cat.id)}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
</div>
{/* Advertisement Banner */}
{adLoading["course_list.banner"] ? (
<BannerSkeleton />
) : (
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
)}
{categoryFilter !== "All" && (
<div className="flex items-center flex-wrap gap-2">
<span className="text-sm text-muted-foreground">Tags:</span>
{allCategories
.filter((cat) => String(cat.id) === categoryFilter)
.map((cat) => (
<Badge
key={cat.id}
variant="default"
className="cursor-pointer"
onClick={() => { setCategoryFilter("All"); setCurrentPage(1); }}
>
<Tags className="size-3" />
{cat.name}
</Badge>
))}
</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">
<Building2 className="size-40 text-primary" />
<p className="text-md">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 xs:px-4 lg:px-0">
{paginated.map((course) => (
<CourseCard
key={course.course_id}
course={course}
tierMap={tierMap}
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 */}
<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 {fmtCurrency(selectedCourse.product.price ?? 0, selectedCourse.product.currency ?? "USD")}
</Button>
)}
<Button onClick={() => { setModalOpen(false); navigate("/plans"); }}>
<LockIcon /> View Plans
</Button>
</>
}
>
<div className="space-y-6 py-2">
{upsellTier && !upsellTier.is_default && (() => {
const { cls, panel } = resolveTierBadge(selectedCourse?.subscription ?? "", tierMap);
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" /> {upsellTier.name}
</Badge>
</div>
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4">
<li className="flex items-center gap-2"><Check /> Access to {upsellTier.name} 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">{upsellTier.name}</span> plan to unlock this course.
</p>
</div>
);
})()}
</div>
</ResponsiveModal>
</div>
);
};
export default CoursesList;