Files
starr-philproperties/src/modules/client/components/blocks/ReadUnit.jsx
T
2026-08-15 11:09:30 +08:00

317 lines
19 KiB
React

import { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge";
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 }) {
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 }) => {
const navigate = useNavigate();
const [selected, setSelected] = useState(null);
const [details, setDetails] = useState({});
const [locked, setLocked] = useState({});
const [lockedInfo, setLockedInfo] = useState({});
const [unavailable, setUnavailable] = useState({});
const [fetching, setFetching] = useState({});
useEffect(() => {
units.forEach(async (unit) => {
if (!unit.reference_id) return;
setFetching((prev) => ({ ...prev, [unit.reference_id]: true }));
try {
const res = await api.get(`/client/courses/unit/uuid/${unit.reference_id}`);
const d = res.data?.data;
if (d) setDetails((prev) => ({ ...prev, [unit.reference_id]: d }));
} catch (err) {
if (err?.response?.status === 403) {
setLocked((prev) => ({ ...prev, [unit.reference_id]: true }));
const course = err.response?.data?.course;
if (course) setLockedInfo((prev) => ({ ...prev, [unit.reference_id]: course }));
} else {
setUnavailable((prev) => ({ ...prev, [unit.reference_id]: true }));
}
} finally {
setFetching((prev) => ({ ...prev, [unit.reference_id]: false }));
}
});
}, []);
const getProgress = (unit) => unit.completed ? 100 : (unit.progress ?? 0);
if (!units.length) {
return (
<div className="border rounded-lg bg-card overflow-hidden">
<div className="px-6 py-4 border-b flex items-center gap-2">
<Layers className="size-4 text-muted-foreground" />
<h2 className="font-semibold text-sm">{title}</h2>
<Badge variant="secondary" className="ml-auto">0</Badge>
</div>
<div className="p-4 text-center text-sm text-muted-foreground">No units available.</div>
</div>
);
}
const hasLocked = Object.values(locked).some(Boolean);
return (
<>
<div className="border rounded-lg bg-card overflow-hidden">
<div className="px-6 py-4 border-b flex items-center gap-2">
<Layers className="size-4 text-muted-foreground" />
<h2 className="font-semibold text-sm">{title}</h2>
<Badge variant="secondary" className="ml-auto">{units.length}</Badge>
</div>
{hasLocked && (
<div className="flex items-start gap-3 px-5 py-3.5 border-b bg-amber-50 dark:bg-amber-950/30 text-amber-800 dark:text-amber-300">
<Info className="size-4 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-snug">Subscription Required</p>
<p className="text-xs mt-0.5 text-amber-700 dark:text-amber-400 leading-relaxed">
To complete this activity, subscribe to one of our available tier plans.
</p>
</div>
<Button size="sm" variant="outline" className="shrink-0 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40" onClick={() => navigate('/subscriptions')}>
<Zap className="size-3.5" /> View Plans
</Button>
</div>
)}
<ScrollArea className="w-full bg-muted overflow-hidden">
<div className="flex gap-4 p-4">
{units.map((unit) => {
const info = details[unit.reference_id];
const courseInfo = lockedInfo[unit.reference_id];
const progress = getProgress(unit);
const isLocked = locked[unit.reference_id];
const isUnavailable = unavailable[unit.reference_id];
const isFetching = fetching[unit.reference_id];
// ── Locked card ───────────────────────────────────
// Skipped when already completed — a completion recorded
// while the unit was accessible still counts even if it
// re-locks later (reading-progress rows are never revoked
// on a tier change), so it must keep showing as Completed.
if (isLocked && !unit.completed) {
return (
<div
key={unit.id}
onClick={() => navigate('/subscriptions')}
className="bg-card rounded-2xl border border-dashed p-4 flex flex-col gap-3 hover:bg-muted/60 hover:shadow-sm transition-all cursor-pointer group w-96 shrink-0 opacity-80"
>
<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">
<TierBadge tier={courseInfo?.subscription} />
</div>
</div>
{courseInfo?.title && (
<p className="text-xs text-muted-foreground font-medium truncate -mt-1">
from <span className="text-foreground/70">{courseInfo.title}</span>
</p>
)}
<div>
<h1 className="text-base font-semibold leading-snug line-clamp-2 text-muted-foreground">
{unit.title}
</h1>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
Subscribe to <span className="font-medium">{courseInfo?.title ?? 'this course'}</span> to unlock this unit.
</p>
</div>
<div className="mt-auto">
<Button size="sm" className="w-full gap-1.5" onClick={(e) => { e.stopPropagation(); navigate('/subscriptions'); }}>
<Zap className="size-3.5" /> Upgrade to unlock
</Button>
</div>
</div>
);
}
// ── Unavailable card ──────────────────────────────
if (isUnavailable) {
return (
<div
key={unit.id}
className="bg-card rounded-2xl border border-dashed p-4 flex flex-col gap-3 w-96 shrink-0 opacity-50 cursor-not-allowed"
>
<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>
<Badge variant="secondary" className="ml-auto gap-1 text-muted-foreground text-xs">
<Lock className="size-3" /> Unavailable
</Badge>
</div>
<h1 className="text-base font-semibold leading-snug line-clamp-2 text-muted-foreground">
{unit.title}
</h1>
<p className="text-sm text-muted-foreground">This unit is no longer available.</p>
</div>
);
}
// ── Normal card ───────────────────────────────────
return (
<div
key={unit.id}
onClick={() => !isFetching && setSelected(unit)}
className={`bg-card rounded-2xl border p-4 flex flex-col gap-3 transition-all w-96 shrink-0 ${
isFetching
? 'opacity-60 cursor-wait'
: 'hover:bg-muted/60 hover:shadow-sm cursor-pointer group'
}`}
>
{/* Card type row */}
<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
onClick={(e) => {
if (!info) return;
e.stopPropagation();
if (info.course?.course_id) {
navigate(`/course/${info.course.course_id}/unit`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
return;
}
// Standalone unit (junction revamp — no parent course) —
// the course-scoped route can't resolve, fall back to
// the standalone unit reader instead.
navigate(`/units/${unit.reference_id}/read`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
}}
className="text-base font-semibold leading-snug line-clamp-2 transition-colors text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
>
{unit.title}
</h1>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1">
{info?.description ?? ''}
</p>
</div>
<div className="flex flex-col gap-2 mt-auto pt-1 border-t">
<div className="flex items-center justify-between text-sm pt-1">
{progress >= 100 ? (
<span className="flex items-center gap-1.5 text-green-600 dark:text-green-400 font-medium">
<CheckCheck className="size-4" /> Completed
</span>
) : progress > 0 ? (
<span className="flex items-center gap-1.5 text-muted-foreground font-medium">
<RefreshCw className="size-4" /> In Progress
</span>
) : (
<span className="text-muted-foreground font-medium">Not Started</span>
)}
{progress > 0 && <span className="text-xs font-semibold">{progress}%</span>}
</div>
<Progress
value={progress}
className={`h-1.5 ${progress >= 100 ? "[&>div]:bg-green-500" : ""}`}
/>
</div>
</div>
);
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
<ResponsiveModal
open={!!selected}
onOpenChange={(v) => !v && setSelected(null)}
title={selected?.title}
description="Unit Info"
footer={
<>
<Button variant="outline" onClick={() => setSelected(null)}>Cancel</Button>
<Button
onClick={() => {
const info = details[selected?.reference_id];
if (!info) return;
if (info.course?.course_id) {
navigate(`/course/${info.course.course_id}/unit`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
return;
}
navigate(`/units/${selected.reference_id}/read`, {
state: taskId ? { taskCtx: { has_task: true, groupId, taskListId, taskId } } : {},
});
}}
disabled={
getProgress(selected ?? {}) >= 100 ||
locked[selected?.reference_id] ||
!details[selected?.reference_id]
}
>
<SendHorizonal /> Proceed
</Button>
</>
}
>
{selected && (() => {
const info = details[selected.reference_id];
const progress = getProgress(selected);
const done = progress >= 100;
return (
<div className="flex flex-col gap-4">
{done && (
<div className="flex items-center gap-2 bg-green-50 dark:bg-green-950/40 text-green-700 dark:text-green-400 text-sm font-medium rounded-lg px-4 py-3">
<CheckCheck className="size-4 shrink-0" /> Automatically Turned-in
</div>
)}
{info?.course?.title && (
<p className="text-sm">
<span className="text-muted-foreground">Course: </span>
<span className="font-medium">{info.course.title}</span>
</p>
)}
<div className="flex flex-col gap-1">
<p className="text-xs uppercase tracking-wide text-muted-foreground font-medium">About this unit</p>
<p className="text-sm leading-relaxed">{info?.description ?? '—'}</p>
</div>
</div>
);
})()}
</ResponsiveModal>
</>
);
};
export default ReadUnit;