mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
411 lines
20 KiB
React
411 lines
20 KiB
React
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");
|
||
if (v === "lessons") navigate("/lessons");
|
||
}}>
|
||
<SelectTrigger className="w-full lg:w-48 bg-card">
|
||
<SelectValue placeholder="Browse" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="courses">Courses</SelectItem>
|
||
<SelectItem value="units">Units</SelectItem>
|
||
<SelectItem value="lessons">Lessons</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;
|