mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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…
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user