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,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;
|
||||
Reference in New Issue
Block a user