pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-28 11:30:01 +08:00
parent b03b204861
commit bac7168b1e
100 changed files with 5958 additions and 1976 deletions
@@ -1,4 +1,4 @@
import { Trophy } from "lucide-react";
import { Trophy, Clock } from "lucide-react";
/**
* Props:
@@ -25,6 +25,15 @@ const CourseCompleteBlock = ({ course }) => {
You've passed all required units and the final assessment for this course.
</p>
</div>
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-left">
<Clock className="size-4 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">Certificate Pending</p>
<p className="text-xs text-muted-foreground">
Certificates are issued automatically every hour. Yours will be ready within the next hour — check your notifications.
</p>
</div>
</div>
</div>
</div>
);
@@ -10,15 +10,10 @@ import {
Paperclip,
Plus,
X,
AlertTriangle,
Database,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
// ── Constants ─────────────────────────────────────────────────────────────────
const DEFAULT_MAX_BYTES = 500 * 1024 * 1024; // 500 MB
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatBytes(b) {
if (b >= 1024 * 1024 * 1024) return (b / 1024 / 1024 / 1024).toFixed(1) + " GB";
@@ -84,46 +79,12 @@ const FileItem = ({ file, onRemove }) => {
);
};
// ── StorageBar ────────────────────────────────────────────────────────────────
const StorageBar = ({ usedBytes, maxBytes }) => {
const pct = Math.min(100, (usedBytes / maxBytes) * 100);
const isOver = usedBytes > maxBytes;
const isWarn = pct > 75 && !isOver;
return (
<div className="mt-3 p-4 border rounded-md bg-muted/50 flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm">
<Database className="size-4" /> Total size
</span>
<span className={cn(
"text-sm font-medium",
isOver && "text-destructive",
isWarn && "text-amber-600 dark:text-amber-400",
!isOver && !isWarn && "text-muted-foreground"
)}>
{formatBytes(usedBytes)} of {formatBytes(maxBytes)}
</span>
</div>
<Progress
value={pct}
className={cn(
"h-1.5",
isOver && "[&>div]:bg-destructive",
isWarn && "[&>div]:bg-amber-500"
)}
/>
</div>
);
};
// ── FileUpload ────────────────────────────────────────────────────────────────
/**
* Standalone file upload UI — no modal, no footer buttons.
* Compose inside <ResponsiveModal> or any container.
*
* Props:
* maxBytes {number} – total size cap (default: 500 MB)
* accept {string} – native <input accept> string (fallback if
* allowedFileTypes not provided)
* hint {string} – dropzone helper text (fallback if
@@ -135,12 +96,11 @@ const StorageBar = ({ usedBytes, maxBytes }) => {
* maxFileCount {number} – max number of files allowed. Displayed in
* the hint and enforced client-side on file add.
* onChange {function} – fires on every file list change:
* ({ files, isUploading, isOverLimit }) => void
* ({ files, isUploading }) => void
* onUploadDone {function} – fires when all uploads finish:
* ({ files }) => void
*/
const FileUpload = ({
maxBytes = DEFAULT_MAX_BYTES,
accept,
hint = "PDF, DOCX, MP4, PNG, JPG",
allowedFileTypes,
@@ -152,8 +112,6 @@ const FileUpload = ({
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef(null);
const totalBytes = files.reduce((sum, f) => sum + (f.bytes || 0), 0);
const isOverLimit = totalBytes > maxBytes;
const isUploading = files.some((f) => f.status === "uploading");
// ── Derived accept string ───────────────────────────────────────────────────
@@ -177,8 +135,7 @@ const FileUpload = ({
// Notify parent with full state
const notify = (next) => {
const uploading = next.some((f) => f.status === "uploading");
const overLimit = next.reduce((s, f) => s + (f.bytes || 0), 0) > maxBytes;
onChange?.({ files: next, isUploading: uploading, isOverLimit: overLimit });
onChange?.({ files: next, isUploading: uploading });
};
// ── simulate upload progress ──────────────────────────────────────────────
@@ -204,7 +161,7 @@ const FileUpload = ({
});
};
setTimeout(tick, 300);
}, [onChange, onUploadDone, maxBytes]);
}, [onChange, onUploadDone]);
// ── add files ─────────────────────────────────────────────────────────────
const addFiles = useCallback((rawFiles) => {
@@ -277,7 +234,7 @@ const FileUpload = ({
return next;
});
if (fileInputRef.current) fileInputRef.current.value = "";
}, [simulateUpload, onChange, maxBytes, allowedFileTypes, maxFileCount]);
}, [simulateUpload, onChange, allowedFileTypes, maxFileCount]);
// ── remove ────────────────────────────────────────────────────────────────
const removeFile = (id) => {
@@ -315,7 +272,7 @@ const FileUpload = ({
<CloudUpload className={cn("size-8 mx-auto mb-3", isDragging ? "text-blue-500" : "text-muted-foreground")} />
<p className="text-sm font-medium">Drop files here or click to browse</p>
<p className="text-sm text-muted-foreground mt-1">Upload your work to submit with this task</p>
<p className="text-sm mt-2">{derivedHint} · Max total: {formatBytes(maxBytes)}</p>
<p className="text-sm mt-2">{derivedHint}</p>
</div>
)}
@@ -348,18 +305,6 @@ const FileUpload = ({
)}
<p className="text-xs text-muted-foreground mt-2">{derivedHint}</p>
<StorageBar usedBytes={totalBytes} maxBytes={maxBytes} />
{isOverLimit && (
<div className="mt-2.5 flex items-start gap-2 px-3 py-2.5 rounded-md bg-destructive/10 border border-destructive/30">
<AlertTriangle className="size-4 text-destructive shrink-0 mt-0.5" />
<p className="text-xs text-destructive leading-relaxed">
Total file size exceeds the <strong>{formatBytes(maxBytes)}</strong> limit.
Please remove some files before turning in.
</p>
</div>
)}
</div>
)}
@@ -376,4 +321,4 @@ const FileUpload = ({
};
export default FileUpload;
export { FileUpload, FileItem, FileIcon, StorageBar, formatBytes, iconForFile };
export { FileUpload, FileItem, FileIcon, formatBytes, iconForFile };
File diff suppressed because it is too large Load Diff
@@ -9,20 +9,24 @@ import { Button } from "@/components/ui/button";
import { SendHorizonal } from "lucide-react";
import { toast } from "sonner";
import api from "@/utils/api.util";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { resolveTierBadge } from "@/utils/tierBadge.util";
const LVL_LABEL = { beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced' };
function TierBadge({ tier, locked = false }) {
if (tier === 'premium')
return <Badge className="gap-1 bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Premium</Badge>;
if (tier === 'exclusive')
return <Badge className="gap-1 bg-gradient-to-r from-rose-500 to-red-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Exclusive</Badge>;
if (!locked)
return <Badge className="gap-1 bg-green-500 text-white border-0 w-fit shrink-0"><Tag className="size-3" /> Free</Badge>;
return null;
const { tierMap } = useClientTiers();
const { rank, label, cls } = resolveTierBadge(tier ?? 'free', tierMap);
if (rank === 0 && !locked) return null;
return (
<Badge className={`gap-1 ${cls} w-fit shrink-0`}>
{rank > 0 ? <Lock className="size-3" /> : <Tag className="size-3" />}
{label}
</Badge>
);
}
const ReadCourse = ({ title = "Read Course", courses = [] }) => {
const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId, taskId }) => {
const navigate = useNavigate();
const [selected, setSelected] = useState(null);
const [details, setDetails] = useState({});
@@ -173,7 +177,21 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
</div>
<div>
<h1 className="text-base font-semibold leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors">
<h1
onClick={(e) => {
if (!taskId || !groupId || !taskListId) return;
e.stopPropagation();
navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { course: { id: course.id, reference_id: course.reference_id, title: course.title } } }
);
}}
className={`text-base font-semibold leading-snug line-clamp-2 transition-colors ${
taskId
? 'text-blue-600 dark:text-blue-400 hover:underline cursor-pointer'
: 'group-hover:text-blue-700 dark:group-hover:text-blue-400'
}`}
>
{course.title}
</h1>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1">
@@ -223,7 +241,19 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
<Button variant="outline" onClick={() => setSelected(null)}>Cancel</Button>
<Button
onClick={() => {
if (info?.course_id) navigate(`/course/${info.course_id}/unit`, { state: allRead ? { seekFirstIncomplete: true } : undefined });
if (!info?.course_id) return;
if (taskId && groupId && taskListId) {
// Task context — read inside ViewRequirement
navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { course: { id: selected.id, reference_id: selected.reference_id, title: selected.title } } }
);
} else {
// No task context — fall back to standalone course reader
navigate(`/course/${info.course_id}/unit`, {
state: allRead ? { seekFirstIncomplete: true } : {},
});
}
}}
disabled={done || !info?.course_id}
>
@@ -2,17 +2,22 @@ import { useNavigate } from "react-router-dom";
import { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { FileText, CheckCheck, Lock, Zap, Info } from "lucide-react";
import { FileText, CheckCheck, Lock, Zap, Info, Tag } from "lucide-react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import api from "@/utils/api.util";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { resolveTierBadge } from "@/utils/tierBadge.util";
function TierBadge({ tier }) {
if (tier === 'premium')
return <Badge className="gap-1 bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Premium</Badge>;
if (tier === 'exclusive')
return <Badge className="gap-1 bg-gradient-to-r from-rose-500 to-red-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Exclusive</Badge>;
return null;
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>
);
}
const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId, taskId }) => {
@@ -156,6 +161,12 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
<div className="flex items-center gap-1.5">
<FileText className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground font-medium">Lesson</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>
{/* Course breadcrumb */}
@@ -166,7 +177,7 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
)}
<div>
<h1 className="text-base font-semibold leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors">
<h1 className="text-base font-semibold leading-snug line-clamp-2 text-blue-600 dark:text-blue-400 hover:underline transition-colors cursor-pointer">
{lesson.title}
</h1>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1">
@@ -1,19 +1,24 @@
import { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { Layers, CheckCheck, RefreshCw, SendHorizonal, Lock, Zap, Info } from "lucide-react";
import { Layers, CheckCheck, RefreshCw, SendHorizonal, Lock, Zap, Info, Tag } from "lucide-react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Progress } from "@/components/ui/progress";
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 }) {
if (tier === 'premium')
return <Badge className="gap-1 bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Premium</Badge>;
if (tier === 'exclusive')
return <Badge className="gap-1 bg-gradient-to-r from-rose-500 to-red-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Exclusive</Badge>;
return null;
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>
);
}
const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskId }) => {
@@ -172,10 +177,36 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
<div className="flex items-center gap-1.5">
<Layers className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground font-medium">Unit</span>
<div className="ml-auto">
{info?.course?.subscription
? <TierBadge tier={info.course.subscription} />
: isFetching && <Badge variant="secondary" className="opacity-40 text-xs">Loading…</Badge>
}
</div>
</div>
{info?.course?.title && (
<p className="text-xs text-muted-foreground truncate -mt-1">
from <span className="text-foreground/70 font-medium">{info.course.title}</span>
</p>
)}
<div>
<h1 className="text-base font-semibold leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors">
<h1
onClick={(e) => {
if (!taskId || !groupId || !taskListId) return;
e.stopPropagation();
navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { unit } }
);
}}
className={`text-base font-semibold leading-snug line-clamp-2 transition-colors ${
taskId
? 'text-blue-600 dark:text-blue-400 hover:underline cursor-pointer'
: 'group-hover:text-blue-700 dark:group-hover:text-blue-400'
}`}
>
{unit.title}
</h1>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1">
@@ -1,4 +1,4 @@
import { ExternalLink, CheckCheck } from "lucide-react";
import { ExternalLink, CheckCheck, RefreshCcw } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Card,
@@ -22,25 +22,32 @@ const normalizeUrl = (url) => {
// ── Meta fetcher ──────────────────────────────────────────────────────────────
const fetchLinkMeta = async (url) => {
const normalized = normalizeUrl(url);
try {
const res = await fetch(`https://api.microlink.io/?url=${encodeURIComponent(url)}`);
const res = await fetch(`https://api.microlink.io/?url=${encodeURIComponent(normalized)}`);
const json = await res.json();
if (json.status === "success") {
return {
title: json.data.title ?? null,
title: json.data.title ?? null,
description: json.data.description ?? null,
image: json.data.image?.url ?? json.data.logo?.url ?? null,
image: json.data.image?.url ?? json.data.logo?.url ?? null,
};
}
} catch { /* silently fail */ }
return { title: null, description: null, image: null };
};
const getDomain = (url) => {
try { return new URL(normalizeUrl(url)).hostname.replace(/^www\./, ''); }
catch { return url; }
};
// ── LinkCard ──────────────────────────────────────────────────────────────────
const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting }) => {
const [meta, setMeta] = useState({ title: null, description: null, image: null });
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [viewModalOpen, setViewModalOpen] = useState(false);
useEffect(() => {
if (!link.url) return;
@@ -49,8 +56,10 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
.finally(() => setLoading(false));
}, [link.url]);
const displayImage = meta.image ?? `https://avatar.vercel.sh/${encodeURIComponent(link.url)}`;
const displayTitle = meta.title ?? link.label;
const domain = getDomain(link.url);
const displayImage = meta.image ?? null;
const displayFavicon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`;
const displayTitle = meta.title ?? link.label ?? domain;
const displayDescription = meta.description ?? link.url;
const handleTurnIn = async () => {
@@ -58,20 +67,33 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
setModalOpen(false);
};
const handleUnsubmit = async () => {
await onUnvisit(link.requirement_id);
setViewModalOpen(false);
};
return (
<>
<Card className="relative w-72 shrink-0 pt-0">
{loading ? (
<div className="relative z-20 h-40 w-full rounded-t-lg bg-muted animate-pulse" />
) : (
<div className="h-40 w-full rounded-t-lg bg-muted animate-pulse" />
) : displayImage ? (
<img
src={displayImage}
alt={displayTitle}
className="relative z-20 h-40 w-full object-cover rounded-t-lg"
onError={(e) => {
e.currentTarget.src = `https://avatar.vercel.sh/${encodeURIComponent(link.url)}`;
}}
className="h-40 w-full object-cover rounded-t-lg"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
) : (
<div className="h-40 w-full rounded-t-lg bg-muted flex flex-col items-center justify-center gap-2">
<img
src={displayFavicon}
alt={domain}
className="w-12 h-12 rounded-xl"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<span className="text-xs text-muted-foreground font-medium">{domain}</span>
</div>
)}
<CardHeader>
<CardTitle className="line-clamp-1">
@@ -89,9 +111,8 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
</CardHeader>
<CardFooter>
{visited ? (
<Button className="w-full" variant="secondary" disabled>
<CheckCheck className="size-4" />
Visited
<Button variant="secondary" className="w-full" onClick={() => setViewModalOpen(true)}>
<CheckCheck /> Visited
</Button>
) : (
<Button className="w-full" onClick={() => setModalOpen(true)}>
@@ -101,16 +122,15 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
</CardFooter>
</Card>
{/* Turn-in modal — first visit */}
<ResponsiveModal
open={modalOpen}
onOpenChange={setModalOpen}
title={`Visit Link: ${displayTitle}`}
description={`By visiting a link, you are about to explore it then Turn-in after.`}
description="By visiting a link, you are about to explore it then Turn-in after."
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancel</Button>
<Button onClick={handleTurnIn} disabled={submitting}>
<SendHorizonal /> {submitting ? "Submitting…" : "Turn In"}
</Button>
@@ -121,12 +141,52 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
<p className="text-xs text-muted-foreground break-all">{link.url}</p>
<Button asChild variant="outline" className="w-full">
<a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-4" />
Open Link
<ExternalLink className="size-4" /> Open Link
</a>
</Button>
</div>
</ResponsiveModal>
{/* View modal — after visited, with Resubmit */}
<ResponsiveModal
open={viewModalOpen}
onOpenChange={setViewModalOpen}
title={displayTitle}
description={displayDescription}
footer={
<>
<Button variant="outline" onClick={() => setViewModalOpen(false)}>Close</Button>
<Button asChild variant="outline">
<a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-4" /> Open Link
</a>
</Button>
<Button onClick={handleUnsubmit} disabled={unsubmitting} variant="destructive">
<RefreshCcw className="size-4" /> {unsubmitting ? "Removing…" : "Unsubmit"}
</Button>
</>
}
>
<div className="flex flex-col gap-4">
{displayImage ? (
<img
src={displayImage}
alt={displayTitle}
className="w-full h-40 object-cover rounded-lg"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
) : (
<div className="w-full h-40 rounded-lg bg-muted flex flex-col items-center justify-center gap-2">
<img src={displayFavicon} alt={domain} className="w-12 h-12 rounded-xl" onError={(e) => { e.currentTarget.style.display = 'none'; }} />
<span className="text-xs text-muted-foreground font-medium">{domain}</span>
</div>
)}
<div className="flex items-center gap-2 bg-green-500/10 text-green-600 dark:text-green-400 rounded-lg px-4 py-3 text-sm font-medium">
<CheckCheck className="size-4 shrink-0" /> Already submitted — you can unsubmit if needed.
</div>
<p className="text-xs text-muted-foreground break-all">{link.url}</p>
</div>
</ResponsiveModal>
</>
);
};
@@ -140,16 +200,20 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
* onVisit {function} – async (requirement_id) => void
* called when user confirms "Turn In"
*/
const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit }) => {
const [submittingId, setSubmittingId] = useState(null);
const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit, onUnvisit }) => {
const [submittingId, setSubmittingId] = useState(null);
const [unsubmittingId, setUnsubmittingId] = useState(null);
const handleTurnIn = async (requirementId) => {
setSubmittingId(requirementId);
try {
await onVisit?.(requirementId);
} finally {
setSubmittingId(null);
}
try { await onVisit?.(requirementId); }
finally { setSubmittingId(null); }
};
const handleUnvisit = async (requirementId) => {
setUnsubmittingId(requirementId);
try { await onUnvisit?.(requirementId); }
finally { setUnsubmittingId(null); }
};
return (
@@ -167,7 +231,9 @@ const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit
link={link}
visited={!!visitedMap[link.requirement_id]}
onTurnIn={handleTurnIn}
onUnvisit={handleUnvisit}
submitting={submittingId === link.requirement_id}
unsubmitting={unsubmittingId === link.requirement_id}
/>
))}
</div>
+17 -6
View File
@@ -28,7 +28,7 @@ import { useProfile } from "@/contexts/ProfileProvider"
import { useClientTiers } from "@/contexts/ClientTiersProvider"
import { useGroup } from "@/contexts/ClientGroupContext"
import { useEffect, useState } from "react"
import { ROLE_CONFIG, AVATAR_COLORS } from "@/data/profile.data"
import { AVATAR_COLORS } from "@/data/profile.data"
import { Badge } from "@/components/ui/badge"
import ClientNotificationBell from "@/components/generic/ClientNotificationBell"
import { QRCodeCanvas } from "qrcode.react"
@@ -141,7 +141,7 @@ function ClientNav() {
// Background fetches only — nav rendering never waits on these
const { achievements, getAchievements } = useProfile()
const { myTier, getMyTier } = useClientTiers()
const { myTier, getMyTier, getTierCategories } = useClientTiers()
const [referOpen, setReferOpen] = useState(false)
@@ -149,6 +149,7 @@ function ClientNav() {
if (!user) return;
if (achievements.length === 0) getAchievements();
if (!myTier) getMyTier();
getTierCategories();
}, [user]);
// ── Derive directly from auth user — same pattern as admin UserMenu ──────
@@ -158,9 +159,17 @@ function ClientNav() {
const fullName = given && last ? `${given} ${last}` : (user?.email ?? "")
const avatarUrl = user?.personal_info?.avatar?.url ?? user?.personal_info?.avatar ?? ""
const email = user?.email ?? ""
const role = ROLE_CONFIG[user?.acc_type] ?? { label: user?.acc_type, variant: 'outline' }
const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground'
const TIER_NAV_BADGE = {
free: { label: 'Free', className: '' },
premium: { label: 'Premium Access', className: 'bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white border-0' },
exclusive: { label: 'Exclusive Access', className: 'bg-gradient-to-r from-rose-500 to-red-600 text-white border-0' },
}
const tierBadge = myTier?.status === 'active'
? (TIER_NAV_BADGE[myTier.tier] ?? TIER_NAV_BADGE.free)
: TIER_NAV_BADGE.free
const initials = given && last
? (given[0] + last[0]).toUpperCase()
: getInitials(fullName)
@@ -201,9 +210,11 @@ function ClientNav() {
</svg>
</div>
<div id="name" className="lg:-ml-1 font-medium text-sm flex items-center gap-2">
<Badge variant={role.variant} className="xs:hidden md:block capitalize">
{role.label}
</Badge>
{tierBadge && (
<Badge className={`xs:hidden md:block ${tierBadge.className}`}>
{tierBadge.label}
</Badge>
)}
</div>
</div>
+85 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { KeyRound, CreditCard, Mail, ChevronRight, Eye, EyeOff, ShieldAlert } from "lucide-react";
import { KeyRound, CreditCard, Mail, ChevronRight, Eye, EyeOff, ShieldAlert, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -9,9 +9,20 @@ import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useAuth } from "@/contexts/AuthContext";
import { useProfile } from "@/contexts/ProfileProvider";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util";
import { toast } from "sonner";
@@ -132,6 +143,7 @@ const TIER_COLORS = {
function SubscriptionSection() {
const navigate = useNavigate();
const { myTier, tierLoading, getMyTier, payments, paymentsLoading, getMyPayments } = useClientTiers();
const { fmtDate, fmtNumber } = useDateFormat();
useEffect(() => {
getMyTier();
@@ -139,9 +151,7 @@ function SubscriptionSection() {
}, []);
const tier = myTier?.tier ?? "free";
const expiresAt = myTier?.expires_at
? new Date(myTier.expires_at).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })
: null;
const expiresAt = myTier?.expires_at ? fmtDate(myTier.expires_at) : null;
return (
<div className="space-y-5">
@@ -192,11 +202,11 @@ function SubscriptionSection() {
{payments.map((p) => (
<tr key={p.payment_id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 text-muted-foreground">
{new Date(p.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
{fmtDate(p.createdAt)}
</td>
<td className="px-4 py-3 capitalize">{p.plan?.tier ?? "—"}</td>
<td className="px-4 py-3">
{p.currency} {Number(p.amount ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}
{p.currency} {fmtNumber(p.amount ?? 0)}
</td>
<td className="px-4 py-3">
<Badge variant={p.status === "completed" ? "outline" : ""} className="capitalize text-xs">
@@ -268,6 +278,71 @@ function NewsletterSection() {
);
}
// ─── Delete Account ───────────────────────────────────────────────────────────
function DeleteAccountSection({ logout }) {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const handleDelete = async () => {
setLoading(true);
try {
await api.delete("/client/profile");
toast.success("Account deleted. Goodbye!");
await logout();
navigate("/login");
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not delete account.");
setLoading(false);
}
};
return (
<>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">Delete account</p>
<p className="text-xs text-muted-foreground">
Permanently remove your account and all associated data. This action cannot be undone.
</p>
</div>
<Button
variant="destructive"
size="sm"
className="shrink-0"
onClick={() => setOpen(true)}
>
<Trash2 className="size-3.5 mr-1.5" />
Delete account
</Button>
</div>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete your account and sign you out of all sessions.
Your data cannot be recovered after deletion.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={loading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{loading ? "Deleting…" : "Yes, delete my account"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function AccountSettings() {
@@ -292,6 +367,10 @@ export default function AccountSettings() {
<Section icon={Mail} title="Newsletter" description="Choose what emails you want to receive from us.">
<NewsletterSection />
</Section>
<Section icon={Trash2} title="Danger Zone" description="Irreversible account actions.">
<DeleteAccountSection logout={logout} />
</Section>
</div>
</div>
);
+7 -10
View File
@@ -18,11 +18,7 @@ import {
House, Loader2, ShieldCheck, Tag, Zap, LockIcon,
} from "lucide-react";
function formatPrice(price = 0, currency = "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency", currency, minimumFractionDigits: 2,
}).format(Number(price) || 0);
}
import { useDateFormat } from "@/hooks/useDateFormat";
function formatDuration(days) {
if (!days) return "Lifetime";
@@ -66,6 +62,7 @@ const CheckoutSkeleton = () => (
const Checkout = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { fmtCurrency } = useDateFormat();
const planId = searchParams.get("plan_id");
const returnToken = searchParams.get("token");
@@ -237,7 +234,7 @@ const Checkout = () => {
</p>
</div>
<p className="text-2xl font-bold text-primary">
{formatPrice(plan.price, plan.currency)}
{fmtCurrency(plan.price, plan.currency)}
</p>
</div>
</div>
@@ -312,12 +309,12 @@ const Checkout = () => {
<div className="space-y-2">
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">Plan Price</span>
<span className="font-medium">{formatPrice(subtotal, plan.currency)}</span>
<span className="font-medium">{fmtCurrency(subtotal, plan.currency)}</span>
</div>
{isPromoApplied && (
<div className="flex justify-between gap-4 text-green-600">
<span>Promo Discount (PHIL10)</span>
<span>-{formatPrice(discount, plan.currency)}</span>
<span>-{fmtCurrency(discount, plan.currency)}</span>
</div>
)}
</div>
@@ -348,7 +345,7 @@ const Checkout = () => {
<div className="flex justify-between items-center text-lg font-semibold">
<span>Total</span>
<span>{formatPrice(total, plan.currency)}</span>
<span>{fmtCurrency(total, plan.currency)}</span>
</div>
{isCurrent ? (
@@ -366,7 +363,7 @@ const Checkout = () => {
? <Loader2 className="size-4 animate-spin" />
: <ShieldCheck className="size-4" />
}
Pay {formatPrice(total, plan.currency)} with PayPal
Pay {fmtCurrency(total, plan.currency)} with PayPal
</Button>
)}
+5 -9
View File
@@ -9,12 +9,7 @@ import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { useClientCourses } from "@/contexts/ClientCoursesContext";
function formatPrice(price = 0, currency = "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency", currency, minimumFractionDigits: 2,
}).format(Number(price) || 0);
}
import { useDateFormat } from "@/hooks/useDateFormat";
function formatAccess(days) {
if (!days) return "Lifetime access";
@@ -38,6 +33,7 @@ const PageSkeleton = () => (
export default function CourseCheckout() {
const navigate = useNavigate();
const { id: courseId } = useParams();
const { fmtCurrency } = useDateFormat();
const [searchParams] = useSearchParams();
const returnToken = searchParams.get("token");
@@ -181,14 +177,14 @@ export default function CourseCheckout() {
<CardContent className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Course Price</span>
<span className="font-medium">{formatPrice(product.price, product.currency)}</span>
<span className="font-medium">{fmtCurrency(product.price, product.currency)}</span>
</div>
<Separator />
<div className="flex justify-between items-center text-lg font-semibold">
<span>Total</span>
<span>{formatPrice(product.price, product.currency)}</span>
<span>{fmtCurrency(product.price, product.currency)}</span>
</div>
{course?.has_purchased ? (
@@ -206,7 +202,7 @@ export default function CourseCheckout() {
? <Loader2 className="size-4 animate-spin" />
: <ShieldCheck className="size-4" />
}
Pay {formatPrice(product.price, product.currency)} with PayPal
Pay {fmtCurrency(product.price, product.currency)} with PayPal
</Button>
)}
+20 -15
View File
@@ -1,5 +1,7 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useParams, useNavigate } from "react-router-dom";
import api from "@/utils/api.util";
import {
House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon,
SendHorizonal, CheckCheck, CheckCircle2, Clock,
@@ -21,6 +23,7 @@ import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgressContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { toast } from "sonner";
import { resolveTierBadge } from "@/utils/tierBadge.util";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -155,11 +158,8 @@ const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle,
// ─── Certificate Card ─────────────────────────────────────────────────────────
function fmtDate(iso) {
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
const CertCard = ({ courseTitle, courseLevel, pendingCert, certificate, delay, nodeRef }) => {
const { fmtDate } = useDateFormat();
const isIssued = !!certificate;
const isPending = !isIssued && !!pendingCert;
@@ -382,6 +382,17 @@ const CourseDetails = () => {
const { myTier, getMyTier } = useClientTiers();
const { fetchCourseProgress, isCompleted, resetProgress } = useCourseReadingProgress();
const [tierMap, setTierMap] = useState({});
useEffect(() => {
api.get("/client/tiers/categories")
.then(({ data }) => {
const m = {};
(data.data ?? []).forEach((c) => { m[c.slug] = c; });
setTierMap(m);
})
.catch(() => {});
}, []);
const hasCompleted = !!course?.is_completed;
useEffect(() => {
@@ -458,17 +469,11 @@ const CourseDetails = () => {
<div className="flex lg:flex-row items-start justify-between w-full">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
{course?.plan_tier && course.plan_tier !== "free" ? (
<Badge className={
course.plan_tier === "premium"
? "bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white"
: "bg-gradient-to-r from-rose-500 to-red-600 text-white"
}>
{course.plan_tier.charAt(0).toUpperCase() + course.plan_tier.slice(1)}
</Badge>
) : (
<Badge className="bg-gradient-to-r from-lime-400 to-lime-600 text-white">Free</Badge>
)}
{(() => {
const slug = course?.plan_tier ?? course?.subscription ?? "free";
const { label, cls } = resolveTierBadge(slug, tierMap);
return <Badge className={cls}>{label}</Badge>;
})()}
</div>
<h1 className="font-bold text-4xl">{course?.title ?? "Course Title"}</h1>
<p className="max-w-2xl lg:text-lg">{course?.description ?? ""}</p>
+95 -134
View File
@@ -11,10 +11,12 @@ 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";
import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util";
import { resolveTierBadge } from "@/utils/tierBadge.util";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -29,21 +31,13 @@ function formatDuration(seconds = 0) {
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 CourseCard = ({ course, tierMap, onViewDetails }) => {
const slug = course.subscription ?? "free";
const locked = course.is_locked;
const duration = formatDuration(course.duration_seconds);
const { rank, label, cls } = resolveTierBadge(slug, tierMap);
return (
<div
@@ -57,31 +51,10 @@ const CourseCard = ({ course, onViewDetails }) => {
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>
)}
<Badge className={cls}>
{locked ? <LockIcon className="size-3" /> : <Tag className="size-3" />}
{label}
</Badge>
{course.level && (
<Badge variant="outline">{course.level.charAt(0).toUpperCase() + course.level.slice(1)}</Badge>
)}
@@ -137,7 +110,7 @@ const CourseCardSkeleton = () => (
const Pagination = ({ currentPage, totalPages, totalItems, itemsPerPage, onPageChange }) => {
const start = (currentPage - 1) * itemsPerPage + 1;
const end = Math.min(currentPage * itemsPerPage, totalItems);
const end = Math.min(currentPage * itemsPerPage, totalItems);
const getPages = () => {
const pages = [];
@@ -167,12 +140,7 @@ const Pagination = ({ currentPage, totalPages, totalItems, itemsPerPage, onPageC
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)}
>
<Button key={page} size="sm" variant={currentPage === page ? "default" : "outline"} onClick={() => onPageChange(page)}>
{page}
</Button>
)
@@ -190,8 +158,9 @@ const Pagination = ({ currentPage, totalPages, totalItems, itemsPerPage, onPageC
const CoursesList = () => {
const navigate = useNavigate();
const { courses, coursesLoading, getCourses } = useClientCourses();
const { myTier, getMyTier } = useClientTiers();
const { fmtCurrency } = useDateFormat();
const [tierCategories, setTierCategories] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const [search, setSearch] = useState("");
const [levelFilter, setLevelFilter] = useState("All");
@@ -200,29 +169,34 @@ const CoursesList = () => {
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();
api.get("/client/tiers/categories")
.then(({ data }) => setTierCategories(data.data ?? []))
.catch(() => {});
}, []);
// ── Filter + sort ─────────────────────────────────────────────────────────
// slug → category info map
const tierMap = useMemo(() => {
const m = {};
tierCategories.forEach((c) => { m[c.slug] = c; });
return m;
}, [tierCategories]);
// Collect unique product 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 filtered = useMemo(() =>
courses
.filter((c) => {
const matchSearch = c.title.toLowerCase().includes(search.toLowerCase()) ||
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 matchLevel = levelFilter === "All" || (c.level ?? "").toLowerCase() === levelFilter.toLowerCase();
const matchSub = subFilter === "All" || c.subscription === subFilter;
const matchCategory = categoryFilter === "All" || (c.categories ?? []).some((cat) => String(cat.id) === categoryFilter);
return matchSearch && matchLevel && matchSub && matchCategory;
})
@@ -231,17 +205,10 @@ const CoursesList = () => {
);
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 paginated = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
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) {
if (course.is_locked) {
setSelectedCourse(course);
setModalOpen(true);
} else {
@@ -254,6 +221,9 @@ const CoursesList = () => {
{ label: "Courses" },
];
// Upsell modal tier panel
const upsellTier = selectedCourse ? tierMap[selectedCourse.subscription] : null;
return (
<div>
<PageMeta title="Courses - STARR" description="Browse your available training courses." />
@@ -282,39 +252,43 @@ const CoursesList = () => {
<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>
{tierCategories.map((cat) => (
<SelectItem key={cat.slug} value={cat.slug}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Category chips */}
{/* Product 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>
<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 */}
@@ -332,7 +306,12 @@ const CoursesList = () => {
) : (
<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} />
<CourseCard
key={course.course_id}
course={course}
tierMap={tierMap}
onViewDetails={handleViewDetails}
/>
))}
</div>
)}
@@ -349,7 +328,7 @@ const CoursesList = () => {
</div>
</div>
{/* Upsell Modal — only for locked courses */}
{/* Upsell Modal */}
<ResponsiveModal
open={modalOpen}
onOpenChange={setModalOpen}
@@ -359,13 +338,13 @@ const CoursesList = () => {
<>
<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
variant="outline"
onClick={() => { setModalOpen(false); navigate(`/course/${selectedCourse.course_id}/checkout`); }}
>
<ShoppingCart className="size-4" />
Buy {fmtCurrency(selectedCourse.product.price ?? 0, selectedCourse.product.currency ?? "USD")}
</Button>
)}
<Button onClick={() => { setModalOpen(false); navigate("/plans"); }}>
<LockIcon /> View Plans
@@ -374,47 +353,29 @@ const CoursesList = () => {
}
>
<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
{upsellTier && !upsellTier.is_default && (() => {
const { cls, panel } = resolveTierBadge(selectedCourse?.subscription ?? "", tierMap);
return (
<div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}>
<div className="flex items-center gap-3 mb-3">
<Badge className={cls}>
<LockIcon className="size-3" /> {upsellTier.name}
</Badge>
</div>
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4">
<li className="flex items-center gap-2"><Check /> Access to {upsellTier.name} content</li>
<li className="flex items-center gap-2"><Check /> Certificates & achievements</li>
</ul>
<p className="text-sm text-muted-foreground">
Upgrade to a <span className="font-medium">{upsellTier.name}</span> plan to unlock this course.
</p>
</div>
<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;
export default CoursesList;
+132 -151
View File
@@ -1,21 +1,25 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
TableOfContents, Users, Timer,
Users, Timer,
Tag, LockIcon, Check,
} from "lucide-react";
import { ThemeSwitcher } from "../components/ThemeSwitcher";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table, TableHeader, TableBody,
TableHead, TableRow, TableCell,
} from "@/components/ui/table";
import { useNavigate, useLocation } from "react-router-dom";
import { toast } from "sonner";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { useClientCourses } from "@/contexts/ClientCoursesContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { cn } from "@/lib/utils";
import { useGroup } from "@/contexts/ClientGroupContext";
import { useTask } from "@/contexts/ClientTaskContext";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Hero, HeroSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Hero";
import { Popup } from "@/components/generic/Blocks/Client/Advertisements/Popup";
@@ -32,20 +36,21 @@ function formatDuration(seconds = 0) {
return `${m}m`;
}
// Mirrors the same access logic in CoursesList.jsx
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;
// Rank-based access: user's rank must be >= course's required rank.
function canAccess(userTier, planTier, tierMap) {
const courseRank = tierMap[planTier]?.rank ?? (planTier && planTier !== "free" ? Infinity : 0);
const userRank = tierMap[userTier]?.rank ?? 0;
return userRank >= courseRank;
}
// ── Course Card ──────────────────────────────────────────────────────────────
const CourseCard = ({ course, onViewDetails }) => {
const type = course.plan_tier ?? "free";
const locked = course.is_locked;
const { tierMap } = useClientTiers();
const slug = course.subscription ?? "free";
const locked = course.is_locked;
const duration = formatDuration(course.duration_seconds);
const { rank, label, cls } = resolveTierBadge(slug, tierMap);
return (
<div
@@ -59,21 +64,10 @@ const CourseCard = ({ course, onViewDetails }) => {
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" && (
<Badge className="bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white">
{locked ? <LockIcon /> : <Tag />} Premium
</Badge>
)}
{type === "exclusive" && (
<Badge className="bg-gradient-to-r from-rose-500 to-red-600 text-white">
<LockIcon /> Exclusive
</Badge>
)}
<Badge className={cls}>
{rank > 0 || locked ? <LockIcon className="size-3" /> : <Tag className="size-3" />}
{label}
</Badge>
{course.level && (
<Badge variant="outline">
{course.level.charAt(0).toUpperCase() + course.level.slice(1)}
@@ -127,20 +121,88 @@ const CourseCardSkeleton = () => (
</div>
);
// ─── Groups Table ─────────────────────────────────────────────────────────────
const GroupsTable = ({ groups, onView }) => (
<div className="bg-card border rounded-xl overflow-hidden">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-10 text-center px-4">#</TableHead>
<TableHead className="px-4">Group Name</TableHead>
<TableHead className="px-4">Code</TableHead>
<TableHead className="px-4 w-full">Description</TableHead>
<TableHead className="px-4" />
</TableRow>
</TableHeader>
<TableBody>
{groups.map((g, i) => {
const isDefault = g.group_code === 'NOGRP';
return (
<TableRow key={g.group_id}>
<TableCell className="text-center px-4 text-muted-foreground tabular-nums">
{i + 1}
</TableCell>
<TableCell className="px-4 font-medium">{g.name}</TableCell>
<TableCell className="px-4">
<Badge variant="outline" className="font-mono text-xs">{g.group_code}</Badge>
</TableCell>
<TableCell className="px-4 text-muted-foreground">
{isDefault
? <span className="text-xs italic">Awaiting assignment by admin</span>
: (g.description ?? <span className="text-xs text-muted-foreground/50">—</span>)
}
</TableCell>
<TableCell className="px-4 text-right">
<Button size="sm" variant="outline" onClick={() => onView(g)}>
View
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
const GroupsTableSkeleton = () => (
<div className="bg-card border rounded-xl overflow-hidden">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-10 text-center px-4">#</TableHead>
<TableHead className="px-4">Group Name</TableHead>
<TableHead className="px-4">Code</TableHead>
<TableHead className="px-4 w-full">Description</TableHead>
<TableHead className="px-4" />
</TableRow>
</TableHeader>
<TableBody>
{[1, 2].map((i) => (
<TableRow key={i}>
<TableCell className="text-center px-4"><Skeleton className="h-4 w-4 mx-auto" /></TableCell>
<TableCell className="px-4"><Skeleton className="h-4 w-32" /></TableCell>
<TableCell className="px-4"><Skeleton className="h-5 w-16 rounded-full" /></TableCell>
<TableCell className="px-4"><Skeleton className="h-4 w-48" /></TableCell>
<TableCell className="px-4 text-right"><Skeleton className="h-8 w-14 ml-auto" /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
// ─── Client Dashboard ─────────────────────────────────────────────────────────
const Client = () => {
const navigate = useNavigate();
const { state: navState } = useLocation();
const { courses, coursesLoading, getCourses } = useClientCourses();
const { myTier, getMyTier } = useClientTiers();
const { myTier, getMyTier, tierMap } = useClientTiers();
const { groups, fetchGroups, loading: groupLoading } = useGroup();
const { fetchTaskLists } = useTask();
const { advertisements, loading: adLoading, getActiveAdvertisement, trackClick } = useClientAdvertisements();
const [completedCount, setCompletedCount] = useState(0);
const [dueSoonCount, setDueSoonCount] = useState(0);
const [modalOpen, setModalOpen] = useState(false);
const [selectedCourse, setSelectedCourse] = useState(null);
@@ -192,16 +254,14 @@ const Client = () => {
// Show only first 3
const featuredCourses = courses.slice(0, 3);
const myGroup = groups?.[0] ?? null;
const breadcrumbItems = [
{ label: "My Group", icon: <Users className="size-4" />, to: `` },
{ label: "Statistics" },
{ label: "My Groups", icon: <Users className="size-4" /> },
];
// ── Card click — mirrors CoursesList.jsx logic ────────────────────────────
const handleViewDetails = (course) => {
const accessible = canAccess(userTier, course.plan_tier);
const accessible = canAccess(userTier, course.subscription, tierMap);
if (!accessible) {
setSelectedCourse(course);
setModalOpen(true);
@@ -211,43 +271,6 @@ const Client = () => {
};
// ── Fetch all task lists once group is known, count individual tasks ─────
//
// "Completed Tasks" and "Due soon" are TASK-level counts (not task-list-level),
// so we fetch ALL task lists unfiltered, flatten every task across them, and
// count by each task's own has_completed flag — regardless of which bucket
// the task list as a whole falls into.
useEffect(() => {
if (!myGroup?.group_id) return;
fetchTaskLists(myGroup.group_id).then((data) => {
if (!data) return;
const allTasks = data.flatMap((taskList) => taskList.tasks ?? []);
// ── Completed Tasks: individual tasks with has_completed ───────────────
const completed = allTasks.filter((task) => task.has_completed).length;
setCompletedCount(completed);
// ── Due soon: incomplete tasks with deadline within 24h ────────────────
const now = Date.now();
const DAY = 24 * 60 * 60 * 1000;
let dueSoon = 0;
allTasks.forEach((task) => {
if (!task.deadline) return;
if (task.has_completed) return; // already submitted, skip
const deadline = new Date(task.deadline).getTime();
const diff = deadline - now;
if (diff > 0 && diff <= DAY) dueSoon += 1;
});
setDueSoonCount(dueSoon);
});
}, [myGroup?.group_id]);
return (
<div>
<div className="my-20">
@@ -260,52 +283,26 @@ const Client = () => {
<Hero ad={heroAd} onCtaClick={handleAdCtaClick} />
)}
{/* ── Group affiliated ── */}
<div className="flex flex-col gap-4">
<AppBreadcrumb items={breadcrumbItems} />
<div className="flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
{groupLoading ? (
<Skeleton className="h-7 w-40" />
) : (
<h1 className="text-2xl font-medium">{myGroup?.name ?? 'No Group'}</h1>
)}
<Button
onClick={() => myGroup && navigate(`/group/${myGroup.group_id}`)}
disabled={!myGroup}
>
View
</Button>
</div>
<div className="grid xs:grid-cols-2 lg:grid-cols-5 gap-4">
<div className="bg-card shadow-md border rounded-md space-y-4 p-4">
<div className="flex gap-2 items-center">
<Button size="sm" variant="secondary">
<TableOfContents />
</Button>
<h1>Completed Tasks</h1>
</div>
{groupLoading ? (
<Skeleton className="h-8 w-10" />
) : (
<h1 className="font-medium text-2xl">{completedCount}</h1>
)}
</div>
<div className="bg-card shadow-md border rounded-md space-y-4 p-4">
<div className="flex gap-2 items-center">
<Button size="sm" variant="secondary">
<Timer />
</Button>
<h1>Due soon</h1>
</div>
{groupLoading ? (
<Skeleton className="h-8 w-10" />
) : (
<h1 className="font-medium text-2xl">{dueSoonCount}</h1>
)}
</div>
</div>
{/* ── My Groups ── */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<AppBreadcrumb items={breadcrumbItems} />
{!groupLoading && groups.length > 0 && (
<span className="text-sm text-muted-foreground">
{groups.length} group{groups.length !== 1 ? 's' : ''}
</span>
)}
</div>
{groupLoading ? (
<GroupsTableSkeleton />
) : groups.length === 0 ? (
<p className="text-sm text-muted-foreground">You are not assigned to any group.</p>
) : (
<GroupsTable
groups={groups}
onView={(g) => navigate(`/group/${g.group_id}`)}
/>
)}
</div>
{/* ── Featured Courses (first 3) ── */}
@@ -363,43 +360,27 @@ const Client = () => {
}
>
<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
{(() => {
const slug = selectedCourse?.subscription ?? "free";
const { rank, label, cls, panel } = resolveTierBadge(slug, tierMap);
if (rank === 0) return null;
return (
<div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}>
<div className="flex items-center gap-3 mb-3">
<Badge className={cls}>
<LockIcon className="size-3" /> {label}
</Badge>
</div>
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4">
<li className="flex items-center gap-2"><Check /> Access to {label} content</li>
<li className="flex items-center gap-2"><Check /> Certificates & achievements</li>
</ul>
<p className="text-sm text-muted-foreground">
Upgrade to a <span className="font-medium">{label}</span> plan to unlock this course.
</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>
+3 -3
View File
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { useProfile } from "@/contexts/ProfileProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
const ACHIEVEMENT_ICONS = {
early_access: Star,
@@ -26,6 +27,7 @@ const getFallbackIcon = (type) => type === "badge" ? BadgeCheck : Trophy;
export default function MyAchievements() {
const navigate = useNavigate();
const { achievements, achievementsLoading, getAchievements } = useProfile();
const { fmtDate } = useDateFormat();
useEffect(() => { getAchievements(); }, []);
@@ -81,9 +83,7 @@ export default function MyAchievements() {
<p className="text-xs text-muted-foreground mt-0.5">{item.description}</p>
{item.granted_at && (
<p className="text-xs text-muted-foreground/70 mt-1">
{new Date(item.granted_at).toLocaleDateString("en-US", {
month: "long", day: "numeric", year: "numeric",
})}
{fmtDate(item.granted_at)}
</p>
)}
</div>
+3 -3
View File
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { useProfile } from "@/contexts/ProfileProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util";
import { toast } from "sonner";
@@ -25,10 +26,9 @@ const CertBadgeIcon = ({ className }) => (
const CertificateCard = ({ courseTitle, issuedAt, courseUuid }) => {
const [downloading, setDownloading] = useState(false);
const { fmtDate } = useDateFormat();
const issuedLabel = issuedAt
? new Date(issuedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
: "—";
const issuedLabel = issuedAt ? fmtDate(issuedAt) : "—";
const handleDownload = async () => {
setDownloading(true);
+66 -32
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Card, CardContent, CardDescription,
@@ -17,15 +17,16 @@ import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { toast } from "sonner";
import api from "@/utils/api.util";
import { PageMeta } from "@/contexts/MetadataContext";
import { useDateFormat } from "@/hooks/useDateFormat";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatPrice(price, currency = "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency,
minimumFractionDigits: 2,
}).format(price);
const REFUND_WINDOW_SECS = 5 * 60;
function formatCountdown(secs) {
const m = Math.floor(secs / 60);
const s = secs % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function formatDuration(days) {
@@ -93,7 +94,8 @@ const PlanSkeleton = () => (
// ─── Plan Card ────────────────────────────────────────────────────────────────
const PlanCard = ({ plan, myTier, onSelect, onView, onRefund }) => {
const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft }) => {
const { fmtCurrency } = useDateFormat();
const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
const Icon = style.icon;
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
@@ -116,7 +118,7 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund }) => {
</div>
<CardDescription>
<span className="text-3xl font-bold text-foreground">
{formatPrice(plan.price, plan.currency)}
{fmtCurrency(plan.price, plan.currency)}
</span>
{duration && (
<span className="text-sm text-muted-foreground ml-1">/ {duration}</span>
@@ -170,15 +172,16 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund }) => {
>
View Details
</Button>
{isCurrent ? (
{isCurrent && refundSecsLeft > 0 ? (
<Button
className="flex-1"
variant="destructive"
onClick={() => onRefund(plan)}
>
<RotateCcw className="size-4" /> Refund
<RotateCcw className="size-4" />
Refund ({formatCountdown(refundSecsLeft)})
</Button>
) : (
) : !isCurrent ? (
<Button
className="flex-1"
variant={style.button}
@@ -186,7 +189,7 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund }) => {
>
{plan.tier === "free" ? "Current" : `Get ${style.label}`}
</Button>
)}
) : null}
</CardFooter>
</Card>
);
@@ -197,15 +200,34 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund }) => {
export default function PlanList() {
const navigate = useNavigate();
const { plans, plansLoading, myTier, tierLoading, getPlans, getMyTier, resetMyTier } = useClientTiers();
const { fmtCurrency, fmtDate } = useDateFormat();
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
const [refundLoading, setRefundLoading] = useState(false);
const [refundSecsLeft, setRefundSecsLeft] = useState(0);
const refundTimerRef = useRef(null);
useEffect(() => {
getPlans();
getMyTier();
}, [getPlans, getMyTier]);
useEffect(() => {
clearInterval(refundTimerRef.current);
if (!myTier?.starts_at) { setRefundSecsLeft(0); return; }
const compute = () => {
const elapsed = Math.floor((Date.now() - new Date(myTier.starts_at).getTime()) / 1000);
return Math.max(0, REFUND_WINDOW_SECS - elapsed);
};
setRefundSecsLeft(compute());
refundTimerRef.current = setInterval(() => {
const left = compute();
setRefundSecsLeft(left);
if (left === 0) clearInterval(refundTimerRef.current);
}, 1000);
return () => clearInterval(refundTimerRef.current);
}, [myTier?.starts_at]);
const handleViewPlan = (plan) => navigate(`/plans/view/${plan.plan_id}`);
const handleSelectPlan = (plan) => navigate(`/plans/checkout?plan_id=${plan.plan_id}`);
@@ -216,7 +238,7 @@ export default function PlanList() {
setRefundLoading(true);
try {
const { data } = await api.post("/client/tiers/checkout/refund");
toast.success(data.message ?? "Refund processed. Access remains until end of billing period.");
toast.success(data.message ?? "Refund processed. Your access has been revoked.");
setRefundPlan(null);
resetMyTier();
getMyTier();
@@ -234,7 +256,7 @@ export default function PlanList() {
<div className="lg:container lg:mx-auto space-y-8 p-6">
{/* Advertisement Banner */}
<Card className="overflow-hidden border-primary/20 bg-gradient-to-r from-primary/10 via-primary/5 to-background">
{/* <Card className="overflow-hidden border-primary/20 bg-gradient-to-r from-primary/10 via-primary/5 to-background">
<CardContent className="flex flex-col gap-4 py-20 md:flex-row md:items-center md:justify-between pl-10">
<div className="space-y-2">
<Badge>
@@ -251,10 +273,10 @@ export default function PlanList() {
</div>
</div>
</CardContent>
</Card>
</Card> */}
{/* Section Header */}
<div className="text-center">
<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.
@@ -279,6 +301,7 @@ export default function PlanList() {
onSelect={handleSelectPlan}
onView={handleViewPlan}
onRefund={handleRefundClick}
refundSecsLeft={refundSecsLeft}
/>
))
)}
@@ -305,7 +328,7 @@ export default function PlanList() {
<Button
variant="destructive"
onClick={handleConfirmRefund}
disabled={refundLoading}
disabled={refundLoading || refundSecsLeft === 0}
>
<RotateCcw className="size-4" />
{refundLoading ? "Processing..." : "Confirm Refund"}
@@ -322,30 +345,41 @@ export default function PlanList() {
<div className="flex justify-between">
<span className="text-muted-foreground">Refund amount</span>
<span className="font-medium">
{refundPlan ? formatPrice(refundPlan.price, refundPlan.currency) : "—"}
{refundPlan ? fmtCurrency(refundPlan.price, refundPlan.currency) : "—"}
</span>
</div>
{myTier?.expires_at && (
<div className="flex justify-between">
<span className="text-muted-foreground">Access until</span>
<span className="font-medium">
{new Date(myTier.expires_at).toLocaleDateString("en-US", {
month: "long", day: "numeric", year: "numeric",
})}
{fmtDate(myTier.expires_at)}
</span>
</div>
)}
<div className="flex justify-between items-center pt-1 border-t">
<span className="text-muted-foreground">Refund window</span>
{refundSecsLeft > 0 ? (
<span className="font-semibold tabular-nums text-destructive">
{formatCountdown(refundSecsLeft)} remaining
</span>
) : (
<span className="font-semibold text-muted-foreground">Expired</span>
)}
</div>
</div>
<p className="text-sm text-muted-foreground">
Your refund will be processed through PayPal. You will retain access to your current plan until{" "}
<span className="font-medium text-foreground">
{myTier?.expires_at
? new Date(myTier.expires_at).toLocaleDateString("en-US", {
month: "long", day: "numeric", year: "numeric",
})
: "the end of the billing period"}
</span>.
</p>
{refundSecsLeft > 0 ? (
<p className="text-sm text-muted-foreground">
Your refund will be processed through PayPal.{" "}
<span className="font-medium text-foreground">
Access will be revoked immediately
</span>{" "}
and your account will be downgraded to Free.
</p>
) : (
<p className="text-sm text-destructive">
The 5-minute refund window has expired. Refunds are no longer available for this payment.
</p>
)}
</div>
</ResponsiveModal>
</div>
+76 -48
View File
@@ -3,6 +3,8 @@ import { useNavigate } from "react-router-dom";
import {
Edit, BookOpen, Award, Trophy, Shield, Star, Zap, Target, BadgeCheck, Medal, Flame, LockIcon, Camera, ChevronRight, Download, RefreshCcw
} from "lucide-react";
import * as LucideIcons from "lucide-react";
import { getTierColor } from "@/utils/tierColors";
import AvatarUploadDialog from "@/components/generic/AvatarUploadDialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
@@ -16,12 +18,13 @@ import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { useProfile } from "@/contexts/ProfileProvider";
import { useAuth } from "@/contexts/AuthContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util";
import { toast } from "sonner";
// ─── Tier badge config ────────────────────────────────────────────────────────
// ─── Tier badge fallbacks (used when no policy is configured in DB) ───────────
const TIER_BADGES = {
const TIER_BADGE_FALLBACKS = {
free: {
src: "/badges/free-access-badge-leaf-flaticon.svg",
label: "Free",
@@ -42,13 +45,56 @@ const TIER_BADGES = {
},
};
const EARLY_ACCESS_BADGE = {
const EARLY_ACCESS_FALLBACK = {
src: "/badges/early-access-badge-percent-flaticon.svg",
label: "Early Access",
description: "Registered during the Philproperties beta period.",
information: "Exclusive to members who registered before Dec 31, 2026.",
};
// Renders either a Lucide icon badge or an image badge.
function TierBadgeDisplay({ badge, className }) {
if (badge?.src) return <img src={badge.src} alt={badge.label} className={className} />;
if (badge?.icon) {
const Icon = LucideIcons[badge.icon];
if (!Icon) return null;
const swatch = getTierColor(badge.colorKey ?? "green").swatch;
return <Icon className={className} style={{ color: swatch }} />;
}
return null;
}
function resolveTierBadge(myTier) {
const tier = myTier?.tier ?? "free";
const category = myTier?.plan?.category ?? myTier?.category ?? null;
if (category) {
const hasBadge = category.badgeAsset || category.badge_icon || category.badge_label;
if (hasBadge) {
return {
src: category.badgeAsset?.file_url ?? null,
icon: !category.badgeAsset ? (category.badge_icon ?? null) : null,
colorKey: category.color ?? "green",
label: category.badge_label ?? category.name ?? tier,
description: "",
information: "",
};
}
}
return TIER_BADGE_FALLBACKS[tier] ?? TIER_BADGE_FALLBACKS.free;
}
function resolveEarlyAccessBadge(systemBadges) {
const found = systemBadges?.find((b) => b.key === "early_access");
if (!found) return EARLY_ACCESS_FALLBACK;
return {
src: found.asset?.file_url ?? EARLY_ACCESS_FALLBACK.src,
label: found.label ?? EARLY_ACCESS_FALLBACK.label,
description: found.description ?? EARLY_ACCESS_FALLBACK.description,
information: found.information ?? EARLY_ACCESS_FALLBACK.information,
};
}
// ─── Achievement icon map (by key) ───────────────────────────────────────────
const ACHIEVEMENT_ICONS = {
@@ -84,10 +130,9 @@ const CertBadgeIcon = ({ className }) => (
const LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid }) => {
const [downloading, setDownloading] = useState(false);
const { fmtDate } = useDateFormat();
const issuedLabel = issuedAt
? new Date(issuedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
: "—";
const issuedLabel = issuedAt ? fmtDate(issuedAt) : "—";
const handleDownload = async () => {
setDownloading(true);
@@ -135,6 +180,7 @@ const LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid }) => {
const ProfilePage = () => {
const navigate = useNavigate();
const { user } = useAuth();
const { fmtDate } = useDateFormat();
const {
profile, profileLoading, getProfile,
@@ -145,7 +191,7 @@ const ProfilePage = () => {
const [avatarDialogOpen, setAvatarDialogOpen] = useState(false);
const { myTier, tierLoading, getMyTier } = useClientTiers();
const { myTier, tierLoading, getMyTier, systemBadges, getSystemBadges } = useClientTiers();
const [badgeOpen, setBadgeOpen] = useState(false);
const [selectedBadge, setSelectedBadge] = useState(null);
@@ -159,6 +205,7 @@ const ProfilePage = () => {
getProfile();
getAchievements();
getMyTier();
getSystemBadges();
(async () => {
setInProgressCoursesLoading(true);
try {
@@ -175,7 +222,8 @@ const ProfilePage = () => {
// ── Derived ────────────────────────────────────────────────────────────────
const tier = myTier?.tier ?? user?.tier ?? "free";
const tierBadge = TIER_BADGES[tier] ?? TIER_BADGES.free;
const tierBadge = resolveTierBadge(myTier);
const earlyAccessBadge = resolveEarlyAccessBadge(systemBadges);
const displayName = fullName || user?.personal_info?.name?.full_name || user?.email?.split("@")[0] || "—";
const initials = displayName.split(" ").map((w) => w[0]).join("").slice(0, 2).toUpperCase();
@@ -194,22 +242,14 @@ const ProfilePage = () => {
// Build badge object with earnedAt from achievements for modal
const getBadgeWithDate = (achievementKey, tierKey) => {
const achievement = achievements.find((a) => a.key === achievementKey);
const earnedAt = achievement?.granted_at
? new Date(achievement.granted_at).toLocaleDateString("en-US", {
month: "long", day: "numeric", year: "numeric",
})
: null;
return { ...TIER_BADGES[tierKey], earnedAt };
const earnedAt = achievement?.granted_at ? fmtDate(achievement.granted_at) : null;
return { ...TIER_BADGE_FALLBACKS[tierKey], earnedAt };
};
const getEarlyAccessBadge = () => {
const achievement = achievements.find((a) => a.key === "early_access");
const earnedAt = achievement?.granted_at
? new Date(achievement.granted_at).toLocaleDateString("en-US", {
month: "long", day: "numeric", year: "numeric",
})
: null;
return { ...EARLY_ACCESS_BADGE, earnedAt };
const earnedAt = achievement?.granted_at ? fmtDate(achievement.granted_at) : null;
return { ...earlyAccessBadge, earnedAt };
};
const getActiveTierBadgeWithDate = () => {
@@ -261,12 +301,12 @@ const ProfilePage = () => {
<TooltipTrigger asChild>
<img
className="size-4.5 cursor-pointer"
src={EARLY_ACCESS_BADGE.src}
alt={EARLY_ACCESS_BADGE.label}
src={earlyAccessBadge.src}
alt={earlyAccessBadge.label}
onClick={() => { setSelectedBadge(getEarlyAccessBadge()); setBadgeOpen(true); }}
/>
</TooltipTrigger>
<TooltipContent><p>{EARLY_ACCESS_BADGE.label}</p></TooltipContent>
<TooltipContent><p>{earlyAccessBadge.label}</p></TooltipContent>
</Tooltip>
)}
@@ -276,7 +316,7 @@ const ProfilePage = () => {
<TooltipTrigger asChild>
<img
className="size-4.5 cursor-pointer"
src={TIER_BADGES.premium.src}
src={TIER_BADGE_FALLBACKS.premium.src}
alt="Premium"
onClick={() => { setSelectedBadge(getBadgeWithDate("premium_first_time", "premium")); setBadgeOpen(true); }}
/>
@@ -291,7 +331,7 @@ const ProfilePage = () => {
<TooltipTrigger asChild>
<img
className="size-4.5 cursor-pointer"
src={TIER_BADGES.exclusive.src}
src={TIER_BADGE_FALLBACKS.exclusive.src}
alt="Exclusive"
onClick={() => { setSelectedBadge(getBadgeWithDate("exclusive_first_time", "exclusive")); setBadgeOpen(true); }}
/>
@@ -305,12 +345,9 @@ const ProfilePage = () => {
{userRank === 0 && (
<Tooltip>
<TooltipTrigger asChild>
<img
className="size-4.5 cursor-pointer"
src={tierBadge.src}
alt={tierBadge.label}
onClick={() => { setSelectedBadge(tierBadge); setBadgeOpen(true); }}
/>
<span className="cursor-pointer" onClick={() => { setSelectedBadge(tierBadge); setBadgeOpen(true); }}>
<TierBadgeDisplay badge={tierBadge} className="size-4.5" />
</span>
</TooltipTrigger>
<TooltipContent><p>{tierBadge.label}</p></TooltipContent>
</Tooltip>
@@ -320,12 +357,9 @@ const ProfilePage = () => {
{userRank === 1 && !hasPremiumBadge && (
<Tooltip>
<TooltipTrigger asChild>
<img
className="size-4.5 cursor-pointer"
src={tierBadge.src}
alt={tierBadge.label}
onClick={() => { setSelectedBadge(getActiveTierBadgeWithDate()); setBadgeOpen(true); }}
/>
<span className="cursor-pointer" onClick={() => { setSelectedBadge(getActiveTierBadgeWithDate()); setBadgeOpen(true); }}>
<TierBadgeDisplay badge={tierBadge} className="size-4.5" />
</span>
</TooltipTrigger>
<TooltipContent><p>{tierBadge.label}</p></TooltipContent>
</Tooltip>
@@ -363,8 +397,8 @@ const ProfilePage = () => {
}
>
<div className="flex flex-col items-center gap-4 py-2">
{selectedBadge?.src && (
<img src={selectedBadge.src} alt={selectedBadge.label} className="size-16" />
{(selectedBadge?.src || selectedBadge?.icon) && (
<TierBadgeDisplay badge={selectedBadge} className="size-16" />
)}
<div className="text-center space-y-1">
<p className="text-sm font-medium">{selectedBadge?.label}</p>
@@ -530,9 +564,7 @@ const ProfilePage = () => {
<div className="flex justify-between">
<span className="text-muted-foreground">Member since</span>
<span className="font-medium">
{profile?.createdAt
? new Date(profile.createdAt).toLocaleDateString("en-US", { month: "long", year: "numeric" })
: "—"}
{profile?.createdAt ? fmtDate(profile.createdAt) : "—"}
</span>
</div>
<Separator />
@@ -542,9 +574,7 @@ const ProfilePage = () => {
<Skeleton className="h-5 w-20" />
) : (
<span className="font-medium capitalize">
{tier}{myTier?.expires_at && ` · until ${new Date(myTier.expires_at).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric",
})}`}
{tier}{myTier?.expires_at && ` · until ${fmtDate(myTier.expires_at)}`}
</span>
)}
</div>
@@ -638,9 +668,7 @@ const ProfilePage = () => {
<p className="text-xs text-muted-foreground">{item.description}</p>
{item.granted_at && (
<p className="text-xs mt-0.5">
{new Date(item.granted_at).toLocaleDateString("en-US", {
month: "long", day: "numeric", year: "numeric",
})}
{fmtDate(item.granted_at)}
</p>
)}
</div>
+363 -90
View File
@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect, useRef } from "react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate, useLocation } from "react-router-dom";
import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle, Zap } from "lucide-react";
import { useParams, useNavigate, useLocation, useBlocker } from "react-router-dom";
import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle, Zap, ListChecks } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
@@ -20,6 +20,64 @@ import { useClientCourses } from "@/contexts/ClientCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgressContext";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import api from "@/utils/api.util";
// ─── Quiz Prerequisite Gate ────────────────────────────────────────────────
const QuizPrerequisiteGate = ({ previousQuizzes, units, onQuizClick }) => {
const [open, setOpen] = useState(false);
const passed = previousQuizzes.filter((q) => q.has_passed).length;
const useModal = previousQuizzes.length > QUIZ_MODAL_THRESHOLD;
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
<div className="w-16 h-16 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center mb-4">
<Lock className="size-8 text-amber-500" />
</div>
<h2 className="text-xl font-bold mb-2">
Complete Previous {previousQuizzes.length > 1 ? "Quizzes" : "Quiz"} First
</h2>
<p className="text-muted-foreground mb-4 max-w-sm text-sm">
You must pass the required {previousQuizzes.length > 1 ? "quizzes" : "quiz"} before you can take this one.
</p>
{useModal ? (
<>
<p className="text-sm mb-4">{passed} of {previousQuizzes.length} quizzes passed</p>
<Button onClick={() => setOpen(true)}>
<ClipboardList className="size-4" />
View Required Quizzes
</Button>
<ResponsiveModal
open={open}
onOpenChange={setOpen}
title="Required Quizzes"
description={`Pass the required quizzes to unlock this one.`}
hideDrawerClose={false}
>
<ScrollArea className="max-h-72 pr-1">
<QuizGateList
requiredQuizzes={previousQuizzes}
units={units}
onQuizClick={onQuizClick}
onItemClick={() => setOpen(false)}
/>
</ScrollArea>
</ResponsiveModal>
</>
) : (
<div className="w-full max-w-sm text-left">
<QuizGateList
requiredQuizzes={previousQuizzes}
units={units}
onQuizClick={onQuizClick}
/>
</div>
)}
</div>
);
};
// ─── Assessment Gate ───────────────────────────────────────────────────────
@@ -114,7 +172,7 @@ const SidebarContent = ({
units, selectedLessonId, selectedQuizId, onLessonClick, onQuizClick,
courseAssessment, selectedAssessment, onAssessmentClick, assessmentLocked,
isCompleted, selectedCompletion, onCompletionClick,
getLessonCompleted, getUnitCompleted,
getLessonCompleted, getUnitCompleted, isQuizLocked,
loading,
}) => (
<ScrollArea className="h-full p-3 md:p-4">
@@ -162,24 +220,31 @@ const SidebarContent = ({
</li>
);
})}
{unit.quiz && (
<li
onClick={() => onQuizClick({ unit, quiz: unit.quiz })}
className={`flex items-center gap-2 pl-6 pr-3 py-1.5 text-sm rounded-md hover:bg-muted-foreground/10 cursor-pointer transition-colors ${
selectedQuizId === unit.quiz.quiz_id
? "bg-muted-foreground/10 text-foreground font-medium"
: unit.quiz.has_passed
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground hover:text-foreground"
}`}
>
{unit.quiz.has_passed
? <CheckCircle2 className="size-3.5 text-emerald-500 shrink-0" />
: <ClipboardList className="size-3.5 shrink-0" />
}
{unit.quiz.title || "Quiz"}
</li>
)}
{unit.quiz && (() => {
const quizLocked = isQuizLocked?.(unit);
return (
<li
onClick={() => onQuizClick({ unit, quiz: unit.quiz })}
className={`flex items-center gap-2 pl-6 pr-3 py-1.5 text-sm rounded-md cursor-pointer transition-colors ${
selectedQuizId === unit.quiz.quiz_id
? "bg-muted-foreground/10 text-foreground font-medium"
: unit.quiz.has_passed
? "text-emerald-600 dark:text-emerald-400 hover:bg-muted-foreground/10"
: quizLocked
? "text-muted-foreground/50 hover:bg-muted-foreground/5"
: "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"
}`}
>
{unit.quiz.has_passed
? <CheckCircle2 className="size-3.5 text-emerald-500 shrink-0" />
: quizLocked
? <Lock className="size-3.5 text-amber-500 shrink-0" />
: <ClipboardList className="size-3.5 shrink-0" />
}
{unit.quiz.title || "Quiz"}
</li>
);
})()}
</ul>
</AccordionContent>
</AccordionItem>
@@ -234,7 +299,7 @@ const UnitList = () => {
const {
course, courseLoading, courseBlocked, getCourse,
lesson, lessonLoading, getLesson, resetLesson,
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz,
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz, saveQuizDraft,
assessment, assessmentLoading, getCourseAssessment, resetAssessment, startCourseAssessment, saveDraft, refreshAssessmentSession, submitCourseAssessment,
} = useClientCourses();
@@ -243,12 +308,17 @@ const UnitList = () => {
upsertLessonProgress,
isCompleted: isProgressCompleted,
isRead: isProgressRead,
completedTasks,
clearCompletedTasks,
resetProgress,
} = useCourseReadingProgress();
// Tracks which lessons have been marked completed this session to avoid duplicate calls
const completedSessionRef = useRef(new Set());
// ── Task context — populated from navigation state or backend fallback ──
const [taskCtx, setTaskCtx] = useState(null);
// ── Local UI state ──────────────────────────────────────────────────────
const [selectedLessonId, setSelectedLessonId] = useState(null);
const [selectedQuizId, setSelectedQuizId] = useState(null);
@@ -260,6 +330,16 @@ const UnitList = () => {
const resetCompletion = () => setSelectedCompletion(false);
// ── Session guard — block navigation while a quiz/assessment is in progress ─
const quizActiveRef = useRef(false); // sync check inside handlers
const [quizSessionActive, setQuizSessionActive] = useState(false);
const [pendingNav, setPendingNav] = useState(null); // deferred nav fn
const setQuizActive = useCallback((active) => {
quizActiveRef.current = active;
setQuizSessionActive(active);
}, []);
// ── Quiz gate — all required unit quizzes must be passed before assessment ─
const requiredQuizzes = (course?.units ?? [])
.filter((u) => u.quiz?.is_required)
@@ -273,6 +353,47 @@ const UnitList = () => {
const allRequiredQuizzesPassed =
requiredQuizzes.length === 0 || requiredQuizzes.every((q) => q.has_passed);
// ── Quiz sequential lock — unit N's quiz is locked until all previous required quizzes are passed ─
const lockedQuizUnitIds = new Set(
(course?.units ?? [])
.filter((u, i, arr) =>
u.quiz && arr.slice(0, i).some(prev => prev.quiz?.is_required && !prev.quiz.has_passed)
)
.map(u => u.unit_id)
);
const selectedUnitIndex = (course?.units ?? []).findIndex(u => u.unit_id === selectedUnitId);
const previousRequiredQuizzes = selectedUnitIndex > 0
? (course?.units ?? []).slice(0, selectedUnitIndex)
.filter(u => u.quiz?.is_required)
.map(u => ({
unit: u,
unitId: u.unit_id,
quizId: u.quiz.quiz_id,
title: u.quiz.title || "Quiz",
has_passed: u.quiz.has_passed ?? false,
}))
: [];
// ── Block React Router navigation (back button / programmatic navigate) ──
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
quizSessionActive && currentLocation.pathname !== nextLocation.pathname
);
useEffect(() => {
if (blocker.state !== "blocked") return;
setPendingNav(() => () => blocker.proceed());
}, [blocker.state]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Block browser tab-close / hard navigation during active session ───────
useEffect(() => {
if (!quizSessionActive) return;
const handler = (e) => { e.preventDefault(); e.returnValue = ''; };
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [quizSessionActive]);
// ── Flatten all content (lessons + quiz + final assessment) ──────────────
const allContent = (course?.units ?? []).flatMap((u) => [
...(u.lessons ?? []).map((l) => ({ type: "lesson", unit: u, lesson: l })),
@@ -307,7 +428,24 @@ const UnitList = () => {
if (completedSessionRef.current.has(selectedLessonId)) return;
if (isProgressCompleted(lesson.uuid)) return;
completedSessionRef.current.add(selectedLessonId);
upsertLessonProgress(courseId, selectedUnitId, selectedLessonId, lesson.uuid, 'completed');
(async () => {
const result = await upsertLessonProgress(courseId, selectedUnitId, selectedLessonId, lesson.uuid, 'completed');
if (!result) return;
toast.success(`"${lesson.title}" marked as read.`);
if (result.unit?.status === 'completed') {
const unitTitle = currentUnit?.title;
toast.success(
unitTitle ? `Unit "${unitTitle}" complete!` : 'Unit complete!',
{ duration: 4000 }
);
}
if (result.course?.status === 'completed') {
toast.success(
'All lessons read! Finish the quizzes & assessment to get certified.',
{ duration: 5000 }
);
}
})();
}, [scrollProgress]);
// ── Fetch course + progress on mount ─────────────────────────────────
@@ -323,12 +461,53 @@ const UnitList = () => {
};
}, [courseId]);
// ── Task context: state-first, endpoint fallback ──────────────────────
// If navigated from ReadCourse the taskCtx is in location.state;
// if the user opened this URL directly, fetch from the backend.
useEffect(() => {
const stateCtx = location.state?.taskCtx;
if (stateCtx) {
setTaskCtx(stateCtx);
return;
}
api.get(`/client/courses/${courseId}/task-context`)
.then(({ data }) => {
if (data?.data?.has_task) setTaskCtx(data.data);
})
.catch(() => {}); // non-critical — silently swallow
}, [courseId]);
// ── Toast when a task's read requirements are all done ────────────────
useEffect(() => {
if (!completedTasks.length) return;
completedTasks.forEach((t) => {
toast.success(`"${t.task_name}" automatically turned in!`);
});
clearCompletedTasks();
}, [completedTasks]);
// ── Auto-load lesson once course data is available ────────────────────
// If navigated from CourseDetails with a specific lesson, open that one;
// otherwise fall back to the first lesson.
useEffect(() => {
if (!course || selectedLessonId || selectedQuizId || selectedAssessment || selectedCompletion) return;
const { lessonId, unitId, seekFirstIncomplete } = location.state ?? {};
const { lessonId, unitId, seekFirstIncomplete, quizUnitId, seekAssessment } = location.state ?? {};
if (seekAssessment && course.assessment) {
setSelectedAssessment(true);
if (allRequiredQuizzesPassed) getCourseAssessment(courseId);
return;
}
if (quizUnitId) {
const targetUnit = (course.units ?? []).find((u) => String(u.unit_id) === String(quizUnitId));
if (targetUnit?.quiz) {
setSelectedQuizId(targetUnit.quiz.quiz_id);
setSelectedUnitId(targetUnit.unit_id);
if (!lockedQuizUnitIds.has(targetUnit.unit_id)) getUnitQuiz(courseId, targetUnit.unit_id);
return;
}
}
if (seekFirstIncomplete) {
const firstIncompleteUnit = (course.units ?? []).find((u) => u.quiz && !u.quiz.has_passed);
@@ -391,78 +570,114 @@ const UnitList = () => {
{ label: selectedCompletion ? "Course Complete" : selectedAssessment ? "Course Assessment" : (currentUnit?.title ?? "Select a lesson") },
];
// ── Session guard helpers ──────────────────────────────────────────────
const handleConfirmNav = useCallback(() => {
const fn = pendingNav;
setPendingNav(null);
quizActiveRef.current = false;
setQuizSessionActive(false);
if (blocker.state === "blocked") blocker.proceed();
else fn?.();
}, [pendingNav, blocker]);
const handleCancelNav = useCallback(() => {
setPendingNav(null);
if (blocker.state === "blocked") blocker.reset();
}, [blocker]);
// ── Lesson click ───────────────────────────────────────────────────────
const handleLessonClick = useCallback(async ({ unit, lesson: lessonStub }) => {
if (lessonStub.lesson_id === selectedLessonId) {
if (lessonStub.lesson_id === selectedLessonId) { setSidebarOpen(false); return; }
const doNav = async () => {
setSelectedLessonId(lessonStub.lesson_id);
setSelectedQuizId(null);
setSelectedAssessment(false);
resetCompletion();
resetQuiz();
resetAssessment();
setSelectedUnitId(unit.unit_id);
setSidebarOpen(false);
return;
}
setSelectedLessonId(lessonStub.lesson_id);
setSelectedQuizId(null);
setSelectedAssessment(false);
resetCompletion();
resetQuiz();
resetAssessment();
setSelectedUnitId(unit.unit_id);
setSidebarOpen(false);
await getLesson(courseId, unit.unit_id, lessonStub.lesson_id);
// Fire in_progress only if not already started or completed
if (!isProgressRead(lessonStub.uuid)) {
upsertLessonProgress(courseId, unit.unit_id, lessonStub.lesson_id, lessonStub.uuid, 'in_progress');
}
await getLesson(courseId, unit.unit_id, lessonStub.lesson_id);
if (!isProgressRead(lessonStub.uuid)) {
upsertLessonProgress(courseId, unit.unit_id, lessonStub.lesson_id, lessonStub.uuid, 'in_progress');
}
};
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
await doNav();
}, [selectedLessonId, courseId, getLesson, resetQuiz, resetAssessment, isProgressRead, upsertLessonProgress]);
// ── Quiz click ─────────────────────────────────────────────────────────
const handleQuizClick = useCallback(async ({ unit, quiz: quizStub }) => {
if (quizStub.quiz_id === selectedQuizId) {
if (quizStub.quiz_id === selectedQuizId) { setSidebarOpen(false); return; }
const doNav = async () => {
setSelectedQuizId(quizStub.quiz_id);
setSelectedLessonId(null);
setSelectedAssessment(false);
resetCompletion();
resetLesson();
resetAssessment();
setSelectedUnitId(unit.unit_id);
setSidebarOpen(false);
return;
}
setSelectedQuizId(quizStub.quiz_id);
setSelectedLessonId(null);
setSelectedAssessment(false);
resetCompletion();
resetLesson();
resetAssessment();
setSelectedUnitId(unit.unit_id);
setSidebarOpen(false);
await getUnitQuiz(courseId, unit.unit_id);
}, [selectedQuizId, courseId, getUnitQuiz, resetLesson, resetAssessment]);
if (!lockedQuizUnitIds.has(unit.unit_id)) {
await getUnitQuiz(courseId, unit.unit_id);
}
};
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
await doNav();
}, [selectedQuizId, courseId, getUnitQuiz, resetLesson, resetAssessment, lockedQuizUnitIds]);
// ── Course assessment click ─────────────────────────────────────────────
const handleAssessmentClick = useCallback(async () => {
if (selectedAssessment) {
if (selectedAssessment) { setSidebarOpen(false); return; }
const doNav = async () => {
setSelectedAssessment(true);
setSelectedLessonId(null);
setSelectedQuizId(null);
resetCompletion();
resetLesson();
resetQuiz();
setSidebarOpen(false);
return;
}
setSelectedAssessment(true);
setSelectedLessonId(null);
setSelectedQuizId(null);
resetCompletion();
resetLesson();
resetQuiz();
setSidebarOpen(false);
if (allRequiredQuizzesPassed) {
await getCourseAssessment(courseId);
}
if (allRequiredQuizzesPassed) await getCourseAssessment(courseId);
};
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
await doNav();
}, [selectedAssessment, courseId, getCourseAssessment, resetLesson, resetQuiz, allRequiredQuizzesPassed]);
// ── Course complete click ───────────────────────────────────────────────
const handleCompletionClick = useCallback(() => {
if (selectedCompletion) {
if (selectedCompletion) { setSidebarOpen(false); return; }
const doNav = () => {
setSelectedLessonId(null);
setSelectedQuizId(null);
setSelectedAssessment(false);
resetLesson();
resetQuiz();
resetAssessment();
setSelectedCompletion(true);
setSidebarOpen(false);
return;
}
setSelectedLessonId(null);
setSelectedQuizId(null);
setSelectedAssessment(false);
resetLesson();
resetQuiz();
resetAssessment();
setSelectedCompletion(true);
setSidebarOpen(false);
};
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
doNav();
}, [selectedCompletion, resetLesson, resetQuiz, resetAssessment]);
// Stable draft callbacks — avoids recreating on every render (which would re-trigger QuizBlock's onDraft effect)
const handleQuizDraft = useCallback((answers) => {
saveQuizDraft(courseId, selectedUnitId, selectedQuizId, answers);
}, [courseId, selectedUnitId, selectedQuizId, saveQuizDraft]);
const handleAssessmentDraft = useCallback((answers) => {
if (!assessment?.assessment_id) return;
saveDraft(courseId, assessment.assessment_id, answers);
}, [courseId, assessment?.assessment_id, saveDraft]);
// ── Next content item (lesson, quiz, or final assessment) ─────────────
const getNextContent = useCallback(() => {
const idx = allContent.findIndex((item) =>
@@ -509,6 +724,52 @@ const UnitList = () => {
return (
<>
<PageMeta title={pageTitle} />
{/* ── Session-guard dialog — shown when user tries to navigate away mid-quiz ── */}
<ResponsiveModal
open={!!pendingNav || blocker.state === "blocked"}
onOpenChange={(open) => !open && handleCancelNav()}
title={`Leave ${selectedAssessment ? "Assessment" : "Quiz"}?`}
description=""
footer={
<>
<Button variant="outline" onClick={handleCancelNav}>
Stay
</Button>
<Button variant="destructive" onClick={handleConfirmNav}>
Leave Anyway
</Button>
</>
}
>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
You have an ongoing <strong className="text-foreground">{selectedAssessment ? "assessment" : "quiz"}</strong> session in progress.
Leaving now will not submit your answers — your session will remain open and the administrator can see it.
</p>
{selectedAssessment && (
<p className="text-amber-600 dark:text-amber-400 font-medium">
Your assessment timer will keep counting while you're away.
</p>
)}
</div>
</ResponsiveModal>
{/* ── Task-mode banner ─────────────────────────────────────────── */}
{taskCtx?.has_task && (
<div className={`fixed top-[124px] left-0 right-0 z-30 flex items-center gap-2 text-white text-xs font-medium px-4 py-1.5 shadow-sm transition-colors ${
course?.is_completed ? 'bg-green-600' : 'bg-blue-600'
}`}>
<ListChecks className="size-3.5 shrink-0" />
<span>
{course?.is_completed
? 'Course complete — tracking finished'
: 'Task mode — reading progress is being tracked automatically'
}
</span>
</div>
)}
{/* ── Up next floating button (lesson only — quizzes/assessments have their own bottom controls) ── */}
{scrollProgress >= 100 && nextContent && !selectedQuizId && !selectedAssessment && (
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
@@ -568,6 +829,7 @@ const UnitList = () => {
onCompletionClick={handleCompletionClick}
getLessonCompleted={(l) => isProgressCompleted(l.uuid)}
getUnitCompleted={(u) => isProgressCompleted(u.uuid)}
isQuizLocked={(u) => lockedQuizUnitIds.has(u.unit_id)}
loading={courseLoading}
/>
</SheetContent>
@@ -599,7 +861,7 @@ const UnitList = () => {
{/* ── Desktop sidebar ── */}
{desktopSidebarOpen && (
<div className="hidden lg:block fixed top-[124px] bottom-0 left-0 w-80 bg-muted border-r">
<div className={`hidden lg:block fixed ${taskCtx?.has_task ? "top-[148px]" : "top-[124px]"} bottom-0 left-0 w-80 bg-muted border-r`}>
<SidebarContent
units={units}
selectedLessonId={selectedLessonId}
@@ -621,7 +883,7 @@ const UnitList = () => {
)}
{/* ── Main content ── */}
<div className={`mt-32 ${desktopSidebarOpen ? "lg:ml-80" : "lg:ml-0"} p-4 md:p-6 min-h-screen`}>
<div className={`${taskCtx?.has_task ? "mt-[8.5rem]" : "mt-32"} ${desktopSidebarOpen ? "lg:ml-80" : "lg:ml-0"} p-4 md:p-6 min-h-screen`}>
<div className="relative w-full h-full">
{selectedCompletion ? (
<CourseCompleteBlock course={course} />
@@ -638,7 +900,7 @@ const UnitList = () => {
loading={assessmentLoading}
label="Assessment"
onStart={() => startCourseAssessment(courseId, assessment.assessment_id)}
onDraft={(answers) => saveDraft(courseId, assessment.assessment_id, answers)}
onDraft={handleAssessmentDraft}
onRefreshSession={() => refreshAssessmentSession(courseId, assessment.assessment_id)}
onSubmit={async (answers, sessionId) => {
const result = await submitCourseAssessment(courseId, assessment.assessment_id, answers, sessionId);
@@ -646,19 +908,30 @@ const UnitList = () => {
return result;
}}
onRetake={() => getCourseAssessment(courseId)}
onActiveChange={setQuizActive}
/>
)
) : selectedQuizId ? (
<QuizBlock
quiz={quiz}
loading={quizLoading}
onSubmit={async (answers) => {
const result = await submitUnitQuiz(courseId, selectedUnitId, selectedQuizId, answers);
await getCourse(courseId);
return result;
}}
onRetake={() => getUnitQuiz(courseId, selectedUnitId)}
/>
lockedQuizUnitIds.has(selectedUnitId) ? (
<QuizPrerequisiteGate
previousQuizzes={previousRequiredQuizzes}
units={units}
onQuizClick={handleQuizClick}
/>
) : (
<QuizBlock
quiz={quiz}
loading={quizLoading}
onDraft={handleQuizDraft}
onSubmit={async (answers) => {
const result = await submitUnitQuiz(courseId, selectedUnitId, selectedQuizId, answers);
await getCourse(courseId);
return result;
}}
onRetake={() => getUnitQuiz(courseId, selectedUnitId)}
onActiveChange={setQuizActive}
/>
)
) : (
<LessonBlock
lesson={lesson ? { ...lesson, blocks: lesson.page?.blocks ?? [] } : null}
+4 -10
View File
@@ -12,18 +12,11 @@ import {
Tag, LockIcon, Zap, CalendarDays,
} from "lucide-react";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatPrice(price, currency = "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency,
minimumFractionDigits: 2,
}).format(price);
}
function formatDuration(days) {
if (!days) return null;
if (days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""}`;
@@ -82,6 +75,7 @@ const ViewPlan = () => {
const { id } = useParams();
const navigate = useNavigate();
const { myTier, getMyTier, plans, plansLoading, getPlans } = useClientTiers();
const { fmtCurrency } = useDateFormat();
useEffect(() => {
getMyTier();
@@ -129,7 +123,7 @@ const ViewPlan = () => {
<h2 className="text-2xl font-bold">{plan.label}</h2>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-3xl font-bold">
{formatPrice(plan.price, plan.currency)}
{fmtCurrency(plan.price, plan.currency)}
</span>
{duration && (
<span className="text-sm text-muted-foreground flex items-center gap-1">
@@ -246,7 +240,7 @@ const ViewPlan = () => {
onClick={() => navigate(`/plans/checkout?plan_id=${plan.plan_id}`)}
>
Get {style.label} Plan — {formatPrice(plan.price, plan.currency)}
Get {style.label} Plan — {fmtCurrency(plan.price, plan.currency)}
</Button>
</div>
)}
+440 -270
View File
@@ -1,25 +1,26 @@
/***********************************************************************************************************************************************************************
* File Name : ViewRequirement.jsx
* Type : Page (Client)
* Description : Task tracker — sidebar lists ALL read_* requirements.
* read_unit items expand to show their lessons as sub-items;
* clicking a lesson renders its content on the right.
* Route: /group/:groupId/view/:taskListId/task/:taskId/requirement
***********************************************************************************************************************************************************************/
import { useState, useCallback, useEffect, useRef } from 'react';
/*
* ViewRequirement.jsx
* Route: /group/:groupId/view/:taskListId/task/:taskId/requirement
*
* Sidebar: sectioned flat list — one plain heading per requirement type,
* each requirement is a clickable button. When selected, its lesson
* sub-tree renders inline below the item (no accordion, no scoping).
*
* read_course → CourseUnitLessonView (on-demand lesson fetch)
* read_unit → LessonBlock (lessons from unitLessonsMap)
* read_lesson → LessonView (standalone fetch by uuid)
*/
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
import {
House, TableOfContents, CheckCheck, Circle,
BookOpen, Layers, FileText, ArrowLeft, ChevronDown, ChevronRight,
Lock, Zap, RefreshCw,
Layers, ArrowLeft, Lock, Zap, RefreshCw, Tag,
ClipboardList, GraduationCap, CheckCircle2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Accordion, AccordionContent, AccordionItem, AccordionTrigger,
} from '@/components/ui/accordion';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
@@ -29,170 +30,266 @@ import { useTaskProgress } from '@/contexts/ClientTaskProgressContext';
import LessonBlock from '@/modules/client/components/LessonBlock';
import api from '@/utils/api.util';
import { useClientTiers } from '@/contexts/ClientTiersProvider';
import { resolveTierBadge } from '@/utils/tierBadge.util';
// ─── Type config ──────────────────────────────────────────────────────────────
const TYPE_ICON = { read_course: BookOpen, read_unit: Layers, read_lesson: FileText };
const TYPE_LABEL = { read_course: 'Courses', read_unit: 'Units', read_lesson: 'Lessons' };
// ─── Constants ────────────────────────────────────────────────────────────────
const TYPE_LABEL = {
read_course: 'Read a Course',
read_unit: 'Read a Unit',
read_lesson: 'Read a Lesson',
};
// ─── Compact tier badge ───────────────────────────────────────────────────────
const TierBadge = ({ tier }) => {
const { tierMap } = useClientTiers();
if (!tier) return null;
const { label, cls } = resolveTierBadge(tier, tierMap);
return (
<Badge className={`${cls} text-[10px] px-1.5 py-0 h-[18px] leading-none shrink-0`}>
{label}
</Badge>
);
};
// ─── Sidebar ──────────────────────────────────────────────────────────────────
//
// selection = { reqId, lessonUuid? }
// • read_course / read_lesson: lessonUuid is undefined
// • read_unit: lessonUuid identifies which sub-lesson is open
//
const SidebarContent = ({
requirements,
selection,
onSelectReq, // (req) → select a read_course / read_lesson requirement
onSelectLesson, // (req, lesson) → select a lesson within a read_unit
onSelectReq,
onSelectLesson,
isCompleted,
unitLessonsMap, // { [reqId]: { meta, lessons } }
unitLoadingMap, // { [reqId]: boolean }
unitLessonsMap,
unitLoadingMap,
courseUnitsMap,
courseLoadingMap,
referenceMetaMap,
onNavigateToCourse,
}) => {
const groups = ['read_course', 'read_unit', 'read_lesson']
.map((type) => ({ type, items: requirements.filter((r) => r.type === type) }))
.filter((g) => g.items.length > 0);
return (
<ScrollArea className="h-full p-3 md:p-4">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-2 px-2">
Requirements
</p>
<Accordion
type="multiple"
defaultValue={groups.map((g) => g.type)}
className="space-y-1"
>
<ScrollArea className="h-full">
<div className="p-4 space-y-6">
<div className="flex items-center justify-between gap-2 pr-10 lg:pr-0">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground shrink-0">
Requirements
</p>
{groups.length === 1 && (
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/60 bg-muted px-2 py-0.5 rounded-full shrink-0">
{TYPE_LABEL[groups[0].type]}
</span>
)}
</div>
{groups.map(({ type, items }) => (
<AccordionItem key={type} value={type} className="border-none">
<AccordionTrigger className="px-3 py-2 text-sm font-semibold rounded-lg hover:bg-muted-foreground/10 hover:no-underline">
{TYPE_LABEL[type]}
</AccordionTrigger>
<AccordionContent className="pb-1">
<ul className="space-y-0.5">
{items.map((req) => {
const Icon = TYPE_ICON[req.type] ?? FileText;
const isActive = selection?.reqId === req.requirement_id;
<div key={type} className="space-y-1">
{/* Section heading — hidden when only one type is visible (entry-scoped) */}
{groups.length > 1 && (
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground px-1 mb-2">
{TYPE_LABEL[type]}
</p>
)}
if (req.type === 'read_unit') {
// ── Unit: show lessons as sub-items ──────────────
const entry = unitLessonsMap[req.requirement_id];
{items.map((req) => {
const isSelected = selection?.reqId === req.requirement_id;
const done = isCompleted(req.requirement_id, req.reference_id);
const meta = referenceMetaMap[req.requirement_id];
return (
<div key={req.requirement_id}>
{/* Requirement button */}
<button
onClick={() => onSelectReq(req)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors text-left ${
isSelected
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
{done
? <CheckCheck className="size-3.5 text-green-500 shrink-0" />
: <Circle className="size-3.5 shrink-0 opacity-40" />
}
<span className="truncate flex-1 font-medium min-w-0">
{req.reference_label ?? req.type}
</span>
<TierBadge tier={meta?.subscription} />
</button>
{/* Breadcrumb — always visible below the button */}
{type === 'read_unit' && meta?.courseTitle && (
<p className="pl-9 text-[11px] text-muted-foreground truncate -mt-0.5 mb-1">
from <span className="font-medium">{meta.courseTitle}</span>
</p>
)}
{type === 'read_lesson' && (meta?.unitTitle || meta?.courseTitle) && (
<p className="pl-9 text-[11px] text-muted-foreground truncate -mt-0.5 mb-1">
{[meta.unitTitle, meta.courseTitle].filter(Boolean).join(' › ')}
</p>
)}
{/* Sub-tree — only for the selected item */}
{isSelected && type === 'read_unit' && (() => {
const lessons = unitLessonsMap[req.requirement_id]?.lessons ?? [];
const loading = unitLoadingMap[req.requirement_id];
const lessons = entry?.lessons ?? [];
const done = isCompleted(req.requirement_id, req.reference_id);
return (
<li key={req.requirement_id}>
{/* Unit header row (non-clickable — navigates via lessons) */}
<div className="flex items-center gap-2 pl-4 pr-3 py-1.5 text-sm rounded-md text-muted-foreground">
{done
? <CheckCheck className="size-3.5 text-green-500 shrink-0" />
: <Circle className="size-3.5 shrink-0 opacity-40" />
}
<Icon className="size-3.5 shrink-0 opacity-60" />
<span className="truncate font-medium text-foreground">
{req.reference_label ?? 'Unit'}
</span>
</div>
{/* Lessons sub-list */}
<div className="pl-5 mt-1 space-y-0.5 border-l ml-4 mb-2">
{loading && (
<div className="pl-10 py-1 space-y-1">
<div className="py-1 space-y-1.5">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-24" />
</div>
)}
{lessons.map((lesson, i) => {
const lessonActive =
isActive && selection?.lessonUuid === lesson.uuid;
const active = selection?.lessonUuid === lesson.uuid;
return (
<li
<button
key={lesson.uuid}
onClick={() => onSelectLesson(req, lesson)}
className={`flex items-center gap-2 pl-10 pr-3 py-1.5 text-sm rounded-md cursor-pointer transition-colors ${
lessonActive
? 'bg-muted-foreground/15 font-medium text-foreground'
: 'text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground'
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors ${
active
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<span className="text-xs tabular-nums w-4 shrink-0 opacity-50">
{i + 1}
</span>
<span className="text-xs tabular-nums w-4 shrink-0 opacity-40">{i + 1}</span>
<span className="truncate">{lesson.title}</span>
</li>
</button>
);
})}
</li>
</div>
);
}
})()}
// ── read_course / read_lesson ─────────────────────────
const done = isCompleted(req.requirement_id, req.reference_id);
return (
<li
key={req.requirement_id}
onClick={() => onSelectReq(req)}
className={`flex items-center gap-2 pl-6 pr-3 py-1.5 text-sm rounded-md cursor-pointer transition-colors ${
isActive
? 'bg-muted-foreground/10 text-foreground font-medium'
: 'text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground'
}`}
>
{done
? <CheckCheck className="size-3.5 text-green-500 shrink-0" />
: <Circle className="size-3.5 shrink-0 opacity-40" />
}
<Icon className="size-3.5 shrink-0 opacity-60" />
<span className="truncate">{req.reference_label ?? req.type}</span>
</li>
);
})}
</ul>
</AccordionContent>
</AccordionItem>
{isSelected && type === 'read_course' && (() => {
const courseData = courseUnitsMap[req.requirement_id] ?? {};
const units = courseData.units ?? [];
const assessment = courseData.assessment ?? null;
const courseId = courseData.course_id;
const loading = courseLoadingMap[req.requirement_id];
return (
<div className="pl-5 mt-1 space-y-3 border-l ml-4 mb-2">
{loading && (
<div className="py-1 space-y-1.5">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-24" />
</div>
)}
{units.map((unit) => (
<div key={unit.unit_id} className="space-y-0.5">
<p className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-semibold text-muted-foreground uppercase tracking-wide truncate">
<Layers className="size-3 shrink-0 opacity-50" />
{unit.title}
</p>
{(unit.lessons ?? []).map((lesson, lIdx) => {
const active = selection?.lessonUuid === lesson.uuid;
return (
<button
key={lesson.uuid}
onClick={() => onSelectLesson(req, lesson)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors ${
active
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<span className="text-xs tabular-nums w-4 shrink-0 opacity-40">{lIdx + 1}</span>
<span className="truncate">{lesson.title}</span>
</button>
);
})}
{unit.quiz && (
<button
onClick={() => onNavigateToCourse(courseId, { quizUnitId: String(unit.unit_id) })}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ClipboardList className="size-3.5 shrink-0 opacity-50" />
<span className="truncate flex-1">{unit.quiz.title || 'Quiz'}</span>
{unit.quiz.has_passed
? <CheckCircle2 className="size-3 text-green-500 shrink-0" />
: <Circle className="size-3 shrink-0 opacity-30" />
}
</button>
)}
</div>
))}
{assessment && (
<button
onClick={() => onNavigateToCourse(courseId, { seekAssessment: true })}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors text-muted-foreground hover:bg-muted hover:text-foreground border-t pt-2 mt-1"
>
<GraduationCap className="size-3.5 shrink-0 opacity-50" />
<span className="truncate flex-1">{assessment.title || 'Final Assessment'}</span>
{assessment.has_passed
? <CheckCircle2 className="size-3 text-green-500 shrink-0" />
: <Circle className="size-3 shrink-0 opacity-30" />
}
</button>
)}
</div>
);
})()}
</div>
);
})}
</div>
))}
</Accordion>
<ScrollBar orientation="vertical" />
</div>
</ScrollArea>
);
};
// ─── Content: course view ─────────────────────────────────────────────────────
const CourseView = ({ req }) => {
const [info, setInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [locked, setLocked] = useState(false);
useEffect(() => {
if (!req.reference_id) { setLoading(false); return; }
api.get(`/client/courses/uuid/${req.reference_id}`)
.then((r) => setInfo(r.data?.data ?? null))
.catch((err) => {
if (err?.response?.status === 403) setLocked(true);
})
.finally(() => setLoading(false));
}, [req.reference_id]);
if (loading) return <ContentSkeleton />;
if (locked) return <LockedContent />;
// ─── Course overview (before first lesson selected) ───────────────────────────
const CourseOverview = ({ meta }) => {
const { tierMap } = useClientTiers();
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 flex-wrap">
{info?.subscription && <Badge variant="secondary" className="text-xs capitalize">{info.subscription}</Badge>}
{info?.level && <Badge variant="outline" className="text-xs capitalize">{info.level}</Badge>}
</div>
<h1 className="text-2xl font-bold mt-1">{req.reference_label ?? 'Course'}</h1>
</div>
{info?.description && (
<p className="text-sm text-muted-foreground leading-relaxed">{info.description}</p>
)}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 flex-wrap">
{meta?.subscription && (() => {
const { rank, label, cls } = resolveTierBadge(meta.subscription, tierMap);
return (
<Badge className={`${cls} capitalize`}>
{rank > 0 ? <Lock className="size-3 mr-1" /> : <Tag className="size-3 mr-1" />}
{label}
</Badge>
);
})()}
{meta?.level && <Badge variant="outline" className="text-xs capitalize">{meta.level}</Badge>}
</div>
<h1 className="text-2xl font-bold">{meta?.title ?? 'Course'}</h1>
{meta?.description && <p className="text-sm text-muted-foreground leading-relaxed">{meta.description}</p>}
<p className="text-sm text-blue-500 dark:text-blue-400 mt-2">Select a lesson from the sidebar to begin reading.</p>
</div>
);
};
// ─── Content: standalone lesson view (for read_lesson requirements) ───────────
const LessonView = ({ req }) => {
// ─── On-demand lesson fetch for read_course ───────────────────────────────────
const CourseUnitLessonView = ({ lessonUuid }) => {
const [lesson, setLesson] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!lessonUuid) return;
setLoading(true);
setLesson(null);
api.get(`/client/courses/lesson/uuid/${lessonUuid}`)
.then((r) => {
const d = r.data?.data;
if (d) setLesson({ ...d, blocks: d.blocks ?? [] });
})
.catch(() => {})
.finally(() => setLoading(false));
}, [lessonUuid]);
if (loading) return <ContentSkeleton />;
return <LessonBlock lesson={lesson} loading={false} />;
};
// ─── Standalone lesson view (read_lesson) ─────────────────────────────────────
const LessonView = ({ req, onMeta }) => {
const [lesson, setLesson] = useState(null);
const [loading, setLoading] = useState(true);
const [locked, setLocked] = useState(false);
@@ -202,11 +299,16 @@ const LessonView = ({ req }) => {
api.get(`/client/courses/lesson/uuid/${req.reference_id}`)
.then((r) => {
const d = r.data?.data;
if (d) setLesson({ ...d, blocks: d.blocks ?? [] });
})
.catch((err) => {
if (err?.response?.status === 403) setLocked(true);
if (d) {
setLesson({ ...d, blocks: d.blocks ?? [] });
onMeta?.(req.requirement_id, {
subscription: d.unit?.course?.subscription,
courseTitle: d.unit?.course?.title,
unitTitle: d.unit?.title,
});
}
})
.catch((err) => { if (err?.response?.status === 403) setLocked(true); })
.finally(() => setLoading(false));
}, [req.reference_id]);
@@ -215,7 +317,7 @@ const LessonView = ({ req }) => {
return <LessonBlock lesson={lesson} loading={false} />;
};
// ─── Loading skeleton ─────────────────────────────────────────────────────────
// ─── Skeletons / locked ───────────────────────────────────────────────────────
const ContentSkeleton = () => (
<div className="flex flex-col gap-3">
<Skeleton className="h-5 w-32" />
@@ -225,7 +327,6 @@ const ContentSkeleton = () => (
</div>
);
// ─── Locked content placeholder ───────────────────────────────────────────────
const LockedContent = () => {
const navigate = useNavigate();
return (
@@ -236,16 +337,14 @@ const LockedContent = () => {
<div className="space-y-2 max-w-sm">
<h2 className="text-lg font-semibold">Premium / Exclusive Content</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
To take this activity, we advise you to subscribe to one of our available tier plans and unlock access to this content.
Subscribe to one of our available tier plans to unlock access to 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>
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
</div>
</div>
);
@@ -258,18 +357,18 @@ const ViewRequirement = () => {
const location = useLocation();
const { task, taskList, loading, fetchTask, fetchTaskList } = useTask();
const {
fetchProgress, isCompleted, updateLessonProgress, loading: progressLoading,
} = useTaskProgress();
const { fetchProgress, isCompleted, updateLessonProgress, loading: progressLoading } = useTaskProgress();
// selection = { reqId, lessonUuid? }
const [selection, setSelection] = useState(null);
const [unitLessonsMap, setUnitLessonsMap] = useState({});
const [unitLoadingMap, setUnitLoadingMap] = useState({});
const [lockedReqs, setLockedReqs] = useState(new Set());
const [sidebarOpen, setSidebarOpen] = useState(false);
const [desktopOpen, setDesktopOpen] = useState(true);
const [scrollPct, setScrollPct] = useState(0);
const [selection, setSelection] = useState(null);
const [unitLessonsMap, setUnitLessonsMap] = useState({});
const [unitLoadingMap, setUnitLoadingMap] = useState({});
const [courseUnitsMap, setCourseUnitsMap] = useState({});
const [courseLoadingMap, setCourseLoadingMap] = useState({});
const [referenceMetaMap, setReferenceMetaMap] = useState({});
const [lockedReqs, setLockedReqs] = useState(new Set());
const [sidebarOpen, setSidebarOpen] = useState(false);
const [desktopOpen, setDesktopOpen] = useState(true);
const [scrollPct, setScrollPct] = useState(0);
const initialised = useRef(false);
const lastAutoMarkRef = useRef(null);
@@ -280,12 +379,25 @@ const ViewRequirement = () => {
fetchProgress(groupId, taskListId, taskId);
}, [groupId, taskListId, taskId]);
// ── Filtered requirements ─────────────────────────────────────────────────
// ── read_* requirements only ─────────────────────────────────────────────
const requirements = (task?.requirements ?? []).filter((r) =>
['read_course', 'read_unit', 'read_lesson'].includes(r.type)
);
// ── Fetch lessons for every read_unit requirement ─────────────────────────
// ── Scope sidebar to the type that was clicked in ViewTaskDetails ─────────
const entryType = useMemo(() => {
const s = location.state ?? {};
if (s.course) return 'read_course';
if (s.unit) return 'read_unit';
if (s.lesson) return 'read_lesson';
return null;
}, [location.state]);
const sidebarRequirements = entryType
? requirements.filter((r) => r.type === entryType)
: requirements;
// ── Fetch lessons for read_unit requirements ──────────────────────────────
useEffect(() => {
requirements.forEach((req) => {
if (req.type !== 'read_unit') return;
@@ -293,92 +405,167 @@ const ViewRequirement = () => {
setUnitLoadingMap((p) => ({ ...p, [req.requirement_id]: true }));
api.get(`/client/courses/unit/uuid/${req.reference_id}/lessons`)
.then((r) => {
const data = r.data?.data ?? null;
const lessons = data?.lessons ?? [];
setUnitLessonsMap((p) => ({ ...p, [req.requirement_id]: { meta: data, lessons } }));
const data = r.data?.data ?? null;
setUnitLessonsMap((p) => ({ ...p, [req.requirement_id]: { meta: data, lessons: data?.lessons ?? [] } }));
})
.catch((err) => {
if (err?.response?.status === 403) {
setLockedReqs((prev) => new Set(prev).add(req.requirement_id));
}
if (err?.response?.status === 403)
setLockedReqs((p) => new Set(p).add(req.requirement_id));
})
.finally(() => setUnitLoadingMap((p) => ({ ...p, [req.requirement_id]: false })));
});
}, [requirements.length]);
// ── Fetch unit + lesson tree for read_course requirements ─────────────────
useEffect(() => {
requirements.forEach(async (req) => {
if (req.type !== 'read_course') return;
if (courseUnitsMap[req.requirement_id] || courseLoadingMap[req.requirement_id]) return;
setCourseLoadingMap((p) => ({ ...p, [req.requirement_id]: true }));
try {
const uuidRes = await api.get(`/client/courses/uuid/${req.reference_id}`);
const meta = uuidRes.data?.data;
if (!meta?.course_id) return;
const fullRes = await api.get(`/client/courses/${meta.course_id}`);
const full = fullRes.data?.data;
setCourseUnitsMap((p) => ({
...p,
[req.requirement_id]: {
meta: { ...meta, description: full?.description ?? meta.description },
units: full?.units ?? [],
assessment: full?.assessment ?? null,
course_id: meta.course_id,
},
}));
setReferenceMetaMap((p) => ({ ...p, [req.requirement_id]: { subscription: meta.subscription } }));
} catch (err) {
if (err?.response?.status === 403)
setLockedReqs((p) => new Set(p).add(req.requirement_id));
} finally {
setCourseLoadingMap((p) => ({ ...p, [req.requirement_id]: false }));
}
});
}, [requirements.length]);
// ── Tier meta for read_unit from unitLessonsMap ───────────────────────────
useEffect(() => {
requirements.forEach((req) => {
if (req.type !== 'read_unit') return;
const data = unitLessonsMap[req.requirement_id];
if (!data?.meta?.course || referenceMetaMap[req.requirement_id]) return;
setReferenceMetaMap((p) => ({
...p,
[req.requirement_id]: {
subscription: data.meta.course.subscription,
courseTitle: data.meta.course.title,
},
}));
});
}, [unitLessonsMap]);
// ── Tier meta for read_lesson (separate fetch) ────────────────────────────
useEffect(() => {
requirements.forEach(async (req) => {
if (req.type !== 'read_lesson' || referenceMetaMap[req.requirement_id]) return;
try {
const res = await api.get(`/client/courses/lesson/uuid/${req.reference_id}`);
const d = res.data?.data;
if (d) setReferenceMetaMap((p) => ({
...p,
[req.requirement_id]: {
subscription: d.unit?.course?.subscription,
courseTitle: d.unit?.course?.title,
unitTitle: d.unit?.title,
},
}));
} catch { /* silently fail */ }
});
}, [requirements.length]);
// ── Auto-select from router state or first item ───────────────────────────
useEffect(() => {
if (initialised.current || !requirements.length) return;
const state = location.state ?? {};
const unitId = state.unit?.id;
const lesId = state.lesson?.id;
const state = location.state ?? {};
if (unitId) {
const req = requirements.find((r) => r.requirement_id === unitId);
if (req) {
// Select unit; lesson will be auto-picked once lessons are fetched
setSelection({ reqId: req.requirement_id, lessonUuid: null });
initialised.current = true;
return;
}
}
if (lesId) {
const req = requirements.find((r) => r.requirement_id === lesId);
if (req) {
setSelection({ reqId: req.requirement_id });
initialised.current = true;
return;
}
}
// Default: first requirement
const first = requirements[0];
if (first.type === 'read_unit') {
setSelection({ reqId: first.requirement_id, lessonUuid: null });
} else {
setSelection({ reqId: first.requirement_id });
const trySelect = (key, type) => {
if (!state[key]?.id) return false;
const req = requirements.find((r) => r.requirement_id === state[key].id);
if (!req) return false;
const needsLesson = type === 'read_unit' || type === 'read_course';
setSelection({ reqId: req.requirement_id, ...(needsLesson ? { lessonUuid: null } : {}) });
return true;
};
if (!trySelect('course', 'read_course') && !trySelect('unit', 'read_unit') && !trySelect('lesson', 'read_lesson')) {
const first = requirements[0];
const needsLesson = first.type === 'read_unit' || first.type === 'read_course';
setSelection({ reqId: first.requirement_id, ...(needsLesson ? { lessonUuid: null } : {}) });
}
initialised.current = true;
}, [requirements, location.state]);
// ── Auto-pick first lesson once unit lessons are loaded ───────────────────
// ── Auto-pick first lesson once unit/course lessons load ─────────────────
useEffect(() => {
if (!selection) return;
if (!selection || selection.lessonUuid !== null) return;
const req = requirements.find((r) => r.requirement_id === selection.reqId);
if (req?.type !== 'read_unit') return;
if (selection.lessonUuid !== null) return; // already have one (null = "not picked yet")
const lessons = unitLessonsMap[selection.reqId]?.lessons ?? [];
if (lessons.length) {
setSelection((p) => ({ ...p, lessonUuid: lessons[0].uuid }));
if (req?.type === 'read_unit') {
const lessons = unitLessonsMap[req.requirement_id]?.lessons ?? [];
if (lessons.length) setSelection((p) => ({ ...p, lessonUuid: lessons[0].uuid }));
}
}, [unitLessonsMap, selection?.reqId]);
if (req?.type === 'read_course') {
const units = courseUnitsMap[req.requirement_id]?.units ?? [];
const first = units.flatMap((u) => u.lessons ?? [])[0];
if (first) setSelection((p) => ({ ...p, lessonUuid: first.uuid }));
}
}, [unitLessonsMap, courseUnitsMap, selection?.reqId]);
// ── Derive selected objects ───────────────────────────────────────────────
// ── Derived objects ───────────────────────────────────────────────────────
const selectedReq = requirements.find((r) => r.requirement_id === selection?.reqId) ?? null;
const selectedLesson = (() => {
if (!selectedReq || selectedReq.type !== 'read_unit') return null;
const lessons = unitLessonsMap[selectedReq.requirement_id]?.lessons ?? [];
return lessons.find((l) => l.uuid === selection?.lessonUuid) ?? null;
if (!selectedReq || !selection?.lessonUuid) return null;
if (selectedReq.type === 'read_unit') {
return (unitLessonsMap[selectedReq.requirement_id]?.lessons ?? [])
.find((l) => l.uuid === selection.lessonUuid) ?? null;
}
if (selectedReq.type === 'read_course') {
const units = courseUnitsMap[selectedReq.requirement_id]?.units ?? [];
return units.flatMap((u) => u.lessons ?? []).find((l) => l.uuid === selection.lessonUuid) ?? null;
}
return null;
})();
// ── Next lesson (cross-unit for read_course) ──────────────────────────────
const nextLesson = (() => {
if (!selectedReq) return null;
if (selectedReq.type === 'read_unit') {
const lessons = unitLessonsMap[selectedReq.requirement_id]?.lessons ?? [];
const idx = lessons.findIndex((l) => l.uuid === selection?.lessonUuid);
return idx !== -1 && idx < lessons.length - 1 ? lessons[idx + 1] : null;
}
if (selectedReq.type === 'read_course') {
const flat = (courseUnitsMap[selectedReq.requirement_id]?.units ?? []).flatMap((u) => u.lessons ?? []);
const idx = flat.findIndex((l) => l.uuid === selection?.lessonUuid);
return idx !== -1 && idx < flat.length - 1 ? flat[idx + 1] : null;
}
return null;
})();
// ── Scroll tracking ───────────────────────────────────────────────────────
useEffect(() => {
window.scrollTo(0, 0);
// Re-evaluate immediately — short content may already be at 100%
const h = document.documentElement.scrollHeight - window.innerHeight;
setScrollPct(h <= 40 ? 100 : 0);
}, [selection]);
useEffect(() => {
const onScroll = () => {
const scrollH = document.documentElement.scrollHeight;
const viewH = window.innerHeight;
const scrollY = window.scrollY;
const h = scrollH - viewH;
if (h <= 0) { setScrollPct(100); return; }
// Within 40px of bottom counts as 100% (handles discrete mouse-wheel steps)
if (h - scrollY <= 40) { setScrollPct(100); return; }
setScrollPct(Math.min(99, Math.round((scrollY / h) * 100)));
const h = document.documentElement.scrollHeight - window.innerHeight;
const y = window.scrollY;
if (h <= 0 || h - y <= 40) { setScrollPct(100); return; }
setScrollPct(Math.min(99, Math.round((y / h) * 100)));
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
@@ -396,9 +583,10 @@ const ViewRequirement = () => {
{ label: 'Requirements' },
];
// ── Selection handlers ────────────────────────────────────────────────────
// ── Handlers ─────────────────────────────────────────────────────────────
const handleSelectReq = useCallback((req) => {
setSelection({ reqId: req.requirement_id });
const needsLesson = req.type === 'read_unit' || req.type === 'read_course';
setSelection({ reqId: req.requirement_id, ...(needsLesson ? { lessonUuid: null } : {}) });
setSidebarOpen(false);
}, []);
@@ -407,58 +595,53 @@ const ViewRequirement = () => {
setSidebarOpen(false);
}, []);
// ── Mark done (marks the requirement, not individual lessons) ─────────────
const handleMarkDone = useCallback(async () => {
if (!selectedReq) return;
const completed = !isCompleted(selectedReq.requirement_id, selectedReq.reference_id);
await updateLessonProgress(groupId, taskListId, taskId, selectedReq.requirement_id, {
reference_id: selectedReq.reference_id,
reference_id: selectedReq.reference_id,
completed,
siblingLessons: [],
});
}, [selectedReq, groupId, taskListId, taskId, isCompleted, updateLessonProgress]);
const selectedDone = selectedReq
? isCompleted(selectedReq.requirement_id, selectedReq.reference_id)
: false;
// ── Derive next lesson within same unit (for scroll-to-next) ─────────────
const nextLesson = (() => {
if (!selectedReq || selectedReq.type !== 'read_unit') return null;
const lessons = unitLessonsMap[selectedReq.requirement_id]?.lessons ?? [];
const idx = lessons.findIndex((l) => l.uuid === selection?.lessonUuid);
return idx !== -1 && idx < lessons.length - 1 ? lessons[idx + 1] : null;
})();
// ── Mark-done gate: last lesson (or non-unit) AND scrolled to 100% ────────
const isLastContent = selectedReq?.type !== 'read_unit' || !nextLesson;
const selectedDone = selectedReq ? isCompleted(selectedReq.requirement_id, selectedReq.reference_id) : false;
const isLastContent = !nextLesson;
const canMarkDone = scrollPct >= 100 && isLastContent;
// ── Auto turn-in: fires once per requirement when user reaches the end ────────
// ── Auto turn-in ──────────────────────────────────────────────────────────
useEffect(() => {
if (!canMarkDone || selectedDone || progressLoading || !selectedReq) return;
if (lockedReqs.has(selectedReq.requirement_id)) return;
// Deduplicate so scrolling back up and down doesn't re-fire
const key = selectedReq.requirement_id + (selection?.lessonUuid ?? '');
if (lastAutoMarkRef.current === key) return;
lastAutoMarkRef.current = key;
handleMarkDone();
}, [canMarkDone, selectedDone]); // eslint-disable-line react-hooks/exhaustive-deps
const handleNavigateToCourse = useCallback((courseId, opts) => {
navigate(`/course/${courseId}/unit`, { state: opts });
}, [navigate]);
const sidebarProps = {
requirements,
requirements: sidebarRequirements,
selection,
onSelectReq: handleSelectReq,
onSelectLesson: handleSelectLesson,
onSelectReq: handleSelectReq,
onSelectLesson: handleSelectLesson,
isCompleted,
unitLessonsMap,
unitLoadingMap,
courseUnitsMap,
courseLoadingMap,
referenceMetaMap,
onNavigateToCourse: handleNavigateToCourse,
};
return (
<>
<PageMeta title={task ? `${task.name} – Requirements - STARR` : undefined} />
{/* ── Floating "up next" (within unit lessons) ─────────────────── */}
{/* ── Floating "up next" ────────────────────────────────────────── */}
{scrollPct >= 100 && nextLesson && (
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div
@@ -531,13 +714,13 @@ const ViewRequirement = () => {
{/* ── Desktop sidebar ───────────────────────────────────────────── */}
{desktopOpen && (
<div className="hidden lg:block fixed top-[124px] bottom-0 left-0 w-80 bg-muted border-r">
<div className="hidden lg:block fixed top-[124px] bottom-0 left-0 w-96 bg-muted/60 border-r">
<SidebarContent {...sidebarProps} />
</div>
)}
{/* ── Main content ──────────────────────────────────────────────── */}
<div className={`mt-32 ${desktopOpen ? 'lg:ml-80' : 'lg:ml-0'} p-4 md:p-6 min-h-screen`}>
<div className={`mt-32 ${desktopOpen ? 'lg:ml-96' : 'lg:ml-0'} p-4 md:p-6 min-h-screen`}>
<div className="relative w-full h-full max-w-3xl mx-auto">
{loading ? (
<ContentSkeleton />
@@ -548,48 +731,35 @@ const ViewRequirement = () => {
) : (
<div className="flex flex-col gap-6">
{/* Content per type */}
{selectedReq.type === 'read_course' && (
<CourseView req={selectedReq} />
lockedReqs.has(selectedReq.requirement_id) ? <LockedContent /> :
selection?.lessonUuid ? <CourseUnitLessonView lessonUuid={selection.lessonUuid} /> :
courseLoadingMap[selectedReq.requirement_id] ? <ContentSkeleton /> :
<CourseOverview meta={courseUnitsMap[selectedReq.requirement_id]?.meta} />
)}
{selectedReq.type === 'read_unit' && (
lockedReqs.has(selectedReq.requirement_id)
? <LockedContent />
: selectedLesson
? <LessonBlock lesson={selectedLesson} loading={false} />
: <ContentSkeleton />
lockedReqs.has(selectedReq.requirement_id) ? <LockedContent /> :
selectedLesson ? <LessonBlock lesson={selectedLesson} loading={false} /> :
<ContentSkeleton />
)}
{selectedReq.type === 'read_lesson' && (
<LessonView req={selectedReq} />
<LessonView
req={selectedReq}
onMeta={(reqId, meta) =>
setReferenceMetaMap((p) => ({ ...p, [reqId]: meta }))
}
/>
)}
{/* Turn-in footer — hidden for locked requirements */}
{!lockedReqs.has(selectedReq.requirement_id) && (
<div className="flex items-center justify-between pt-4 border-t">
{selectedDone ? (
<>
<span className="text-sm text-muted-foreground">
You have completed this requirement.
</span>
<Button
onClick={handleMarkDone}
disabled={progressLoading}
variant="outline"
>
<CheckCheck className="size-4" />
Mark as not done
</Button>
</>
) : nextLesson ? (
<p className="text-sm text-muted-foreground">
Continue reading all lessons to complete this requirement.
</p>
{/* Turn-in footer — only shown while not yet completed */}
{!lockedReqs.has(selectedReq.requirement_id) && !selectedDone && (
<div className="flex items-center pt-4 border-t">
{nextLesson ? (
<p className="text-sm text-muted-foreground">Continue reading all lessons to complete this requirement.</p>
) : !canMarkDone ? (
<p className="text-sm text-muted-foreground">
Scroll to the end to complete this requirement.
</p>
<p className="text-sm text-muted-foreground">Scroll to the end to complete this requirement.</p>
) : (
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
<RefreshCw className="size-3.5 animate-spin" /> Turning in…
+12 -4
View File
@@ -247,13 +247,14 @@ const ViewTask = () => {
fetchProgress,
isVisited, isCompleted,
visitLink,
unvisitLink,
resetProgress,
} = useTaskProgress();
const { group, fetchGroup } = useGroup();
const [taskModal, setTaskModal] = useState(false);
const [uploadState, setUploadState] = useState({ files: [], isUploading: false, isOverLimit: false });
const [uploadState, setUploadState] = useState({ files: [], isUploading: false });
const [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false);
const [previewFile, setPreviewFile] = useState(null);
@@ -285,9 +286,13 @@ const ViewTask = () => {
await visitLink(groupId, taskListId, taskId, requirementId);
}, [groupId, taskListId, taskId, visitLink]);
const handleUnvisitLink = useCallback(async (requirementId) => {
await unvisitLink(groupId, taskListId, taskId, requirementId);
}, [groupId, taskListId, taskId, unvisitLink]);
// ── Submit handler ────────────────────────────────────────────────────────
const handleSubmit = async () => {
if (uploadState.isUploading || uploadState.isOverLimit) return;
if (uploadState.isUploading) return;
if (!uploadState.files.length) return;
setSubmitting(true);
@@ -329,7 +334,7 @@ const ViewTask = () => {
setTaskModal(false);
setNote('');
setUploadState({ files: [], isUploading: false, isOverLimit: false });
setUploadState({ files: [], isUploading: false });
} catch (err) {
toast.error('Failed to submit. Please try again.');
} finally {
@@ -371,7 +376,6 @@ const ViewTask = () => {
disabled={
submitting ||
uploadState.isUploading ||
uploadState.isOverLimit ||
uploadState.files.length === 0
}
>
@@ -463,6 +467,7 @@ const ViewTask = () => {
)
}
onVisit={handleVisitLink}
onUnvisit={handleUnvisitLink}
/>
)}
@@ -476,6 +481,9 @@ const ViewTask = () => {
description: r.description ?? '',
completed: isCompleted(r.requirement_id, r.reference_id),
}))}
groupId={groupId}
taskListId={taskListId}
taskId={taskId}
/>
)}