units,lesson as standalone

This commit is contained in:
2026-07-10 11:45:02 +08:00
parent 182bd93d10
commit 01a2c63b06
60 changed files with 3217 additions and 606 deletions
@@ -0,0 +1,85 @@
// LessonCard — grid card for a standalone Lesson. Shared by LessonsList.jsx and
// Dashboard.jsx's "Featured Lessons" section. Mirrors UnitCard.jsx; a Lesson has
// no lesson_count/quiz_id of its own — shows unit_count instead (how many Units
// it's attached to).
import { Timer, LockIcon, Layers } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
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`;
}
export const LessonCard = ({ lesson, onViewDetails }) => {
const locked = lesson.is_locked;
const duration = formatDuration(lesson.duration_seconds);
const unitCount = Number(lesson.unit_count ?? 0);
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(lesson)}
>
<div className="flex flex-wrap gap-1.5">
{locked ? (
<Badge variant="secondary">
<LockIcon className="size-3" /> Locked
</Badge>
) : unitCount > 0 ? (
<Badge variant="outline"><Layers className="size-3" /> In {unitCount} unit{unitCount === 1 ? "" : "s"}</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">Standalone</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">
{lesson.title}
</h1>
{lesson.description && (
<p className="text-sm leading-relaxed line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400">
{lesson.description}
</p>
)}
</div>
<div className="flex items-center justify-between pt-2 mt-auto border-t">
<div className={`flex items-center gap-3 text-sm [&_svg]:size-4 ${locked ? "text-muted-foreground" : "text-card-foreground group-hover:text-blue-700 dark:group-hover:text-blue-400"}`}>
<div className="flex items-center gap-1">
<Timer /> {duration ?? "—"}
</div>
</div>
{locked && (
<span className="text-xs text-muted-foreground">Upgrade to unlock</span>
)}
</div>
</div>
);
};
export const LessonCardSkeleton = () => (
<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-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-24" />
</div>
</div>
);
@@ -0,0 +1,70 @@
// LessonUpsellModal — shown when a learner clicks a locked standalone Lesson.
// Mirrors UnitUpsellModal.jsx: a Lesson isn't independently purchasable, so this
// lists every course that would unlock it (aggregated across all its attached
// Units, since a Lesson can sit in more than one) plus a generic "View Plans"
// fallback. Shared by LessonsList and Dashboard.
import { useNavigate } from "react-router-dom";
import { LockIcon, BookOpen } from "lucide-react";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { resolveTierBadge } from "@/utils/tierBadge.util";
export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap = {} }) {
const navigate = useNavigate();
const courses = lesson?.courses ?? [];
return (
<ResponsiveModal
open={open}
onOpenChange={onOpenChange}
title={lesson?.title ?? "Lesson Details"}
description="This lesson is part of one or more courses that require a plan upgrade."
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
<Button onClick={() => { onOpenChange(false); navigate("/plans"); }}>
<LockIcon /> View Plans
</Button>
</>
}
>
<div className="space-y-3 py-2">
{courses.length === 0 ? (
<p className="text-sm text-muted-foreground">
Upgrade your plan to access this content.
</p>
) : (
courses.map((course) => {
const { label, cls } = resolveTierBadge(course.subscription, tierMap);
return (
<div
key={course.course_id}
className="flex items-center justify-between gap-3 p-4 rounded-xl border bg-muted/40"
>
<div className="flex items-center gap-2.5 min-w-0">
<BookOpen className="size-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{course.title}</p>
<Badge className={`${cls} mt-1`}>
<LockIcon className="size-3" /> {label}
</Badge>
</div>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0"
onClick={() => { onOpenChange(false); navigate(`/course/${course.course_id}`); }}
>
View Course
</Button>
</div>
);
})
)}
</div>
</ResponsiveModal>
);
}
@@ -0,0 +1,36 @@
// LockedContentPanel — full-page inline panel shown when a deep-link lands on
// content the learner can't access (locked unit or a lesson under one).
// Distinct from UnitUpsellModal (a dialog triggered from card grids) — this
// renders in place of the page body itself. Shared by UnitDetails and
// LessonDetails.
import { useNavigate } from "react-router-dom";
import { LockIcon, Zap } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function LockedContentPanel({ course, tierMap = {} }) {
const navigate = useNavigate();
const tier = course?.subscription ? tierMap[course.subscription] : null;
return (
<div className="flex flex-col items-center gap-6 py-32 text-center px-4">
<div className="h-16 w-16 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<LockIcon className="size-7 text-amber-500" />
</div>
<div className="space-y-2 max-w-sm">
<h2 className="text-xl font-semibold">Premium / Exclusive Content</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
{course
? `This content is part of "${course.title}"${tier?.name ? ` (${tier.name} plan)` : ""}. Upgrade your plan or view the course to unlock it.`
: "Upgrade your plan to access this content."}
</p>
</div>
<div className="flex flex-col items-center gap-2">
<Button onClick={() => navigate('/plans')} className="gap-1.5">
<Zap className="size-4" /> View Available Plans
</Button>
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
</div>
</div>
);
}
@@ -0,0 +1,96 @@
// PlanComparisonTable — side-by-side feature matrix, an alternate view next to
// the card grid on PlanList.jsx. Rows are the union of every active plan's
// admin-authored `features` list; a plan gets a checkmark for a row if that
// exact feature text is present in its own list.
import * as LucideIcons from "lucide-react";
import { Check, Minus, Tag } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { resolveTierBadge } from "@/utils/tierBadge.util";
export default function PlanComparisonTable({ plans, myTier, tierMap, fmtCurrency, onSelect }) {
const featureRows = [...new Set(
plans.flatMap((p) => (p.features ?? []).map((f) => f.text))
)];
return (
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full text-sm border-collapse min-w-[640px]">
<thead>
<tr className="border-b">
<th className="text-left p-4 w-48 align-bottom text-muted-foreground font-medium">Plan</th>
{plans.map((plan) => {
const { label, cls } = resolveTierBadge(plan.tier, tierMap);
const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
return (
<th key={plan.plan_id} className="p-4 text-center align-bottom min-w-[160px]">
<div className="flex flex-col items-center gap-2">
<Badge className={cls}><Icon className="size-3" />{label}</Badge>
<span className="font-semibold text-foreground">{plan.label}</span>
<span className="text-lg font-bold">{fmtCurrency(plan.price, plan.currency)}</span>
{isCurrent && <Badge variant="secondary" className="text-xs">Current Plan</Badge>}
</div>
</th>
);
})}
</tr>
</thead>
<tbody>
<tr className="border-b bg-muted/30">
<td className="p-3 font-medium text-muted-foreground">Courses included</td>
{plans.map((plan) => (
<td key={plan.plan_id} className="p-3 text-center">
{plan.course_count > 0 ? plan.course_count : "—"}
</td>
))}
</tr>
{featureRows.map((text, i) => (
<tr key={text} className={`border-b ${i % 2 === 1 ? "bg-muted/30" : ""}`}>
<td className="p-3 text-muted-foreground">{text}</td>
{plans.map((plan) => {
const included = (plan.features ?? []).some((f) => f.text === text);
return (
<td key={plan.plan_id} className="p-3 text-center">
{included
? <Check className="size-4 mx-auto text-green-500" />
: <Minus className="size-4 mx-auto text-muted-foreground/30" />
}
</td>
);
})}
</tr>
))}
{featureRows.length === 0 && (
<tr>
<td colSpan={plans.length + 1} className="p-6 text-center text-muted-foreground">
No features have been added to these plans yet.
</td>
</tr>
)}
</tbody>
<tfoot>
<tr>
<td className="p-4" />
{plans.map((plan) => {
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
return (
<td key={plan.plan_id} className="p-4 text-center">
<Button
size="sm"
variant={isCurrent ? "secondary" : "default"}
disabled={isCurrent || !plan.is_active}
onClick={() => onSelect(plan)}
>
{isCurrent ? "Current" : !plan.is_active ? "Not Available" : "Select"}
</Button>
</td>
);
})}
</tr>
</tfoot>
</table>
</div>
);
}
@@ -0,0 +1,239 @@
import { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { ClipboardCheck, CheckCheck, SendHorizonal, Lock, Zap, Info, Tag } from "lucide-react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { Button } from "@/components/ui/button";
import { useNavigate } from "react-router-dom";
import api from "@/utils/api.util";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { resolveTierBadge } from "@/utils/tierBadge.util";
function TierBadge({ tier }) {
const { tierMap } = useClientTiers();
const { rank, label, cls } = resolveTierBadge(tier ?? 'free', tierMap);
return (
<Badge className={`gap-1 ${cls} w-fit shrink-0`}>
{rank > 0 ? <Lock className="size-3" /> : <Tag className="size-3" />}
{label}
</Badge>
);
}
// ─── PassQuiz — task requirement block for pass_quiz type ─────────────────────
// Mirrors ReadUnit.jsx's fetch-detail-and-navigate pattern. A quiz is always
// unit-scoped; navigation goes into the course reader if the unit is attached
// to a course, otherwise the standalone unit reader.
const PassQuiz = ({ title = "Pass Quizzes", quizzes = [], groupId, taskListId, taskId }) => {
const navigate = useNavigate();
const [details, setDetails] = useState({});
const [locked, setLocked] = useState({});
const [lockedInfo, setLockedInfo] = useState({});
const [unavailable, setUnavailable] = useState({});
const [fetching, setFetching] = useState({});
const [selected, setSelected] = useState(null);
useEffect(() => {
quizzes.forEach(async (q) => {
if (!q.reference_id) return;
setFetching((prev) => ({ ...prev, [q.reference_id]: true }));
try {
const res = await api.get(`/client/courses/quiz/uuid/${q.reference_id}`);
const d = res.data?.data;
if (d) setDetails((prev) => ({ ...prev, [q.reference_id]: d }));
} catch (err) {
if (err?.response?.status === 403) {
setLocked((prev) => ({ ...prev, [q.reference_id]: true }));
const course = err.response?.data?.course;
if (course) setLockedInfo((prev) => ({ ...prev, [q.reference_id]: course }));
} else {
setUnavailable((prev) => ({ ...prev, [q.reference_id]: true }));
}
} finally {
setFetching((prev) => ({ ...prev, [q.reference_id]: false }));
}
});
}, []);
const goToQuiz = (info) => {
if (!info) return;
const taskCtx = taskId ? { has_task: true, groupId, taskListId, taskId } : undefined;
if (info.unit?.course?.course_id) {
navigate(`/course/${info.unit.course.course_id}/unit`, {
state: { quizUnitId: info.unit.unit_id, ...(taskCtx ? { taskCtx } : {}) },
});
} else if (info.unit?.uuid) {
navigate(`/units/${info.unit.uuid}/read`, { state: { quizId: true } });
}
};
if (!quizzes.length) return null;
const hasLocked = Object.values(locked).some(Boolean);
return (
<>
<div className="border rounded-lg bg-card overflow-hidden">
<div className="px-6 py-4 border-b flex items-center gap-2">
<ClipboardCheck className="size-4 text-muted-foreground" />
<h2 className="font-semibold text-sm">{title}</h2>
<Badge variant="secondary" className="ml-auto">{quizzes.length}</Badge>
</div>
{hasLocked && (
<div className="flex items-start gap-3 px-5 py-3.5 border-b bg-amber-50 dark:bg-amber-950/30 text-amber-800 dark:text-amber-300">
<Info className="size-4 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-snug">Subscription Required</p>
<p className="text-xs mt-0.5 text-amber-700 dark:text-amber-400 leading-relaxed">
To complete this activity, subscribe to one of our available tier plans.
</p>
</div>
<Button size="sm" variant="outline" className="shrink-0 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40" onClick={() => navigate('/plans')}>
<Zap className="size-3.5" /> View Plans
</Button>
</div>
)}
<ScrollArea className="w-full bg-muted overflow-hidden">
<div className="flex gap-4 p-4">
{quizzes.map((q) => {
const info = details[q.reference_id];
const courseInfo = lockedInfo[q.reference_id];
const isLocked = locked[q.reference_id];
const isUnavailable = unavailable[q.reference_id];
const isFetching = fetching[q.reference_id];
const passed = info?.has_passed || q.completed;
if (isLocked) {
return (
<div
key={q.id}
onClick={() => navigate('/plans')}
className="bg-card rounded-2xl border border-dashed p-4 flex flex-col gap-3 hover:bg-muted/60 hover:shadow-sm transition-all cursor-pointer group w-80 shrink-0 opacity-80"
>
<div className="flex items-center gap-1.5">
<ClipboardCheck className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground font-medium">Quiz</span>
<div className="ml-auto"><TierBadge tier={courseInfo?.subscription} /></div>
</div>
<h1 className="text-base font-semibold leading-snug line-clamp-2 text-muted-foreground">{q.title}</h1>
<p className="text-sm text-muted-foreground">
Subscribe to <span className="font-medium">{courseInfo?.title ?? 'this course'}</span> to unlock this quiz.
</p>
<div className="mt-auto">
<Button size="sm" className="w-full gap-1.5" onClick={(e) => { e.stopPropagation(); navigate('/plans'); }}>
<Zap className="size-3.5" /> Upgrade to unlock
</Button>
</div>
</div>
);
}
if (isUnavailable) {
return (
<div key={q.id} className="bg-card rounded-2xl border border-dashed p-4 flex flex-col gap-3 w-80 shrink-0 opacity-50 cursor-not-allowed">
<div className="flex items-center gap-1.5">
<ClipboardCheck className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground font-medium">Quiz</span>
<Badge variant="secondary" className="ml-auto gap-1 text-muted-foreground text-xs">
<Lock className="size-3" /> Unavailable
</Badge>
</div>
<h1 className="text-base font-semibold leading-snug line-clamp-2 text-muted-foreground">{q.title}</h1>
<p className="text-sm text-muted-foreground">This quiz is no longer available.</p>
</div>
);
}
return (
<div
key={q.id}
onClick={() => !isFetching && setSelected(q)}
className={`bg-card rounded-2xl border p-4 flex flex-col gap-3 transition-all w-80 shrink-0 ${
isFetching ? 'opacity-60 cursor-wait' : 'hover:bg-muted/60 hover:shadow-sm cursor-pointer group'
}`}
>
<div className="flex items-center gap-1.5">
<ClipboardCheck className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground font-medium">Quiz</span>
<div className="ml-auto">
{info?.unit?.course?.subscription
? <TierBadge tier={info.unit.course.subscription} />
: isFetching && <Badge variant="secondary" className="opacity-40 text-xs">Loading…</Badge>
}
</div>
</div>
{info?.unit?.title && (
<p className="text-xs text-muted-foreground truncate -mt-1">
in <span className="text-foreground/70 font-medium">{info.unit.title}</span>
</p>
)}
<h1 className="text-base font-semibold leading-snug line-clamp-2">{q.title}</h1>
<div className="flex items-center justify-between text-sm mt-auto pt-2 border-t">
{passed ? (
<span className="flex items-center gap-1.5 text-green-600 dark:text-green-400 font-medium">
<CheckCheck className="size-4" /> Passed
</span>
) : (
<span className="text-muted-foreground font-medium">Not Attempted</span>
)}
{info?.passing_score && (
<span className="text-xs text-muted-foreground">{info.passing_score}% to pass</span>
)}
</div>
</div>
);
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
<ResponsiveModal
open={!!selected}
onOpenChange={(v) => !v && setSelected(null)}
title={selected?.title}
description="Quiz Info"
footer={
<>
<Button variant="outline" onClick={() => setSelected(null)}>Cancel</Button>
<Button
onClick={() => goToQuiz(details[selected?.reference_id])}
disabled={!details[selected?.reference_id]}
>
<SendHorizonal /> Take Quiz
</Button>
</>
}
>
{selected && (() => {
const info = details[selected.reference_id];
return (
<div className="flex flex-col gap-4">
{(info?.has_passed || selected.completed) && (
<div className="flex items-center gap-2 bg-green-50 dark:bg-green-950/40 text-green-700 dark:text-green-400 text-sm font-medium rounded-lg px-4 py-3">
<CheckCheck className="size-4 shrink-0" /> Already Passed
</div>
)}
{info?.unit?.title && (
<p className="text-sm">
<span className="text-muted-foreground">Unit: </span>
<span className="font-medium">{info.unit.title}</span>
</p>
)}
{info?.passing_score && (
<p className="text-sm">
<span className="text-muted-foreground">Passing score: </span>
<span className="font-medium">{info.passing_score}%</span>
</p>
)}
</div>
);
})()}
</ResponsiveModal>
</>
);
};
export default PassQuiz;
+18 -1
View File
@@ -34,6 +34,7 @@ import { useEffect, useRef, useState } from "react"
import { AVATAR_COLORS } from "@/data/profile.data"
import { Badge } from "@/components/ui/badge"
import ClientNotificationBell from "@/components/generic/ClientNotificationBell"
import StickyAnnouncementBar from "@/components/generic/StickyAnnouncementBar"
import { QRCodeCanvas } from "qrcode.react"
// ─── Refer / Invite dialog ────────────────────────────────────────────────────
@@ -142,6 +143,8 @@ function ClientNav() {
const navigate = useNavigate()
const { user, logout } = useAuth()
const navRef = useRef(null)
// Background fetches only — nav rendering never waits on these
const { achievements, getAchievements } = useProfile()
const { myTier, tierMap, tierCategories, getMyTier, getTierCategories } = useClientTiers()
@@ -159,6 +162,19 @@ function ClientNav() {
if (tierCategories.length === 0) getTierCategories();
}, [user]);
// Used by sticky overlays (e.g. StickyAnnouncementBar) to avoid overlapping the fixed header.
useEffect(() => {
if (!navRef.current) return
const update = () => {
document.documentElement.style.setProperty('--navbar-h', `${navRef.current.offsetHeight}px`)
}
update()
const ro = new ResizeObserver(update)
ro.observe(navRef.current)
return () => ro.disconnect()
}, [])
// ── Derive directly from auth user — same pattern as admin UserMenu ──────
// No extra fetch, no loading state, no flicker on reload.
const given = user?.personal_info?.name?.given_name ?? ""
@@ -219,7 +235,7 @@ function ClientNav() {
return (
<>
<nav className="bg-card fixed w-full z-50 top-0 border-b border-default">
<nav ref={navRef} className="bg-card fixed w-full z-50 top-0 border-b border-default">
<div className="flex flex-wrap items-center justify-between mx-auto py-3 px-6">
{/* Logo */}
@@ -345,6 +361,7 @@ const ClientLayout = () => {
<ClientProvider>
<div className="min-h-screen flex flex-col">
<ClientNav />
<StickyAnnouncementBar />
<div className="flex-1 flex flex-col">
<Outlet />
</div>
+5 -1
View File
@@ -249,13 +249,17 @@ const CoursesList = () => {
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"); }}>
<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>
+53 -1
View File
@@ -19,6 +19,8 @@ import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import UnitUpsellModal from "../components/UnitUpsellModal";
import { UnitCard, UnitCardSkeleton } from "../components/UnitCard";
import LessonUpsellModal from "../components/LessonUpsellModal";
import { LessonCard, LessonCardSkeleton } from "../components/LessonCard";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { cn } from "@/lib/utils";
import { useGroup } from "@/contexts/ClientGroupContext";
@@ -208,7 +210,7 @@ const Client = () => {
const navigate = useNavigate();
const { state: navState } = useLocation();
const { courses, coursesLoading, getCourses } = useClientCourses();
const { units, unitsLoading, getUnits } = useLibrary();
const { units, unitsLoading, getUnits, lessons, lessonsLoading, getLessons } = useLibrary();
const { myTier, getMyTier, tierMap } = useClientTiers();
const { groups, fetchGroups, loading: groupLoading } = useGroup();
const {
@@ -223,6 +225,9 @@ const Client = () => {
const [unitModalOpen, setUnitModalOpen] = useState(false);
const [selectedUnit, setSelectedUnit] = useState(null);
const [lessonModalOpen, setLessonModalOpen] = useState(false);
const [selectedLesson, setSelectedLesson] = useState(null);
const [popupOpen, setPopupOpen] = useState(false);
const userTier = myTier?.tier ?? "free";
@@ -243,6 +248,7 @@ const Client = () => {
useEffect(() => {
getCourses();
getUnits();
getLessons();
if (!myTier) getMyTier();
}, []);
@@ -262,6 +268,7 @@ const Client = () => {
// Show only first 3
const featuredCourses = courses.slice(0, 3);
const featuredUnits = units.slice(0, 3);
const featuredLessons = lessons.slice(0, 3);
const breadcrumbItems = [
{ label: "My Groups", icon: <Users className="size-4" /> },
@@ -287,6 +294,15 @@ const Client = () => {
}
};
const handleViewLessonDetails = (lesson) => {
if (lesson.is_locked) {
setSelectedLesson(lesson);
setLessonModalOpen(true);
} else {
navigate(`/lessons/${lesson.uuid}`);
}
};
return (
<div>
@@ -378,6 +394,34 @@ const Client = () => {
)}
</div>
{/* ── Featured Lessons (first 3) ── */}
<div className="flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<h1 className="text-2xl font-medium">Lessons</h1>
<Button onClick={() => navigate(`/lessons`)}>View All</Button>
</div>
{lessonsLoading ? (
<div className="grid lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<LessonCardSkeleton key={i} />
))}
</div>
) : featuredLessons.length === 0 ? (
<p className="text-sm text-muted-foreground">No lessons available yet.</p>
) : (
<div className="grid lg:grid-cols-3 gap-4">
{featuredLessons.map((lesson) => (
<LessonCard
key={lesson.lesson_id}
lesson={lesson}
onViewDetails={handleViewLessonDetails}
/>
))}
</div>
)}
</div>
</div>
</div>
@@ -437,6 +481,14 @@ const Client = () => {
unit={selectedUnit}
tierMap={tierMap}
/>
{/* ── Upsell Modal — only for locked lessons ── */}
<LessonUpsellModal
open={lessonModalOpen}
onOpenChange={setLessonModalOpen}
lesson={selectedLesson}
tierMap={tierMap}
/>
</div>
);
};
+165
View File
@@ -0,0 +1,165 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate } from "react-router-dom";
import {
House, Timer, SendHorizonal, CheckCheck, ListChecks, ArrowRight, Layers, Hourglass,
} from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { useEffect } from "react";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { PageMeta } from "@/contexts/MetadataContext";
import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatDuration(seconds = 0) {
if (!seconds) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}hr ${m}min`;
return `${m}min`;
}
// ─── Lesson Details ─────────────────────────────────────────────────────────
const LessonDetails = () => {
const { uuid } = useParams();
const navigate = useNavigate();
const { getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson } = useLibrary();
const { tierMap, getTierCategories } = useClientTiers();
const hasCompleted = lesson?.status === "completed";
const unit = lesson?.unit ?? null;
const hasUnit = !!unit?.uuid;
useEffect(() => {
getTierCategories();
getLesson(uuid);
return () => resetLesson();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uuid]);
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
{ label: "Lessons", to: `/lessons` },
...(hasUnit ? [{ label: unit.title, to: `/units/${unit.uuid}` }] : []),
{ label: lesson?.title ?? "Lesson" },
];
// ── Deep-link to a lesson under a locked unit — inline blocked panel ────
if (unitBlocked) {
return <LockedContentPanel course={unitBlockedInfo?.course} tierMap={tierMap} />;
}
if (lessonLoading || !lesson) {
return (
<div className="my-17 p-8 lg:container lg:mx-auto space-y-4">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-10 w-2/3" />
<Skeleton className="h-5 w-full max-w-2xl" />
</div>
);
}
const handleStart = () => {
if (!hasUnit) return;
navigate(`/units/${unit.uuid}/read`, { state: { lessonId: lesson.lesson_id } });
};
return (
<div>
<PageMeta title={`${lesson.title} - STARR`} description={lesson.description} />
<div className="my-17">
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-6">
{/* Hero */}
<div className="bg-primary dark:bg-accent/50">
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-6 xs:py-6 lg:px-4 lg:py-16">
<AppBreadcrumb
color={{ link: { color: "text-white" }, page: { color: "text-white" } }}
items={items}
/>
<div className="flex lg:flex-row items-start justify-between w-full text-white">
<div className="flex flex-col gap-4">
<h1 className="font-bold xs:text-2xl lg:text-4xl">{lesson.title}</h1>
<p className="max-w-2xl xs:text-sm lg:text-lg">{lesson.description ?? ""}</p>
{lesson.duration_seconds > 0 && (
<div className="[&_svg]:size-4 flex gap-1.5 items-center">
<Timer />
{formatDuration(lesson.duration_seconds)}
</div>
)}
<div className="w-fit">
{!hasUnit ? (
<div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground">
<Hourglass className="size-4 shrink-0" />
This lesson isn't part of a unit yet. Check back later.
</div>
) : (
<Button
className="w-fit bg-blue-500"
onClick={handleStart}
>
{hasCompleted
? <><CheckCheck /> Start Again</>
: <><SendHorizonal /> Start Lesson</>
}
</Button>
)}
</div>
</div>
</div>
</div>
</div>
{/* Body */}
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-3 xs:-mt-5 lg:-mt-0">
<div className="flex flex-col gap-8 max-w-3xl">
<div className="space-y-4">
<div className="font-bold text-2xl">About this lesson</div>
<div className="space-y-4 text-muted-foreground lg:text-lg">
<p>{lesson.description ?? ""}</p>
</div>
</div>
{lesson.objectives?.length > 0 && (
<div className="space-y-4">
<div className="font-bold text-2xl flex items-center gap-2">
<ListChecks className="size-5 text-muted-foreground" />
Objectives
</div>
<ul className="space-y-2">
{lesson.objectives.map((obj) => (
<li key={obj.objective_id} className="flex items-start gap-2 text-muted-foreground lg:text-lg">
<span className="mt-2.5 size-1.5 rounded-full bg-muted-foreground/60 shrink-0" />
{obj.text}
</li>
))}
</ul>
</div>
)}
{hasUnit && (
<button
type="button"
onClick={() => navigate(`/units/${unit.uuid}`)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
>
<Layers className="size-4" />
Part of unit: <span className="font-medium text-foreground">{unit.title}</span>
<ArrowRight className="size-3.5" />
</button>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default LessonDetails;
+201
View File
@@ -0,0 +1,201 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, ChevronLeft, ChevronRight, Layers } 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 { Button } from "@/components/ui/button";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import LessonUpsellModal from "../components/LessonUpsellModal";
import { LessonCard, LessonCardSkeleton } from "../components/LessonCard";
import { PageMeta } from "@/contexts/MetadataContext";
const ITEMS_PER_PAGE = 10;
// ─── 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> lessons
</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 LessonsList = () => {
const navigate = useNavigate();
const { lessons, lessonsLoading, getLessons } = useLibrary();
const { tierMap, getTierCategories } = useClientTiers();
const [currentPage, setCurrentPage] = useState(1);
const [search, setSearch] = useState("");
const [lockFilter, setLockFilter] = useState("All");
const [modalOpen, setModalOpen] = useState(false);
const [selectedLesson, setSelectedLesson] = useState(null);
useEffect(() => {
getLessons();
getTierCategories();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filtered = useMemo(() =>
lessons
.filter((l) => {
const matchSearch = l.title.toLowerCase().includes(search.toLowerCase()) ||
(l.description ?? "").toLowerCase().includes(search.toLowerCase());
const matchLock = lockFilter === "All"
|| (lockFilter === "Unlocked" && !l.is_locked)
|| (lockFilter === "Locked" && l.is_locked);
return matchSearch && matchLock;
})
.sort((a, b) => a.title.localeCompare(b.title)),
[lessons, search, lockFilter]
);
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 = (lesson) => {
if (lesson.is_locked) {
setSelectedLesson(lesson);
setModalOpen(true);
} else {
navigate(`/lessons/${lesson.uuid}`);
}
};
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
{ label: "Lessons" },
];
return (
<div>
<PageMeta title="Lessons - STARR" description="Browse standalone lessons you can start right away." />
<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 lessons..."
className="w-full bg-card lg:max-w-64 text-sm"
value={search}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
/>
<Select value={lockFilter} onValueChange={(v) => { setLockFilter(v); setCurrentPage(1); }}>
<SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Access" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All">All Lessons</SelectItem>
<SelectItem value="Unlocked">Unlocked</SelectItem>
<SelectItem value="Locked">Locked</SelectItem>
</SelectContent>
</Select>
<Select value="lessons" onValueChange={(v) => {
if (v === "courses") navigate("/course");
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>
<SelectItem value="lessons">Lessons</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Lesson Grid */}
{lessonsLoading ? (
<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) => <LessonCardSkeleton key={i} />)}
</div>
) : paginated.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<Layers className="size-40 text-primary" />
<p className="text-md">No lessons 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((lesson) => (
<LessonCard
key={lesson.lesson_id}
lesson={lesson}
onViewDetails={handleViewDetails}
/>
))}
</div>
)}
{!lessonsLoading && filtered.length > ITEMS_PER_PAGE && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={filtered.length}
itemsPerPage={ITEMS_PER_PAGE}
onPageChange={setCurrentPage}
/>
)}
</div>
</div>
<LessonUpsellModal
open={modalOpen}
onOpenChange={setModalOpen}
lesson={selectedLesson}
tierMap={tierMap}
/>
</div>
);
};
export default LessonsList;
+90 -64
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import * as LucideIcons from "lucide-react";
import {
Card, CardContent, CardDescription,
CardFooter, CardHeader, CardTitle,
@@ -14,7 +15,7 @@ import {
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import {
BookOpen, Clock, Check,
Tag, LockIcon, Zap, RotateCcw,
Tag, RotateCcw, LaptopMinimal, Table as TableIcon,
} from "lucide-react";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
@@ -24,6 +25,8 @@ import { PageMeta } from "@/contexts/MetadataContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import PlanComparisonTable from "../components/PlanComparisonTable";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -53,31 +56,6 @@ function formatCourseDuration(seconds = 0) {
return `${m}m`;
}
// Badge styles per tier
const TIER_STYLES = {
free: {
badge: "bg-gradient-to-r from-lime-400 to-lime-600 text-white",
button: "default",
icon: Tag,
label: "Free",
ring: "",
},
premium: {
badge: "bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white",
button: "default",
icon: Zap,
label: "Premium",
ring: "ring-2 ring-fuchsia-400/40",
},
exclusive: {
badge: "bg-gradient-to-r from-rose-500 to-red-600 text-white",
button: "default",
icon: LockIcon,
label: "Exclusive",
ring: "ring-2 ring-rose-400/40",
},
};
// ─── Skeleton ──────────────────────────────────────────────────────────────────
const PlanSkeleton = () => (
@@ -104,20 +82,22 @@ const PlanSkeleton = () => (
const PREVIEW_COURSE_LIMIT = 2;
const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft }) => {
const fmtPlanPrice = (p) => `${p.currency} ${Number(p.price).toFixed(2)}`;
const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSecsLeft }) => {
const { fmtCurrency } = useDateFormat();
const [coursesOpen, setCoursesOpen] = useState(false);
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
const Icon = style.icon;
const { label: tierLabel, cls: badgeCls, rank } = resolveTierBadge(plan.tier, tierMap);
const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
const ring = rank > 0 ? "ring-2 ring-primary/30" : "";
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
const duration = formatDuration(plan.duration_days, plan.duration_unit);
const features = plan.features ?? [];
const previewCourses = plan.courses?.slice(0, PREVIEW_COURSE_LIMIT) ?? [];
const extraCount = (plan.courses?.length ?? 0) - PREVIEW_COURSE_LIMIT;
return (
<>
<Card className={`relative flex flex-col ${style.ring}`}>
<Card className={`relative flex flex-col ${ring}`}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{plan.label}</CardTitle>
@@ -125,15 +105,15 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
{isCurrent && (
<Badge className="bg-green-500 text-white">Current Plan</Badge>
)}
<Badge className={style.badge}>
<Badge className={badgeCls}>
<Icon />
{style.label}
{tierLabel}
</Badge>
</div>
</div>
<CardDescription>
<span className="text-3xl font-bold text-foreground">
{fmtPlanPrice(plan)}
{fmtCurrency(plan.price, plan.currency)}
</span>
{duration && (
<span className="text-sm text-muted-foreground ml-1">/ {duration}</span>
@@ -142,7 +122,18 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
</CardHeader>
<CardContent className="flex-1 space-y-4">
{features.length > 0 && (
<ul className="space-y-1.5">
{features.slice(0, 4).map((f, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
<span>{f.text}</span>
</li>
))}
</ul>
)}
{plan.courses?.length > 0 ? (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
@@ -219,10 +210,9 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
) : !isCurrent ? (
<Button
className="flex-1"
variant={style.button}
onClick={() => onSelect(plan)}
>
{plan.tier === "free" ? "Current" : `Get ${style.label}`}
{plan.tier === "free" ? "Current" : `Get ${tierLabel}`}
</Button>
) : null}
</CardFooter>
@@ -253,9 +243,9 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
<DialogHeader>
<div className="flex items-start gap-2">
<DialogTitle className="leading-snug">{plan.label}</DialogTitle>
<Badge className={`${style.badge} shrink-0`}>
<Badge className={`${badgeCls} shrink-0`}>
<Icon className="size-3" />
{style.label}
{tierLabel}
</Badge>
</div>
</DialogHeader>
@@ -263,13 +253,25 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
{/* Price + Duration */}
<div className="flex items-baseline gap-1.5">
<span className="text-2xl font-bold">
{fmtPlanPrice(plan)}
{fmtCurrency(plan.price, plan.currency)}
</span>
{duration && (
<span className="text-sm text-muted-foreground">/ {duration}</span>
)}
</div>
{/* Features */}
{features.length > 0 && (
<ul className="space-y-1.5">
{features.map((f, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
<span>{f.text}</span>
</li>
))}
</ul>
)}
{/* Description */}
{plan.description && (
<p className="text-sm text-muted-foreground leading-relaxed -mt-1">
@@ -326,7 +328,7 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
className="w-full"
onClick={() => { setCoursesOpen(false); onSelect(plan); }}
>
Get {style.label}
Get {tierLabel}
</Button>
</DialogFooter>
)}
@@ -340,11 +342,11 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
export default function PlanList() {
const navigate = useNavigate();
const { plans, plansLoading, myTier, tierLoading, getPlans, getMyTier, resetMyTier } = useClientTiers();
const { fmtDate } = useDateFormat();
const { plans, plansLoading, myTier, tierLoading, tierMap, getPlans, getMyTier, getTierCategories, resetMyTier } = useClientTiers();
const { fmtDate, fmtCurrency } = useDateFormat();
const { advertisements, loading: adLoading, getActiveAdvertisement, handleAdCtaClick } = useClientAdvertisements();
const fmtPlanPrice = (p) => `${p.currency} ${Number(p.price).toFixed(2)}`;
const [view, setView] = useState("grid");
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
const [refundLoading, setRefundLoading] = useState(false);
const [refundSecsLeft, setRefundSecsLeft] = useState(0);
@@ -353,6 +355,7 @@ export default function PlanList() {
useEffect(() => {
getPlans();
getMyTier();
getTierCategories();
getActiveAdvertisement("plans.banner");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getPlans, getMyTier]);
@@ -410,36 +413,59 @@ export default function PlanList() {
)}
{/* Section Header */}
<div className="text-center mt-6">
<h2 className="text-3xl font-bold">Available Plans</h2>
<p className="text-muted-foreground mt-2">
Choose a subscription that matches your goals.
</p>
<div className="flex flex-col items-center gap-4 mt-6">
<div className="text-center">
<h2 className="text-3xl font-bold">Available Plans</h2>
<p className="text-muted-foreground mt-2">
Choose a subscription that matches your goals.
</p>
</div>
{!plansLoading && plans.length > 0 && (
<div className="flex items-center gap-2">
<Button size="sm" variant={view === "grid" ? "default" : "outline"} onClick={() => setView("grid")}>
<LaptopMinimal /> Cards
</Button>
<Button size="sm" variant={view === "table" ? "default" : "outline"} onClick={() => setView("table")}>
<TableIcon /> Compare
</Button>
</div>
)}
</div>
{/* Plan Cards */}
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{plansLoading || tierLoading ? (
Array.from({ length: 3 }).map((_, i) => <PlanSkeleton key={i} />)
) : plans.length === 0 ? (
<div className="col-span-full flex flex-col items-center justify-center py-20">
<BookOpen className="size-10 mb-3" />
<p className="text-sm">No plans available at the moment.</p>
</div>
) : (
plans.map((plan) => (
{/* Plan Cards / Comparison Table */}
{plansLoading || tierLoading ? (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => <PlanSkeleton key={i} />)}
</div>
) : plans.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<BookOpen className="size-10 mb-3" />
<p className="text-sm">No plans available at the moment.</p>
</div>
) : view === "table" ? (
<PlanComparisonTable
plans={plans}
myTier={myTier}
tierMap={tierMap}
fmtCurrency={fmtCurrency}
onSelect={handleSelectPlan}
/>
) : (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{plans.map((plan) => (
<PlanCard
key={plan.plan_id}
plan={plan}
myTier={myTier}
tierMap={tierMap}
onSelect={handleSelectPlan}
onView={handleViewPlan}
onRefund={handleRefundClick}
refundSecsLeft={refundSecsLeft}
/>
))
)}
</div>
))}
</div>
)}
</div>
</div>
@@ -479,7 +505,7 @@ export default function PlanList() {
<div className="flex justify-between">
<span className="text-muted-foreground">Refund amount</span>
<span className="font-medium">
{refundPlan ? fmtPlanPrice(refundPlan) : "—"}
{refundPlan ? fmtCurrency(refundPlan.price, refundPlan.currency) : "—"}
</span>
</div>
{myTier?.expires_at && (
+14 -30
View File
@@ -1,8 +1,8 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate } from "react-router-dom";
import {
House, Timer, Layers, LockIcon, SendHorizonal, CheckCheck, CheckCircle2, Circle,
FileQuestion, Hourglass, Zap, ClipboardList,
House, Timer, Layers, SendHorizonal, CheckCheck, CheckCircle2, Circle,
FileQuestion, Hourglass, ClipboardList,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
@@ -13,6 +13,7 @@ import { useEffect } from "react";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { PageMeta } from "@/contexts/MetadataContext";
import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -63,12 +64,17 @@ const UnitContentCard = ({ unitDetail, onLessonClick, onQuizClick }) => {
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-200 dark:hover:bg-blue-500 transition-colors cursor-pointer"
onClick={() => onLessonClick(lesson)}
>
<div className="flex items-center gap-3 select-none min-w-0">
<div className="flex items-start gap-3 select-none min-w-0">
{lesson.status === "completed"
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
: <Circle className="size-4 text-muted-foreground/40 shrink-0" />
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0 mt-0.5" />
: <Circle className="size-4 text-muted-foreground/40 shrink-0 mt-0.5" />
}
<span className="text-md text-card-foreground truncate">{lesson.title}</span>
<div className="min-w-0">
<span className="text-md text-card-foreground truncate block">{lesson.title}</span>
{lesson.description && (
<span className="text-sm text-muted-foreground line-clamp-1 block">{lesson.description}</span>
)}
</div>
</div>
{lesson.duration_seconds > 0 && (
<span className="text-sm shrink-0 ml-2">{formatDuration(lesson.duration_seconds)}</span>
@@ -130,29 +136,7 @@ const UnitDetails = () => {
// ── Deep-link to a locked unit — inline blocked panel, not a redirect ────
if (unitBlocked) {
const course = unitBlockedInfo?.course;
const tier = course?.subscription ? tierMap[course.subscription] : null;
return (
<div className="flex flex-col items-center gap-6 py-32 text-center px-4">
<div className="h-16 w-16 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<LockIcon className="size-7 text-amber-500" />
</div>
<div className="space-y-2 max-w-sm">
<h2 className="text-xl font-semibold">Premium / Exclusive Content</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
{course
? `This unit is part of "${course.title}"${tier?.name ? ` (${tier.name} plan)` : ""}. Upgrade your plan or view the course to unlock it.`
: "Upgrade your plan to access this unit."}
</p>
</div>
<div className="flex flex-col items-center gap-2">
<Button onClick={() => navigate('/plans')} className="gap-1.5">
<Zap className="size-4" /> View Available Plans
</Button>
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
</div>
</div>
);
return <LockedContentPanel course={unitBlockedInfo?.course} tierMap={tierMap} />;
}
if (unitDetailLoading) {
@@ -166,7 +150,7 @@ const UnitDetails = () => {
}
const handleLessonClick = (lesson) => {
navigate(`/units/${uuid}/read`, { state: { lessonId: lesson.lesson_id } });
navigate(`/lessons/${lesson.uuid}`);
};
const handleQuizClick = () => {
navigate(`/units/${uuid}/read`, { state: { quizId: true } });
+5 -1
View File
@@ -138,13 +138,17 @@ const UnitsList = () => {
<SelectItem value="Locked">Locked</SelectItem>
</SelectContent>
</Select>
<Select value="units" onValueChange={(v) => { if (v === "courses") navigate("/course"); }}>
<Select value="units" onValueChange={(v) => {
if (v === "courses") navigate("/course");
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>
</div>
+48 -86
View File
@@ -1,16 +1,19 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import * as LucideIcons from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
ArrowLeft, BookOpen, Clock, Check,
Tag, LockIcon, Zap, CalendarDays,
Star, Users, Trophy, Shield, Flame,
Tag, CalendarDays,
} from "lucide-react";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useProfile } from "@/contexts/ProfileProvider";
import { PageMeta } from "@/contexts/MetadataContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { getTierColor } from "@/utils/tierColors";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -33,59 +36,6 @@ function formatCourseDuration(seconds = 0) {
return `${m}m`;
}
// ─── Tier config ──────────────────────────────────────────────────────────────
const TIER_STYLES = {
free: {
badge: "bg-gradient-to-r from-lime-400 to-lime-600 text-white",
heroBg: "from-lime-500 via-green-600 to-emerald-700",
accentColor: "text-lime-600 dark:text-lime-400",
accentBg: "bg-lime-50 dark:bg-lime-950/30",
accentBorder: "border-lime-200 dark:border-lime-800",
icon: Tag,
label: "Free",
tagline: "Start your learning journey — no cost, no commitment.",
perks: [
{ icon: BookOpen, text: "Access to free course library" },
{ icon: Users, text: "Join our learning community" },
{ icon: Shield, text: "Track your progress & achievements" },
{ icon: Star, text: "No credit card required" },
],
},
premium: {
badge: "bg-gradient-to-r from-fuchsia-500 to-purple-600 text-white",
heroBg: "from-fuchsia-600 via-purple-700 to-violet-800",
accentColor: "text-fuchsia-600 dark:text-fuchsia-400",
accentBg: "bg-fuchsia-50 dark:bg-fuchsia-950/30",
accentBorder: "border-fuchsia-200 dark:border-fuchsia-800",
icon: Zap,
label: "Premium",
tagline: "Unlock expert knowledge and accelerate your career.",
perks: [
{ icon: BookOpen, text: "Full access to all premium courses" },
{ icon: Clock, text: "Learn at your own pace, anytime" },
{ icon: Trophy, text: "Earn certificates of completion" },
{ icon: Shield, text: "Priority support & guidance" },
],
},
exclusive: {
badge: "bg-gradient-to-r from-rose-500 to-red-600 text-white",
heroBg: "from-rose-600 via-red-700 to-orange-800",
accentColor: "text-rose-600 dark:text-rose-400",
accentBg: "bg-rose-50 dark:bg-rose-950/30",
accentBorder: "border-rose-200 dark:border-rose-800",
icon: LockIcon,
label: "Exclusive",
tagline: "The ultimate learning experience for serious professionals.",
perks: [
{ icon: Star, text: "Everything in Premium unlocked" },
{ icon: Users, text: "1-on-1 mentorship sessions" },
{ icon: Trophy, text: "Exclusive expert-only content" },
{ icon: Flame, text: "Early access to new releases" },
],
},
};
// ─── Skeleton ─────────────────────────────────────────────────────────────────
const ViewPlanSkeleton = () => (
@@ -103,14 +53,16 @@ const ViewPlanSkeleton = () => (
const ViewPlan = () => {
const { id } = useParams();
const navigate = useNavigate();
const { myTier, getMyTier, plans, plansLoading, getPlans } = useClientTiers();
const { myTier, getMyTier, plans, plansLoading, getPlans, tierMap, getTierCategories } = useClientTiers();
const { getProfile } = useProfile();
const fmtPlanPrice = (p) => `${p.currency} ${Number(p.price).toFixed(2)}`;
const { fmtCurrency } = useDateFormat();
useEffect(() => {
getProfile();
getMyTier();
getTierCategories();
if (!plans.length) getPlans();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const plan = plans.find((p) => String(p.plan_id) === String(id)) ?? null;
@@ -119,8 +71,14 @@ const ViewPlan = () => {
if (loading) return <ViewPlanSkeleton />;
if (!plan) return null;
const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
const Icon = style.icon;
const { label: tierLabel, cls: badgeCls } = resolveTierBadge(plan.tier, tierMap);
const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
const colors = getTierColor(tierMap[plan.tier]?.color ?? "purple");
const accentSwatch = colors.swatch;
const accentStyle = { color: accentSwatch };
const accentBg = colors.panel.bg;
const accentBorder = colors.panel.border;
const features = plan.features ?? [];
const duration = formatDuration(plan.duration_days, plan.duration_unit);
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
@@ -132,7 +90,7 @@ const ViewPlan = () => {
<PageMeta title={plan ? `${plan.label} - STARR` : undefined} />
{/* ── Hero Banner ───────────────────────────────────────────── */}
<div className={`relative bg-gradient-to-br ${style.heroBg} overflow-hidden`}>
<div className={`relative ${badgeCls} overflow-hidden`}>
{/* Decorative blobs */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div className="absolute -top-24 -right-24 w-96 h-96 rounded-full bg-white/5" />
@@ -148,21 +106,21 @@ const ViewPlan = () => {
<ArrowLeft className="size-4" /> Back to Plans
</button>
<Badge className={`${style.badge} mb-4 text-sm px-3 py-1`}>
<Icon className="size-3.5 mr-1" /> {style.label}
<Badge className="bg-white/20 border border-white/30 text-white mb-4 text-sm px-3 py-1">
<Icon className="size-3.5 mr-1" /> {tierLabel}
</Badge>
<h1 className="text-3xl sm:text-4xl font-extrabold text-white mb-2 leading-tight tracking-tight">
{plan.label}
</h1>
<p className="text-white/70 text-sm mb-8 max-w-md leading-relaxed">
{plan.description || style.tagline}
{plan.description || `Everything you need with the ${tierLabel} plan.`}
</p>
<div className="flex flex-wrap items-end gap-4">
<div>
<span className="text-5xl font-black text-white leading-none">
{fmtPlanPrice(plan)}
{fmtCurrency(plan.price, plan.currency)}
</span>
{duration && (
<span className="text-white/60 text-sm ml-2">/ {duration}</span>
@@ -193,21 +151,23 @@ const ViewPlan = () => {
<div className="px-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5 py-6">
{/* ── What's included ───────────────────────────────────── */}
<div className={`rounded-2xl border ${style.accentBorder} ${style.accentBg} p-5`}>
<p className={`text-xs font-bold uppercase tracking-widest ${style.accentColor} mb-4`}>
What's included
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{style.perks.map(({ icon: PerkIcon, text }, i) => (
<div key={i} className="flex items-center gap-3">
<div className={`h-8 w-8 rounded-lg ${style.accentBg} border ${style.accentBorder} flex items-center justify-center shrink-0`}>
<PerkIcon className={`size-4 ${style.accentColor}`} />
{features.length > 0 && (
<div className={`rounded-2xl border ${accentBorder} ${accentBg} p-5`}>
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={accentStyle}>
What's included
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{features.map(({ text }, i) => (
<div key={i} className="flex items-center gap-3">
<div className={`h-8 w-8 rounded-lg ${accentBg} border ${accentBorder} flex items-center justify-center shrink-0`}>
<Check className="size-4" style={accentStyle} />
</div>
<span className="text-sm font-medium">{text}</span>
</div>
<span className="text-sm font-medium">{text}</span>
</div>
))}
))}
</div>
</div>
</div>
)}
{/* ── Stats row ─────────────────────────────────────────── */}
<div className="grid grid-cols-3 gap-3">
@@ -217,7 +177,7 @@ const ViewPlan = () => {
{ label: "Access", value: duration ?? "Lifetime", icon: CalendarDays },
].map(({ label, value, icon: StatIcon }) => (
<div key={label} className="rounded-xl bg-card border p-4 flex flex-col items-center gap-1 text-center">
<StatIcon className={`size-4 ${style.accentColor}`} />
<StatIcon className="size-4" style={accentStyle} />
<p className="text-2xl font-bold leading-none mt-1">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
@@ -228,7 +188,7 @@ const ViewPlan = () => {
<div className="rounded-2xl bg-card border overflow-hidden">
<div className="px-5 py-4 border-b flex items-center justify-between">
<div className="flex items-center gap-2">
<BookOpen className={`size-4 ${style.accentColor}`} />
<BookOpen className="size-4" style={accentStyle} />
<span className="text-sm font-semibold">Included Courses</span>
</div>
<Badge variant="secondary">{plan.course_count ?? 0}</Badge>
@@ -238,8 +198,8 @@ const ViewPlan = () => {
{plan.courses?.length > 0 ? (
plan.courses.map((course) => (
<div key={course.course_id} className="flex items-start gap-4 px-5 py-4">
<div className={`h-10 w-10 rounded-xl ${style.accentBg} border ${style.accentBorder} flex items-center justify-center shrink-0`}>
<BookOpen className={`size-5 ${style.accentColor}`} />
<div className={`h-10 w-10 rounded-xl ${accentBg} border ${accentBorder} flex items-center justify-center shrink-0`}>
<BookOpen className="size-5" style={accentStyle} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold line-clamp-1">{course.title}</p>
@@ -267,8 +227,8 @@ const ViewPlan = () => {
))
) : (
<div className="flex flex-col items-center justify-center py-10 text-center px-6">
<div className={`h-14 w-14 rounded-2xl ${style.accentBg} border ${style.accentBorder} flex items-center justify-center mb-3`}>
<BookOpen className={`size-7 ${style.accentColor}`} />
<div className={`h-14 w-14 rounded-2xl ${accentBg} border ${accentBorder} flex items-center justify-center mb-3`}>
<BookOpen className="size-7" style={accentStyle} />
</div>
<p className="font-semibold text-sm">Courses coming soon</p>
<p className="text-xs text-muted-foreground mt-1 max-w-xs">
@@ -281,7 +241,7 @@ const ViewPlan = () => {
{/* ── Bottom CTA ────────────────────────────────────────── */}
{!isCurrent && (
<div className={`rounded-2xl bg-gradient-to-br ${style.heroBg} p-6 text-center relative overflow-hidden`}>
<div className={`rounded-2xl ${badgeCls} p-6 text-center relative overflow-hidden`}>
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div className="absolute -top-10 -right-10 w-40 h-40 rounded-full bg-white/5" />
<div className="absolute -bottom-8 -left-8 w-32 h-32 rounded-full bg-white/5" />
@@ -291,7 +251,9 @@ const ViewPlan = () => {
{plan.is_active ? "Ready to get started?" : "Coming Soon"}
</p>
<p className="text-white/70 text-sm mb-5 max-w-xs mx-auto">
{plan.is_active ? style.tagline : "This plan is not available for purchase at the moment. Check back later."}
{plan.is_active
? (plan.description || `Everything you need with the ${tierLabel} plan.`)
: "This plan is not available for purchase at the moment. Check back later."}
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button
@@ -307,7 +269,7 @@ const ViewPlan = () => {
className="bg-white text-gray-900 hover:bg-white/90 font-bold shadow-lg"
onClick={() => navigate(`/plans/checkout?plan_id=${plan.plan_id}`)}
>
Get {style.label} Plan — {fmtPlanPrice(plan)}
Get {tierLabel} Plan — {fmtCurrency(plan.price, plan.currency)}
</Button>
)}
</div>
+117 -22
View File
@@ -11,7 +11,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import {
Trophy, Clock, Paperclip, Plus,
Link, BookOpen, LayoutList, FileText, House,
Image, Video, Music,
Image, Video, Music, PenLine, ClipboardCheck, Hourglass, XCircle,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
@@ -29,6 +29,7 @@ import VisitLink from '../components/blocks/VisitLink';
import ReadCourse from '../components/blocks/ReadCourse';
import ReadUnit from '../components/blocks/ReadUnit';
import ReadLesson from '../components/blocks/ReadLesson';
import PassQuiz from '../components/blocks/PassQuiz';
import { useTask } from '@/contexts/ClientTaskContext';
import { PageMeta } from '@/contexts/MetadataContext';
@@ -38,9 +39,18 @@ import { formatDate } from '@/utils/table.util';
import api from '@/utils/api.util';
// ─── Status badge ─────────────────────────────────────────────────────────────
const StatusBadge = ({ hasCompletion }) => {
if (hasCompletion) return <Badge variant="outline">Turned in</Badge>;
return <Badge variant="outline">Assigned</Badge>;
const StatusBadge = ({ latestCompletion, requiresReview }) => {
if (!latestCompletion) return <Badge variant="outline">Assigned</Badge>;
if (requiresReview) {
if (latestCompletion.status === 'approved') {
return <Badge className="bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700">Approved</Badge>;
}
if (latestCompletion.status === 'rejected') {
return <Badge variant="destructive" className="gap-1"><XCircle className="size-3" /> Rejected — resubmit</Badge>;
}
return <Badge className="bg-amber-100 text-amber-700 border-amber-400 dark:bg-amber-900/40 dark:text-amber-400 dark:border-amber-700 gap-1"><Hourglass className="size-3" /> Pending Review</Badge>;
}
return <Badge variant="outline">Turned in</Badge>;
};
// ─── Requirements status panel ────────────────────────────────────────────────
@@ -48,16 +58,44 @@ const RequirementsStatusPanel = ({ requirements = [], latestCompletion, isVisite
const reqTypes = requirements.map((r) => r.type);
// upload_file and submit_text share one TaskCompletion — "done" also needs
// status === 'approved' when either requirement opted into requires_review.
const submissionDone = (type) => {
const reqs = requirements.filter((r) => r.type === type);
if (!reqs.length || !latestCompletion) return false;
const needsReview = reqs.some((r) => r.requires_review);
return needsReview ? latestCompletion.status === 'approved' : true;
};
const items = [
{
key: 'upload_file',
label: 'File upload',
icon: <Paperclip className="size-4 shrink-0 text-muted-foreground" />,
getValue: () => {
const done = !!latestCompletion;
const done = submissionDone('upload_file');
return { done: done ? 1 : 0, total: 1, binary: true };
},
},
{
key: 'submit_text',
label: 'Written response',
icon: <PenLine className="size-4 shrink-0 text-muted-foreground" />,
getValue: () => {
const done = submissionDone('submit_text');
return { done: done ? 1 : 0, total: 1, binary: true };
},
},
{
key: 'pass_quiz',
label: 'Pass quizzes',
icon: <ClipboardCheck className="size-4 shrink-0 text-muted-foreground" />,
getValue: () => {
const reqs = requirements.filter((r) => r.type === 'pass_quiz');
const done = reqs.filter((r) => isCompleted(r.requirement_id, r.reference_id)).length;
return { done, total: reqs.length, binary: false };
},
},
{
key: 'visit_link',
label: 'Visit links',
@@ -176,8 +214,8 @@ const FileRow = ({ file, onClick }) => {
);
};
// ─── File upload panel (Your Work) ────────────────────────────────────────────
const FileUploadPanel = ({ latestCompletion, onAddAttachment, submitting, onFileClick }) => {
// ─── Submission panel (Your Work) — upload_file and/or submit_text ────────────
const SubmissionPanel = ({ latestCompletion, onAddAttachment, submitting, onFileClick, requiresReview, hasTextRequirement }) => {
const files = latestCompletion?.files ?? [];
return (
@@ -185,7 +223,7 @@ const FileUploadPanel = ({ latestCompletion, onAddAttachment, submitting, onFile
<div className="flex items-center justify-between">
<h2 className="font-semibold text-base">Your work</h2>
<div className="flex items-center gap-2">
<StatusBadge hasCompletion={!!latestCompletion} />
<StatusBadge latestCompletion={latestCompletion} requiresReview={requiresReview} />
{files.length >= 2 && (
<Badge>
<Paperclip className="size-3 mr-1" />
@@ -195,6 +233,16 @@ const FileUploadPanel = ({ latestCompletion, onAddAttachment, submitting, onFile
</div>
</div>
{latestCompletion?.status === 'rejected' && latestCompletion?.review_note && (
<p className="text-sm text-destructive bg-destructive/5 border border-destructive/30 rounded-md p-3">
{latestCompletion.review_note}
</p>
)}
{hasTextRequirement && latestCompletion?.response_text && (
<p className="text-sm border rounded-md p-3 whitespace-pre-wrap bg-muted/40">{latestCompletion.response_text}</p>
)}
{files.length > 0 ? (
files.length >= 2 ? (
<ScrollArea className="max-h-[210px]">
@@ -211,13 +259,13 @@ const FileUploadPanel = ({ latestCompletion, onAddAttachment, submitting, onFile
))}
</div>
)
) : (
) : !(hasTextRequirement && latestCompletion?.response_text) ? (
<p className="text-sm text-center py-6 text-muted-foreground">No work attached</p>
)}
) : null}
<div className="flex flex-col gap-3">
<Button className="w-full" onClick={onAddAttachment} disabled={submitting}>
<Plus /> {latestCompletion ? 'Resubmit' : 'Add Attachment'}
<Plus /> {latestCompletion ? 'Resubmit' : 'Turn In'}
</Button>
</div>
</div>
@@ -249,6 +297,7 @@ const ViewTask = () => {
const [taskModal, setTaskModal] = useState(false);
const [uploadState, setUploadState] = useState({ files: [], isUploading: false });
const [note, setNote] = useState('');
const [responseText, setResponseText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [previewFile, setPreviewFile] = useState(null);
@@ -268,11 +317,15 @@ const ViewTask = () => {
const requirements = task?.requirements ?? [];
const reqTypes = requirements.map((r) => r.type);
const hasFileUpload = reqTypes.includes('upload_file');
const hasTextReq = reqTypes.includes('submit_text');
const hasSubmission = hasFileUpload || hasTextReq;
const requiresReview = requirements.some((r) => ['upload_file', 'submit_text'].includes(r.type) && r.requires_review);
// ── Upload file requirement config (allowed types, max count) ─────────────
const uploadFileReq = requirements.find((r) => r.type === 'upload_file');
const allowedFileTypes = uploadFileReq?.allowed_file_types ?? [];
const maxFileCount = uploadFileReq?.max_file_count ?? null;
const textReq = requirements.find((r) => r.type === 'submit_text');
// ── Visit link handler (passed to VisitLink block) ────────────────────────
const handleVisitLink = useCallback(async (requirementId) => {
@@ -286,7 +339,8 @@ const ViewTask = () => {
// ── Submit handler ────────────────────────────────────────────────────────
const handleSubmit = async () => {
if (uploadState.isUploading) return;
if (!uploadState.files.length) return;
if (hasFileUpload && !uploadState.files.length) return;
if (!hasFileUpload && hasTextReq && !responseText.trim()) return;
setSubmitting(true);
try {
@@ -314,19 +368,21 @@ const ViewTask = () => {
});
}
if (!uploadedFiles.length) {
if (hasFileUpload && !uploadedFiles.length) {
toast('No files were uploaded successfully.');
return;
}
// 2. Submit completion with uploaded file references
// 2. Submit completion with uploaded file references + response text
await completeTask(groupId, taskListId, taskId, {
note: note.trim() || null,
files: uploadedFiles,
response_text: hasTextReq ? (responseText.trim() || null) : undefined,
});
setTaskModal(false);
setNote('');
setResponseText('');
setUploadState({ files: [], isUploading: false });
} catch (err) {
toast('Failed to submit. Please try again.');
@@ -340,6 +396,7 @@ const ViewTask = () => {
const readCourseReqs = requirements.filter((r) => r.type === 'read_course');
const readUnitReqs = requirements.filter((r) => r.type === 'read_unit');
const readLessonReqs = requirements.filter((r) => r.type === 'read_lesson');
const passQuizReqs = requirements.filter((r) => r.type === 'pass_quiz');
const breadcrumbItems = [
{ label: 'Home', icon: <House className="size-4" />, to: '/dashboard' },
@@ -352,12 +409,12 @@ const ViewTask = () => {
<div className="mt-17">
<PageMeta title={task ? `${task.name} - STARR` : undefined} />
{/* ── File upload modal ──────────────────────────────────────────── */}
{hasFileUpload && (
{/* ── Submission modal (files and/or text response) ────────────────── */}
{hasSubmission && (
<ResponsiveModal
open={taskModal}
onOpenChange={setTaskModal}
title="Add Attachment"
title={hasFileUpload ? 'Add Attachment' : 'Submit Response'}
description={task?.name ?? ''}
footer={
<>
@@ -369,7 +426,8 @@ const ViewTask = () => {
disabled={
submitting ||
uploadState.isUploading ||
uploadState.files.length === 0
(hasFileUpload && uploadState.files.length === 0) ||
(!hasFileUpload && hasTextReq && !responseText.trim())
}
>
{submitting ? 'Submitting…' : uploadState.isUploading ? 'Uploading…' : 'Turn in'}
@@ -377,6 +435,21 @@ const ViewTask = () => {
</>
}
>
{hasTextReq && (
<div className="flex flex-col gap-1.5 mb-3">
<label className="text-sm font-medium">
{textReq?.prompt || 'Your response'}
</label>
<textarea
className="w-full rounded-md border bg-muted/50 px-3 py-2 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-ring"
rows={5}
placeholder="Write your response…"
value={responseText}
onChange={(e) => setResponseText(e.target.value)}
/>
</div>
)}
{/* Optional note */}
<div className="flex flex-col gap-1.5 mb-3">
<label className="text-sm font-medium">Note (optional)</label>
@@ -389,11 +462,13 @@ const ViewTask = () => {
/>
</div>
{hasFileUpload && (
<FileUpload
allowedFileTypes={allowedFileTypes}
maxFileCount={maxFileCount}
onChange={(state) => setUploadState(state)}
/>
)}
</ResponsiveModal>
)}
@@ -519,17 +594,35 @@ const ViewTask = () => {
taskId={taskId}
/>
)}
{/* pass_quiz */}
{passQuizReqs.length > 0 && (
<PassQuiz
quizzes={passQuizReqs.map((r) => ({
id: r.requirement_id,
requirement_id: r.requirement_id,
reference_id: r.reference_id,
title: r.reference_label ?? 'Quiz',
completed: isCompleted(r.requirement_id, r.reference_id),
}))}
groupId={groupId}
taskListId={taskListId}
taskId={taskId}
/>
)}
</>
)}
{/* Your work — mobile only, at the very end */}
{hasFileUpload && (
{hasSubmission && (
<div className="lg:hidden">
<FileUploadPanel
<SubmissionPanel
latestCompletion={latestCompletion}
onAddAttachment={() => setTaskModal(true)}
submitting={submitting}
onFileClick={(file) => setPreviewFile(file)}
requiresReview={requiresReview}
hasTextRequirement={hasTextReq}
/>
</div>
)}
@@ -537,12 +630,14 @@ const ViewTask = () => {
{/* ── Right (desktop sidebar only) ───────────────────────── */}
<div className="hidden lg:flex lg:flex-col gap-4 lg:sticky lg:top-24 lg:self-start select-none">
{hasFileUpload && (
<FileUploadPanel
{hasSubmission && (
<SubmissionPanel
latestCompletion={latestCompletion}
onAddAttachment={() => setTaskModal(true)}
submitting={submitting}
onFileClick={(file) => setPreviewFile(file)}
requiresReview={requiresReview}
hasTextRequirement={hasTextReq}
/>
)}
<RequirementsStatusPanel
+62 -6
View File
@@ -11,14 +11,16 @@ import { useParams, useNavigate } from 'react-router-dom';
import { useTask } from '@/contexts/ClientTaskContext';
import { useGroup } from '@/contexts/ClientGroupContext';
import { PageMeta } from '@/contexts/MetadataContext';
import api from '@/utils/api.util';
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Progress } from '@/components/ui/progress';
import {
House, Calendar, AlertTriangle, Check, ArrowRight, ListChecks, LayoutList,
LaptopMinimal, Table,
LaptopMinimal, Table, Lock,
} from 'lucide-react';
import { Tabs, TabsList, TabsPanel, TabsTab } from '@/components/coss/tabs';
import { formatDate } from '@/utils/table.util';
@@ -73,22 +75,31 @@ const TaskStatusBadge = ({ task }) => {
};
// ─── Task card ────────────────────────────────────────────────────────────────
const TaskCard = ({ task, onClick }) => {
const TaskCard = ({ task, onClick, locked }) => {
const reqCount = task.requirements?.length ?? 0;
return (
<div
onClick={onClick}
onClick={() => !locked && onClick()}
className={cn(
'border bg-card rounded-lg flex flex-col cursor-pointer transition-colors',
'hover:border-blue-400 dark:hover:border-blue-500',
'border bg-card rounded-lg flex flex-col transition-colors',
locked
? 'opacity-60 cursor-not-allowed'
: 'cursor-pointer hover:border-blue-400 dark:hover:border-blue-500',
task.has_completed && 'opacity-90',
)}
>
<div className="p-4 flex flex-col gap-2.5 flex-1">
<div className="flex items-start justify-between gap-2">
<h1 className="text-base font-medium leading-snug line-clamp-2 min-w-0 flex-1">{task.name}</h1>
<TaskStatusBadge task={task} />
{locked
? (
<span className="inline-flex items-center gap-1 text-xs font-medium bg-muted text-muted-foreground rounded-full px-2.5 py-1">
<Lock className="size-3" /> Locked
</span>
)
: <TaskStatusBadge task={task} />
}
</div>
{task.description && (
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
@@ -99,6 +110,9 @@ const TaskCard = ({ task, onClick }) => {
<Calendar />
{task.deadline ? `Due ${formatDate(task.deadline)}` : 'No due date'}
</div>
{locked && (
<p className="text-xs text-muted-foreground">Complete the earlier required tasks to unlock.</p>
)}
</div>
<div className="px-4 py-2.5 border-t flex items-center justify-between">
<span className="inline-flex items-center gap-1 text-xs font-medium bg-muted text-muted-foreground rounded-full px-2.5 py-1">
@@ -155,6 +169,7 @@ const ViewTaskDetails = () => {
const [view, setView] = useState('grid');
const [activeTab, setActiveTab] = useState('tab-ongoing');
const [allTasks, setAllTasks] = useState([]);
// ── Fetch group info once ─────────────────────────────────────────────────
useEffect(() => {
@@ -167,6 +182,26 @@ const ViewTaskDetails = () => {
if (tab) fetchTaskList(groupId, taskListId, { status: tab.status });
}, [groupId, taskListId, activeTab]);
// ── Fetch the FULL, unfiltered, order_index-sorted task array separately —
// the tab fetch above is server-side status-filtered, so it alone can't
// tell us whether an earlier task (possibly in a different tab) is done.
// Kept in local state rather than context so it never clobbers the
// tab-driven fetch's `taskList.tasks`.
useEffect(() => {
api.get(`/client/groups/${groupId}/task-lists/${taskListId}`)
.then(({ data }) => setAllTasks(data?.data?.tasks ?? []))
.catch(() => {});
}, [groupId, taskListId]);
// ── Sequencing lock — same pattern as UnitList.jsx's quiz lock ───────────
const lockedTaskIds = new Set(
allTasks
.filter((t, i, arr) => arr.slice(0, i).some((prev) => prev.is_required && !prev.has_completed))
.map((t) => t.task_id)
);
const completedCount = allTasks.filter((t) => t.has_completed).length;
const handleTabChange = useCallback((val) => {
setActiveTab(val);
}, []);
@@ -214,6 +249,7 @@ const ViewTaskDetails = () => {
<TaskCard
key={task.task_id}
task={task}
locked={lockedTaskIds.has(task.task_id)}
onClick={() => navigate(`task/${task.task_id}`)}
/>
))}
@@ -244,6 +280,26 @@ const ViewTaskDetails = () => {
</>
)}
</div>
{/* ── List-level progress rollup ── */}
{allTasks.length > 0 && (
<div className="max-w-md flex flex-col gap-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{completedCount} of {allTasks.length} task{allTasks.length !== 1 ? 's' : ''} complete
</span>
{completedCount >= allTasks.length && (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 font-medium">
<Check className="size-3.5" /> All done
</span>
)}
</div>
<Progress
value={(completedCount / allTasks.length) * 100}
className={cn('h-1.5', completedCount >= allTasks.length && '[&>div]:bg-green-500')}
/>
</div>
)}
</div>
{/* ── Tabs + view toggle ────────────────────────────────────── */}
@@ -8,6 +8,8 @@ import CourseDetails from '../pages/CourseDetails'
import UnitList from '../pages/UnitList'
import UnitsList from '../pages/UnitsList'
import UnitDetails from '../pages/UnitDetails'
import LessonDetails from '../pages/LessonDetails'
import LessonsList from '../pages/LessonsList'
import UnitReader from '../pages/UnitReader'
import { Fragment } from 'react'
import GroupList from '../pages/GroupList'
@@ -105,6 +107,15 @@ export const ClientRoutes = {
],
},
{
path: 'lessons',
element: <Outlet />,
children: [
{ index: true, element: <LessonsList /> },
{ path: ':uuid', element: <LessonDetails /> },
],
},
{
path: 'group', element: <Outlet />,
children: [