pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-28 11:30:01 +08:00
parent b03b204861
commit bac7168b1e
100 changed files with 5958 additions and 1976 deletions
+3
View File
@@ -1,6 +1,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { AuthProvider, useAuth, decodeToken } from './contexts/AuthContext'; import { AuthProvider, useAuth, decodeToken } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { DateTimePreferenceProvider } from './contexts/DateTimePreferenceContext';
import { Helmet, HelmetProvider } from "react-helmet-async"; import { Helmet, HelmetProvider } from "react-helmet-async";
import { TooltipProvider } from './components/ui/tooltip'; import { TooltipProvider } from './components/ui/tooltip';
import { setAuthInterceptor } from './utils/api.util'; import { setAuthInterceptor } from './utils/api.util';
@@ -78,11 +79,13 @@ export default function App() {
<meta name="twitter:image" content="https://95306gu5u4.ufs.sh/f/uBRuoG2BEPFD7KSEgHPf2HyENZzXqhfcGpQsYtIMT9F5RWUC" /> <meta name="twitter:image" content="https://95306gu5u4.ufs.sh/f/uBRuoG2BEPFD7KSEgHPf2HyENZzXqhfcGpQsYtIMT9F5RWUC" />
</Helmet> </Helmet>
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme"> <ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">
<DateTimePreferenceProvider>
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<AuthProvider> <AuthProvider>
<AppWithAuth /> <AppWithAuth />
</AuthProvider> </AuthProvider>
</TooltipProvider> </TooltipProvider>
</DateTimePreferenceProvider>
</ThemeProvider> </ThemeProvider>
</HelmetProvider> </HelmetProvider>
); );
+181 -46
View File
@@ -1,25 +1,32 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { Search, CheckCircle2 } from "lucide-react"; import { Search, CheckCircle2, SlidersHorizontal, X } from "lucide-react";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useAssets } from "@/contexts/AdminAssetsContext"; import { useAssets } from "@/contexts/AdminAssetsContext";
import api from "@/utils/api.util";
function EmptyState({ fileType }) { const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
return ( const DEBOUNCE_MS = 400;
<div className="flex flex-col items-center justify-center h-48 gap-2">
<p className="text-sm text-muted-foreground">
No {fileType} assets found.
</p>
</div>
);
}
function AssetCard({ asset, selected, onSelect }) { const EXT_OPTIONS = {
const thumb = asset.thumbnail_url ?? asset.file_url; image: ["svg", "png", "jpg", "jpeg", "webp", "gif"],
video: ["mp4", "mov", "webm", "avi"],
audio: ["mp3", "wav", "ogg", "m4a"],
document: ["pdf", "docx", "xlsx", "pptx"],
};
// ─── Asset Card ───────────────────────────────────────────────────────────────
// streamSrc is resolved at the sheet level (batch token request) — no per-card fetch.
function AssetCard({ asset, streamSrc, selected, onSelect }) {
const directThumb = asset.thumbnail_url ?? asset.file_url;
const thumb = streamSrc ?? directThumb;
return ( return (
<button <button
@@ -28,18 +35,12 @@ function AssetCard({ asset, selected, onSelect }) {
className={[ className={[
"relative rounded-lg border-2 overflow-hidden transition-all text-left w-full", "relative rounded-lg border-2 overflow-hidden transition-all text-left w-full",
"hover:border-primary/60 hover:shadow-sm", "hover:border-primary/60 hover:shadow-sm",
selected selected ? "border-primary ring-2 ring-primary/20" : "border-border",
? "border-primary ring-2 ring-primary/20"
: "border-border",
].join(" ")} ].join(" ")}
> >
<div className="aspect-video bg-muted w-full overflow-hidden"> <div className="aspect-video bg-muted w-full overflow-hidden">
{thumb ? ( {thumb ? (
<img <img src={thumb} alt={asset.display_name} className="w-full h-full object-cover" />
src={thumb}
alt={asset.display_name}
className="w-full h-full object-cover"
/>
) : ( ) : (
<div className="w-full h-full flex items-center justify-center"> <div className="w-full h-full flex items-center justify-center">
<span className="text-xs text-muted-foreground">No preview</span> <span className="text-xs text-muted-foreground">No preview</span>
@@ -48,6 +49,9 @@ function AssetCard({ asset, selected, onSelect }) {
</div> </div>
<div className="p-2"> <div className="p-2">
<p className="text-xs font-medium truncate">{asset.display_name}</p> <p className="text-xs font-medium truncate">{asset.display_name}</p>
{asset.extension && (
<p className="text-[10px] text-muted-foreground uppercase mt-0.5">{asset.extension}</p>
)}
</div> </div>
{selected && ( {selected && (
<div className="absolute top-1.5 right-1.5"> <div className="absolute top-1.5 right-1.5">
@@ -58,45 +62,119 @@ function AssetCard({ asset, selected, onSelect }) {
); );
} }
function EmptyState({ fileType }) {
return (
<div className="flex flex-col items-center justify-center h-48 gap-2">
<p className="text-sm text-muted-foreground">No {fileType} assets found.</p>
</div>
);
}
// ─── Main Sheet ───────────────────────────────────────────────────────────────
export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) { export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
if (!open) return null; if (!open) return null;
const { fetchAssets, assets, pagination, loading } = useAssets(); const { fetchAssets, assets, pagination, loading } = useAssets();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [committed, setCommitted] = useState(""); // ← only updates on search trigger const [activeExts, setActiveExts] = useState(new Set());
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
const [filterOpen, setFilterOpen] = useState(false);
// { [asset_id]: streamUrl } — resolved once per asset list via batch token request
const [streamUrls, setStreamUrls] = useState({});
const debounceRef = useRef(null);
const LIMIT = 12; const LIMIT = 12;
const triggerSearch = useCallback(() => { const extOptions = EXT_OPTIONS[fileType] ?? [];
setCommitted(search);
setPage(1);
}, [search]);
// ── Fetch only when committed search, page, or open changes ────────────── // ── Build and fire fetch ──────────────────────────────────────────────────
const doFetch = useCallback((searchVal, extSet, pg) => {
const filters = [
...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
...(searchVal.trim() ? [{ id: "display_name", value: [searchVal.trim()] }] : []),
...(extSet.size > 0 ? [{ id: "extension", value: [...extSet] }] : []),
];
fetchAssets({ page: pg, limit: LIMIT, filters });
}, [fileType, fetchAssets]);
// ── Auto-search: debounce on search input change ──────────────────────────
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
fetchAssets({ clearTimeout(debounceRef.current);
page, debounceRef.current = setTimeout(() => {
limit: LIMIT, setPage(1);
filters: [ doFetch(search, activeExts, 1);
...(fileType ? [{ id: "file_type", value: [fileType] }] : []), }, DEBOUNCE_MS);
...(committed ? [{ id: "display_name", value: [committed] }] : []), return () => clearTimeout(debounceRef.current);
], }, [search, open]);
// ── Immediate fetch on ext filter or page change ──────────────────────────
useEffect(() => {
if (!open) return;
doFetch(search, activeExts, page);
}, [activeExts, page, open]);
// ── Batch token fetch after assets load ───────────────────────────────────
// One request for all S3 assets on the current page instead of N per-card requests.
// This eliminates the thundering-herd / auth-refresh race that caused some cards to
// silently show "No preview" after a page reload (multiple 401s queuing simultaneously
// while the interceptor refreshes, some dropping if cancelled mid-flight).
useEffect(() => {
if (!assets.length) return;
const s3Ids = assets
.filter((a) => a.storage_provider === "s3" && !a.thumbnail_url && !a.file_url)
.map((a) => a.asset_id);
if (!s3Ids.length) return;
let cancelled = false;
api.post("/admin/media/tokens", { asset_ids: s3Ids })
.then(({ data }) => {
if (cancelled) return;
const tokens = data.data?.tokens ?? {};
const urls = {};
for (const [id, token] of Object.entries(tokens)) {
urls[id] = `${STREAM_BASE}/${token}`;
}
setStreamUrls((prev) => ({ ...prev, ...urls }));
})
.catch((err) => {
console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message);
}); });
}, [open, page, committed, fileType]);
return () => { cancelled = true; };
}, [assets]);
// ── Reset on close ──────────────────────────────────────────────────────── // ── Reset on close ────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setSearch(""); setSearch("");
setCommitted(""); setActiveExts(new Set());
setPage(1); setPage(1);
setSelected(null); setSelected(null);
setFilterOpen(false);
setStreamUrls({});
} }
}, [open]); }, [open]);
const toggleExt = (ext) => {
setActiveExts((prev) => {
const next = new Set(prev);
next.has(ext) ? next.delete(ext) : next.add(ext);
return next;
});
setPage(1);
};
const clearFilters = () => {
setActiveExts(new Set());
setPage(1);
};
const handleSelect = (asset) => { const handleSelect = (asset) => {
setSelected(asset.asset_id); setSelected(asset.asset_id);
onSelect(asset); onSelect(asset);
@@ -107,6 +185,8 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s` ? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s`
: "Assets"; : "Assets";
const hasActiveFilters = activeExts.size > 0;
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0"> <SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
@@ -114,13 +194,11 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
{/* ── Header ── */} {/* ── Header ── */}
<SheetHeader className="px-6 pt-6 pb-4 border-b"> <SheetHeader className="px-6 pt-6 pb-4 border-b">
<SheetTitle>Select {label}</SheetTitle> <SheetTitle>Select {label}</SheetTitle>
<SheetDescription> <SheetDescription>Click an asset to attach it.</SheetDescription>
Click an asset to attach it.
</SheetDescription>
</SheetHeader> </SheetHeader>
{/* ── Search ── */} {/* ── Search + Filter ── */}
<div className="px-6 py-3 border-b"> <div className="px-6 py-3 border-b space-y-2">
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@@ -128,18 +206,74 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
placeholder={`Search ${label.toLowerCase()}…`} placeholder={`Search ${label.toLowerCase()}…`}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && triggerSearch()}
className="pl-9" className="pl-9"
/> />
</div> </div>
{extOptions.length > 0 && (
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
<PopoverTrigger asChild>
<Button <Button
type="button" type="button"
onClick={triggerSearch} variant={hasActiveFilters ? "default" : "outline"}
disabled={loading} size="icon"
className="relative shrink-0"
> >
{loading ? <Spinner className="h-4 w-4" /> : "Search"} <SlidersHorizontal className="h-4 w-4" />
{hasActiveFilters && (
<span className="absolute -top-1.5 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground font-medium">
{activeExts.size}
</span>
)}
</Button> </Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-3 space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">File type</p>
{hasActiveFilters && (
<button
type="button"
onClick={clearFilters}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
)}
</div> </div>
<div className="flex flex-wrap gap-1.5">
{extOptions.map((ext) => (
<button
key={ext}
type="button"
onClick={() => toggleExt(ext)}
className={[
"px-2.5 py-1 rounded-full border text-xs uppercase font-mono transition-colors",
activeExts.has(ext)
? "bg-primary text-primary-foreground border-primary"
: "bg-card border-border hover:bg-muted",
].join(" ")}
>
{ext}
</button>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
{hasActiveFilters && (
<div className="flex flex-wrap gap-1.5">
{[...activeExts].map((ext) => (
<Badge key={ext} variant="secondary" className="gap-1 pr-1 uppercase text-[10px] font-mono">
{ext}
<button type="button" onClick={() => toggleExt(ext)} className="ml-0.5 hover:opacity-70">
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div> </div>
{/* ── Grid ── */} {/* ── Grid ── */}
@@ -156,6 +290,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
<AssetCard <AssetCard
key={asset.asset_id} key={asset.asset_id}
asset={asset} asset={asset}
streamSrc={streamUrls[String(asset.asset_id)] ?? null}
selected={selected === asset.asset_id} selected={selected === asset.asset_id}
onSelect={handleSelect} onSelect={handleSelect}
/> />
@@ -7,6 +7,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { useClientNotifications } from "@/contexts/ClientNotificationContext"; import { useClientNotifications } from "@/contexts/ClientNotificationContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDateFormat } from "@/hooks/useDateFormat";
const TYPE_ICON = { const TYPE_ICON = {
achievement: Trophy, achievement: Trophy,
@@ -31,20 +32,10 @@ function timeAgo(dateStr) {
return `${Math.floor(h / 24)}d ago`; return `${Math.floor(h / 24)}d ago`;
} }
function formatDate(dateStr) {
return new Date(dateStr).toLocaleString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
});
}
export default function ClientNotificationBell() { export default function ClientNotificationBell() {
const { notifications, unseenCount, loading, fetchNotifications, markSeen, markAllSeen } = const { notifications, unseenCount, loading, fetchNotifications, markSeen, markAllSeen } =
useClientNotifications(); useClientNotifications();
const { fmtDateTime } = useDateFormat();
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
const [copiedCode, setCopiedCode] = useState(false); const [copiedCode, setCopiedCode] = useState(false);
@@ -79,15 +70,16 @@ export default function ClientNotificationBell() {
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0"> <PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between px-4 py-3"> <div className="flex items-center justify-between px-4 pt-3">
<span className="text-sm font-semibold">Notifications</span> <span className="text-sm font-semibold">Notifications</span>
{unseenCount > 0 && ( {unseenCount > 0 && (
<button <Button
onClick={markAllSeen} onClick={markAllSeen}
className="text-xs text-muted-foreground hover:text-foreground transition-colors" variant="ghost"
size="sm"
> >
Mark all as read Mark all as read
</button> </Button>
)} )}
</div> </div>
@@ -186,7 +178,7 @@ export default function ClientNotificationBell() {
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="capitalize">{selected?.type}</span> <span className="capitalize">{selected?.type}</span>
<span>{selected ? formatDate(selected.createdAt) : ""}</span> <span>{selected ? fmtDateTime(selected.createdAt) : ""}</span>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -0,0 +1,230 @@
// ─── components/Dialogs/BanUserDialog.jsx ─────────────────────────────────────
import { useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Spinner } from "@/components/ui/spinner";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { CalendarIcon, Clock2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
import { fmtDate } from "@/utils/datetime.util";
const EMPTY = { reason: "", ban_type: "temporary", expires_at: "", _date: null, _time: "12:00" };
/**
* Ban dialog — single or bulk.
*
* Single: <BanUserDialog entity={rowObject} getName={(r) => r.name} ... />
* Bulk: <BanUserDialog ids={[1, 2, 3]} entityLabel="User" ... />
*
* @param {Function} onBan (payload) => Promise — called with { reason, ban_type, expires_at? }
* For bulk, caller merges ids on top.
*/
export function BanUserDialog({
open,
onOpenChange,
entity,
ids,
entityLabel = "User",
getName,
onBan,
loading,
onSuccess,
}) {
const [form, setForm] = useState(EMPTY);
const [errors, setErrors] = useState({});
const [calOpen, setCalOpen] = useState(false);
const mergeDateTime = (date, time) => {
if (!date) return "";
const [h, m] = (time || "12:00").split(":").map(Number);
const d = new Date(date);
d.setHours(h, m, 0, 0);
return d.toISOString();
};
const isBulk = Array.isArray(ids) && ids.length > 0;
const count = isBulk ? ids.length : 1;
const displayName = isBulk
? `${count} selected ${entityLabel.toLowerCase()}${count !== 1 ? "s" : ""}`
: (getName?.(entity) ?? entity?.personal_info?.name?.full_name ?? entity?.email ?? "this user");
const validate = () => {
const e = {};
if (!form.reason.trim()) e.reason = "Reason is required.";
if (form.ban_type === "temporary") {
if (!form.expires_at) e.expires_at = "Expiry date is required.";
else if (new Date(form.expires_at) <= new Date()) e.expires_at = "Expiry must be in the future.";
}
setErrors(e);
return Object.keys(e).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
const payload = {
reason: form.reason.trim(),
ban_type: form.ban_type,
...(form.ban_type === "temporary" ? { expires_at: form.expires_at } : {}),
};
const res = await onBan(payload);
if (res) {
setForm(EMPTY);
setErrors({});
onOpenChange(false);
onSuccess?.();
}
};
const handleOpenChange = (v) => {
if (!v) { setForm(EMPTY); setErrors({}); }
onOpenChange(v);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-destructive">
Ban {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
</DialogTitle>
<DialogDescription>
You are about to ban{" "}
<span className="font-medium text-foreground">{displayName}</span>.
They will be logged out immediately and blocked from accessing the platform.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
{/* Reason */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="ban-reason">
Reason <span className="text-destructive">*</span>
</Label>
<Textarea
id="ban-reason"
placeholder="Explain why this user is being banned…"
rows={3}
value={form.reason}
onChange={(e) => setForm((f) => ({ ...f, reason: e.target.value }))}
aria-invalid={!!errors.reason}
/>
{errors.reason && (
<p className="text-xs text-destructive">{errors.reason}</p>
)}
</div>
{/* Ban type */}
<div className="flex flex-col gap-2">
<Label>Ban Duration</Label>
<RadioGroup
value={form.ban_type}
onValueChange={(v) => setForm((f) => ({ ...f, ban_type: v, expires_at: "" }))}
className="flex gap-6"
>
<label className="flex items-center gap-2 cursor-pointer text-sm">
<RadioGroupItem value="permanent" id="ban-perm" />
<span>Permanent</span>
</label>
<label className="flex items-center gap-2 cursor-pointer text-sm">
<RadioGroupItem value="temporary" id="ban-temp" />
<span>Temporary</span>
</label>
</RadioGroup>
</div>
{/* Expiry — shown only for temporary */}
{form.ban_type === "temporary" && (
<div className="flex flex-col gap-1.5">
<Label>
Ban Until <span className="text-destructive">*</span>
</Label>
<Popover open={calOpen} onOpenChange={setCalOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-invalid={!!errors.expires_at}
className={cn(
"w-full justify-start text-left font-normal",
!form._date && "text-muted-foreground",
errors.expires_at && "border-destructive"
)}
>
<CalendarIcon className="mr-2 size-4 shrink-0" />
{form._date ? fmtDate(form._date) : "Pick a date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Card size="sm" className="w-fit rounded-none border-0 shadow-none ring-0">
<CardContent>
<Calendar
mode="single"
selected={form._date ?? undefined}
onSelect={(date) => {
const merged = mergeDateTime(date, form._time);
setForm((f) => ({ ...f, _date: date ?? null, expires_at: merged }));
}}
disabled={(d) => d < new Date(Date.now() + 60_000)}
className="p-0"
initialFocus
/>
</CardContent>
<CardFooter className="border-t bg-card">
<FieldGroup>
<Field>
<FieldLabel htmlFor="ban-expires-time">Time</FieldLabel>
<InputGroup>
<InputGroupInput
id="ban-expires-time"
type="time"
step="60"
value={form._time}
onChange={(e) => {
const merged = mergeDateTime(form._date, e.target.value);
setForm((f) => ({ ...f, _time: e.target.value, expires_at: merged }));
}}
className="appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
<InputGroupAddon align="inline-end">
<Clock2Icon className="text-muted-foreground" />
</InputGroupAddon>
</InputGroup>
</Field>
</FieldGroup>
</CardFooter>
</Card>
</PopoverContent>
</Popover>
{errors.expires_at && (
<p className="text-xs text-destructive">{errors.expires_at}</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleSubmit}
disabled={loading}
>
{loading && <Spinner className="size-4 mr-2" />}
Ban {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,93 @@
// ─── components/Dialogs/UnbanDialog.jsx ───────────────────────────────────────
import { useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
/**
* Unban dialog — single or bulk.
*
* Single: <UnbanDialog entity={rowObject} getName={(r) => r.name} ... />
* Bulk: <UnbanDialog ids={[1, 2, 3]} entityLabel="User" ... />
*
* @param {Function} onUnban (payload) => Promise — { lift_reason? }
*/
export function UnbanDialog({
open,
onOpenChange,
entity,
ids,
entityLabel = "User",
getName,
onUnban,
loading,
onSuccess,
}) {
const [liftReason, setLiftReason] = useState("");
const isBulk = Array.isArray(ids) && ids.length > 0;
const count = isBulk ? ids.length : 1;
const displayName = isBulk
? `${count} selected ${entityLabel.toLowerCase()}${count !== 1 ? "s" : ""}`
: (getName?.(entity) ?? entity?.personal_info?.name?.full_name ?? entity?.email ?? "this user");
const handleSubmit = async () => {
const res = await onUnban({ lift_reason: liftReason.trim() || undefined });
if (res) {
setLiftReason("");
onOpenChange(false);
onSuccess?.();
}
};
const handleOpenChange = (v) => {
if (!v) setLiftReason("");
onOpenChange(v);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Unban {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}</DialogTitle>
<DialogDescription>
Remove the ban on{" "}
<span className="font-medium text-foreground">{displayName}</span>.
They will regain access to the platform immediately.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5 py-2">
<Label htmlFor="lift-reason">
Lift Reason <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Textarea
id="lift-reason"
placeholder="Reason for lifting this ban…"
rows={3}
value={liftReason}
onChange={(e) => setLiftReason(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button
className="bg-emerald-600 text-white hover:bg-emerald-700"
onClick={handleSubmit}
disabled={loading}
>
{loading && <Spinner className="size-4 mr-2" />}
Unban {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+3 -12
View File
@@ -7,6 +7,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { useAdminNotifications } from "@/contexts/AdminNotificationContext"; import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDateFormat } from "@/hooks/useDateFormat";
const TYPE_ICON = { const TYPE_ICON = {
task_overdue: AlertCircle, task_overdue: AlertCircle,
@@ -29,20 +30,10 @@ function timeAgo(dateStr) {
return `${Math.floor(h / 24)}d ago`; return `${Math.floor(h / 24)}d ago`;
} }
function formatDate(dateStr) {
return new Date(dateStr).toLocaleString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
});
}
export default function NotificationBell() { export default function NotificationBell() {
const { notifications, unseenCount, loading, fetchNotifications, markSeen, markAllSeen } = const { notifications, unseenCount, loading, fetchNotifications, markSeen, markAllSeen } =
useAdminNotifications(); useAdminNotifications();
const { fmtDateTime } = useDateFormat();
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
@@ -147,7 +138,7 @@ export default function NotificationBell() {
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="capitalize">{selected?.type?.replace(/_/g, ' ')}</span> <span className="capitalize">{selected?.type?.replace(/_/g, ' ')}</span>
<span>{selected ? formatDate(selected.createdAt) : ""}</span> <span>{selected ? fmtDateTime(selected.createdAt) : ""}</span>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+29 -2
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { AlertTriangle } from "lucide-react";
/** /**
* Generic sheet for selecting and adding users to any entity * Generic sheet for selecting and adding users to any entity
@@ -25,6 +26,8 @@ import { Spinner } from "@/components/ui/spinner";
* @param {string} [props.idKey] Key for the user id. Default: "user_id" * @param {string} [props.idKey] Key for the user id. Default: "user_id"
* @param {string} [props.labelKey] Key for the display name. Default: "full_name" * @param {string} [props.labelKey] Key for the display name. Default: "full_name"
* @param {string} [props.subLabelKey] Optional secondary line (e.g. "email") * @param {string} [props.subLabelKey] Optional secondary line (e.g. "email")
* @param {string} [props.warningKey] If set, users with a truthy value at this key show
* a "will be moved" warning (e.g. "current_group")
* *
* @param {Function} props.onSubmit Called with selected ids[] * @param {Function} props.onSubmit Called with selected ids[]
* *
@@ -63,6 +66,7 @@ export function AddSheet({
idKey = "user_id", idKey = "user_id",
labelKey = "full_name", labelKey = "full_name",
subLabelKey = null, subLabelKey = null,
warningKey = null,
onSubmit, onSubmit,
}) { }) {
@@ -102,6 +106,14 @@ export function AddSheet({
const allFilteredSelected = const allFilteredSelected =
filtered.length > 0 && filtered.every((u) => selected.includes(u[idKey])); filtered.length > 0 && filtered.every((u) => selected.includes(u[idKey]));
const movingCount = useMemo(() => {
if (!warningKey) return 0;
return selected.filter((id) => {
const user = users.find((u) => u[idKey] === id);
return !!user?.[warningKey];
}).length;
}, [selected, users, warningKey, idKey]);
async function handleSubmit() { async function handleSubmit() {
if (!selected.length) return; if (!selected.length) return;
await onSubmit(selected); await onSubmit(selected);
@@ -169,6 +181,7 @@ export function AddSheet({
const id = user[idKey]; const id = user[idKey];
const label = user[labelKey]; const label = user[labelKey];
const sub = subLabelKey ? user[subLabelKey] : null; const sub = subLabelKey ? user[subLabelKey] : null;
const warning = warningKey ? user[warningKey] : null;
return ( return (
<label <label
@@ -180,13 +193,19 @@ export function AddSheet({
checked={selected.includes(id)} checked={selected.includes(id)}
onChange={() => toggle(id)} onChange={() => toggle(id)}
/> />
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0 flex-1">
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
{sub && ( {sub && (
<span className="text-xs text-muted-foreground truncate"> <span className="text-xs text-muted-foreground truncate">
{sub} {sub}
</span> </span>
)} )}
{warning && (
<span className="text-xs text-amber-600 flex items-center gap-1 mt-0.5">
<AlertTriangle className="size-3 shrink-0" />
Also in: {warning}
</span>
)}
</div> </div>
</label> </label>
); );
@@ -197,7 +216,14 @@ export function AddSheet({
</div> </div>
{/* Footer */} {/* Footer */}
<div className="shrink-0 flex gap-2 border-t px-6 py-4"> <div className="shrink-0 flex flex-col gap-2 border-t px-6 py-4">
{movingCount > 0 && (
<p className="text-xs text-amber-600 flex items-center gap-1.5">
<AlertTriangle className="size-3 shrink-0" />
{movingCount} user{movingCount > 1 ? 's' : ''} already belong{movingCount === 1 ? 's' : ''} to other group{movingCount > 1 ? 's' : ''}.
</p>
)}
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={handleClose}> <Button variant="outline" className="flex-1" onClick={handleClose}>
Cancel Cancel
</Button> </Button>
@@ -209,6 +235,7 @@ export function AddSheet({
{selected.length > 0 ? `${submitLabel} (${selected.length})` : submitLabel} {selected.length > 0 ? `${submitLabel} (${selected.length})` : submitLabel}
</Button> </Button>
</div> </div>
</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
+5 -5
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -14,14 +15,12 @@ const FIELD_DISPLAY_MAP = {
const BOOLEAN_FIELDS = Object.keys(FIELD_DISPLAY_MAP); const BOOLEAN_FIELDS = Object.keys(FIELD_DISPLAY_MAP);
// ─── Generic formatter ──────────────────────────────────────────────────────── // ─── Generic formatter ────────────────────────────────────────────────────────
const formatFilterItem = (item, field, type) => { const formatFilterItem = (item, field, type, fmtDate) => {
if (FIELD_DISPLAY_MAP[field]) { if (FIELD_DISPLAY_MAP[field]) {
return FIELD_DISPLAY_MAP[field][String(item)] ?? item; return FIELD_DISPLAY_MAP[field][String(item)] ?? item;
} }
if (type === "date" && item) { if (type === "date" && item) {
return new Date(item).toLocaleDateString("en-US", { return fmtDate(item);
year: "numeric", month: "long", day: "numeric",
});
} }
return item; return item;
}; };
@@ -35,6 +34,7 @@ const EmptyState = ({ search }) => (
// ─── Reusable item list renderer ────────────────────────────────────────────── // ─── Reusable item list renderer ──────────────────────────────────────────────
const FilterList = ({ items, field, type, selected, onToggle, inputType = "checkbox" }) => { const FilterList = ({ items, field, type, selected, onToggle, inputType = "checkbox" }) => {
const { fmtDate } = useDateFormat();
if (items.length === 0) return <EmptyState />; if (items.length === 0) return <EmptyState />;
return items.map((item) => ( return items.map((item) => (
@@ -45,7 +45,7 @@ const FilterList = ({ items, field, type, selected, onToggle, inputType = "check
checked={selected.includes(String(item))} checked={selected.includes(String(item))}
onChange={() => onToggle(item)} onChange={() => onToggle(item)}
/> />
{formatFilterItem(item, field, type)} {formatFilterItem(item, field, type, fmtDate)}
</label> </label>
)); ));
}; };
+63 -1
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { useTheme } from '@/contexts/ThemeContext' import { useTheme } from '@/contexts/ThemeContext'
import { useProfile } from '@/contexts/ProfileProvider' import { useProfile } from '@/contexts/ProfileProvider'
import { useDateTimePreference } from '@/contexts/DateTimePreferenceContext'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@@ -11,7 +12,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem,
import { Dialog, DialogContent } from '@/components/ui/dialog' import { Dialog, DialogContent } from '@/components/ui/dialog'
import { AlertDialog, AlertDialogContent, } from '@/components/ui/alert-dialog' import { AlertDialog, AlertDialogContent, } from '@/components/ui/alert-dialog'
import { User, Settings, LogOut, Sun, Moon, Monitor, Loader2, Check } from 'lucide-react' import { User, Settings, LogOut, Sun, Moon, Monitor, Loader2, Check, Clock } from 'lucide-react'
import { AVATAR_COLORS } from '@/data/profile.data' import { AVATAR_COLORS } from '@/data/profile.data'
@@ -39,13 +40,35 @@ function ThemeOption({ value, label, icon: Icon, active, onClick }) {
) )
} }
// ─── Timezone Option ──────────────────────────────────────────────────────────
function TimezoneOption({ value, label, description, active, onClick }) {
return (
<button
onClick={() => onClick(value)}
className={`flex flex-col gap-1 p-3 rounded-lg border-2 transition-all cursor-pointer w-full text-left
${active
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted/40'
}`}
>
<span className={`text-sm font-semibold ${active ? 'text-primary' : 'text-foreground'}`}>
{label}
</span>
<span className="text-xs text-muted-foreground">{description}</span>
{active && <Check size={12} className="text-primary mt-0.5" />}
</button>
)
}
// ─── Settings Dialog ────────────────────────────────────────────────────────── // ─── Settings Dialog ──────────────────────────────────────────────────────────
function SettingsDialog({ open, onClose }) { function SettingsDialog({ open, onClose }) {
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const { timezone, setTimezone } = useDateTimePreference()
const [tab, setTab] = useState('appearance') const [tab, setTab] = useState('appearance')
const TABS = [ const TABS = [
{ id: 'appearance', label: 'Appearance', icon: Sun }, { id: 'appearance', label: 'Appearance', icon: Sun },
{ id: 'datetime', label: 'Date & Time', icon: Clock },
] ]
return ( return (
@@ -100,6 +123,45 @@ function SettingsDialog({ open, onClose }) {
</div> </div>
)} )}
{/* Date & Time */}
{tab === 'datetime' && (
<div className="space-y-5">
<div>
<h2 className="text-base font-semibold">Date &amp; Time</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Choose how dates and times are displayed across the platform.
Your locale format is taken from your browser&apos;s language setting.
</p>
</div>
<Separator />
<div>
<Label className="text-sm font-medium mb-3 block">Timezone Display</Label>
<div className="grid grid-cols-2 gap-3">
<TimezoneOption
value="local"
label="Local"
description="Dates shown in your device's local timezone."
active={timezone === 'local'}
onClick={setTimezone}
/>
<TimezoneOption
value="UTC"
label="UTC"
description="Dates shown in Coordinated Universal Time (UTC+0)."
active={timezone === 'UTC'}
onClick={setTimezone}
/>
</div>
</div>
<div className="rounded-lg border bg-muted/40 p-3 text-xs text-muted-foreground space-y-1">
<p className="font-medium text-foreground">Preview</p>
<p>Date: <span className="text-foreground">{new Date().toLocaleDateString(navigator.language, { month: 'short', day: 'numeric', year: 'numeric', ...(timezone === 'UTC' ? { timeZone: 'UTC' } : {}) })}</span></p>
<p>Date &amp; Time: <span className="text-foreground">{new Date().toLocaleString(navigator.language, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', ...(timezone === 'UTC' ? { timeZone: 'UTC' } : {}) })}</span></p>
{timezone === 'UTC' && <p className="text-amber-600 dark:text-amber-400 font-medium">All times shown in UTC</p>}
</div>
</div>
)}
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
@@ -0,0 +1,76 @@
import { createContext, useCallback, useContext, useState } from "react";
import api from "@/utils/api.util";
import { toast } from "sonner";
const AdminTierCategoriesContext = createContext(null);
export function useAdminTierCategories() {
const ctx = useContext(AdminTierCategoriesContext);
if (!ctx) throw new Error("useAdminTierCategories must be used inside AdminTierCategoriesProvider");
return ctx;
}
export function AdminTierCategoriesProvider({ children }) {
const [categories, setCategories] = useState([]);
const [category, setCategory] = useState(null);
const [loading, setLoading] = useState(false);
const request = useCallback(async (fn) => {
setLoading(true);
try { return await fn(); }
catch (err) {
toast.error(err?.response?.data?.message ?? "Something went wrong.");
return null;
} finally { setLoading(false); }
}, []);
const fetchCategories = useCallback(() =>
request(async () => {
const { data } = await api.get("/admin/tiers/categories");
setCategories(data.data ?? []);
return data.data;
}), [request]);
const fetchCategory = useCallback((id) =>
request(async () => {
const { data } = await api.get(`/admin/tiers/categories/${id}`);
setCategory(data.data ?? null);
return data.data;
}), [request]);
const createCategory = useCallback((payload) =>
request(async () => {
const { data } = await api.post("/admin/tiers/categories", payload);
toast.success("Tier category created.");
return data.data;
}), [request]);
const updateCategory = useCallback((id, payload) =>
request(async () => {
const { data } = await api.put(`/admin/tiers/categories/${id}`, payload);
setCategories((prev) =>
prev.map((c) => (String(c.tier_category_id) === String(id) ? data.data : c))
);
if (category && String(category.tier_category_id) === String(id)) setCategory(data.data);
toast.success("Tier category updated.");
return data.data;
}), [request, category]);
const deleteCategory = useCallback((id) =>
request(async () => {
await api.delete(`/admin/tiers/categories/${id}`);
setCategories((prev) => prev.filter((c) => String(c.tier_category_id) !== String(id)));
toast.success("Tier category deleted.");
return true;
}), [request]);
return (
<AdminTierCategoriesContext.Provider value={{
categories, category, loading,
fetchCategories, fetchCategory,
createCategory, updateCategory, deleteCategory,
}}>
{children}
</AdminTierCategoriesContext.Provider>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { createContext, useCallback, useContext, useState } from "react";
import api from "@/utils/api.util";
import { toast } from "sonner";
const AdminTierPoliciesContext = createContext(null);
export function useAdminTierPolicies() {
const ctx = useContext(AdminTierPoliciesContext);
if (!ctx) throw new Error("useAdminTierPolicies must be used inside AdminTierPoliciesProvider");
return ctx;
}
export function AdminTierPoliciesProvider({ children }) {
const [systemBadges, setSystemBadges] = useState([]);
const [loading, setLoading] = useState(false);
const request = useCallback(async (fn) => {
setLoading(true);
try {
return await fn();
} catch (err) {
const message = err?.response?.data?.message ?? "Something went wrong.";
toast.error(message);
return null;
} finally {
setLoading(false);
}
}, []);
// ─── System Badges ────────────────────────────────────────────────────────────
const fetchSystemBadges = useCallback(() =>
request(async () => {
const { data } = await api.get("/admin/tiers/system-badges");
setSystemBadges(data.data ?? []);
return data.data;
}), [request]);
// payload = { asset_id, label, description, information, active_from, active_until }
const saveSystemBadge = useCallback((key, payload) =>
request(async () => {
const { data } = await api.put(`/admin/tiers/system-badges/${key}`, payload);
setSystemBadges((prev) => {
const idx = prev.findIndex((b) => b.key === key);
return idx >= 0
? prev.map((b) => (b.key === key ? data.data : b))
: [...prev, data.data];
});
toast.success("Badge saved.");
return data.data;
}), [request]);
return (
<AdminTierPoliciesContext.Provider value={{
systemBadges, loading,
fetchSystemBadges, saveSystemBadge,
}}>
{children}
</AdminTierPoliciesContext.Provider>
);
}
-25
View File
@@ -13,7 +13,6 @@ export function AdminTiersProvider({ children }) {
const [userTiers, setUserTiers] = useState([]); const [userTiers, setUserTiers] = useState([]);
const [planAttributes, setPlanAttributes] = useState([]); const [planAttributes, setPlanAttributes] = useState([]);
const [paymentAttributes, setPaymentAttributes] = useState([]); const [paymentAttributes, setPaymentAttributes] = useState([]);
const [planCourses, setPlanCourses] = useState([]);
const [planPagination, setPlanPagination] = useState({ page: 1, limit: 10, totalPages: 1, totalRecords: 0 }); const [planPagination, setPlanPagination] = useState({ page: 1, limit: 10, totalPages: 1, totalRecords: 0 });
const [paymentPagination, setPaymentPagination] = useState({ page: 1, limit: 10, totalPages: 1, totalRecords: 0 }); const [paymentPagination, setPaymentPagination] = useState({ page: 1, limit: 10, totalPages: 1, totalRecords: 0 });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -183,28 +182,6 @@ export function AdminTiersProvider({ children }) {
finally { setLoading(false); } finally { setLoading(false); }
}, []); }, []);
const fetchPlanCourses = useCallback(async (planId) => {
setLoading(true);
try {
const { data } = await api.get(`/admin/tiers/${planId}/courses`);
setPlanCourses(data.data ?? []);
} catch { toast.error("Could not load plan courses."); }
finally { setLoading(false); }
}, []);
const syncPlanCourses = useCallback(async (planId, courseIds) => {
setLoading(true);
try {
await api.post(`/admin/tiers/${planId}/courses`, { course_ids: courseIds });
toast.success("Courses updated.");
return true;
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not update courses.");
return false;
} finally { setLoading(false); }
}, []);
return ( return (
<AdminTiersContext.Provider value={{ <AdminTiersContext.Provider value={{
// State // State
@@ -228,8 +205,6 @@ const syncPlanCourses = useCallback(async (planId, courseIds) => {
// Payment actions // Payment actions
fetchPayments, fetchPayment, fetchPayments, fetchPayment,
// Course plans
planCourses, fetchPlanCourses, syncPlanCourses,
}}> }}>
{children} {children}
</AdminTiersContext.Provider> </AdminTiersContext.Provider>
+83
View File
@@ -34,6 +34,8 @@ export const UserProvider = ({ children }) => {
const [activity, setActivity] = useState([]); const [activity, setActivity] = useState([]);
const [activityPagination, setActivityPagination] = useState(PAGINATION_INIT); const [activityPagination, setActivityPagination] = useState(PAGINATION_INIT);
const [activityLoading, setActivityLoading] = useState(false); const [activityLoading, setActivityLoading] = useState(false);
const [bans, setBans] = useState([]);
const [bansLoading, setBansLoading] = useState(false);
const request = useCallback(async (fn) => { const request = useCallback(async (fn) => {
setLoading(true); setLoading(true);
@@ -289,10 +291,90 @@ export const UserProvider = ({ children }) => {
} }
}, []); }, []);
// ─── POST /api/admin/users/:id/ban ────────────────────────────────────────
const banUser = useCallback(
(userId, payload) =>
request(async () => {
const res = await api.post(`${BASE}/users/${userId}/ban`, payload);
setUsers((prev) =>
prev.map((u) => (u.user_id === userId ? { ...u, is_banned: true } : u))
);
if (user?.user_id === userId) setUser((prev) => prev ? { ...prev, is_banned: true } : prev);
toast.success("User banned successfully.");
return res.data;
}),
[request, user]
);
// ─── POST /api/admin/users/:id/unban ──────────────────────────────────────
const unbanUser = useCallback(
(userId, payload) =>
request(async () => {
const res = await api.post(`${BASE}/users/${userId}/unban`, payload);
setUsers((prev) =>
prev.map((u) => (u.user_id === userId ? { ...u, is_banned: false } : u))
);
if (user?.user_id === userId) setUser((prev) => prev ? { ...prev, is_banned: false } : prev);
toast.success("User unbanned successfully.");
return res.data;
}),
[request, user]
);
// ─── POST /api/admin/users/bulk/ban ───────────────────────────────────────
const bulkBanUsers = useCallback(
({ ids, ...payload }) =>
request(async () => {
const res = await api.post(`${BASE}/users/bulk/ban`, { ids, ...payload });
const { banned_ids } = res.data?.data ?? {};
if (banned_ids?.length) {
setUsers((prev) =>
prev.map((u) => (banned_ids.includes(u.user_id) ? { ...u, is_banned: true } : u))
);
toast.success(`${banned_ids.length} user(s) banned.`);
}
return res.data;
}),
[request]
);
// ─── POST /api/admin/users/bulk/unban ─────────────────────────────────────
const bulkUnbanUsers = useCallback(
({ ids, lift_reason } = {}) =>
request(async () => {
const res = await api.post(`${BASE}/users/bulk/unban`, { ids, lift_reason });
const { unbanned_ids } = res.data?.data ?? {};
if (unbanned_ids?.length) {
setUsers((prev) =>
prev.map((u) => (unbanned_ids.includes(u.user_id) ? { ...u, is_banned: false } : u))
);
toast.success(`${unbanned_ids.length} user(s) unbanned.`);
}
return res.data;
}),
[request]
);
// ─── GET /api/admin/users/:id/bans ────────────────────────────────────────
const fetchUserBans = useCallback(async (userId) => {
setBansLoading(true);
try {
const res = await api.get(`${BASE}/users/${userId}/bans`);
setBans(res.data?.data ?? []);
return res.data?.data;
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not load ban history.");
return [];
} finally {
setBansLoading(false);
}
}, []);
return ( return (
<UserContext.Provider value={{ <UserContext.Provider value={{
users, user, sessions, achievements, achievementsLoading, pagination, attributes, loading, error, users, user, sessions, achievements, achievementsLoading, pagination, attributes, loading, error,
activity, activityPagination, activityLoading, activity, activityPagination, activityLoading,
bans, bansLoading,
setPagination, setPagination,
fetchUsers, fetchArchivedUsers, fetchUser, fetchUsers, fetchArchivedUsers, fetchUser,
addStaffUser, updateUser, addStaffUser, updateUser,
@@ -302,6 +384,7 @@ export const UserProvider = ({ children }) => {
fetchUserFieldValues, fetchUserFieldValues,
fetchUserAchievements, fetchUserAchievements,
fetchActivity, fetchUserActivity, fetchActivity, fetchUserActivity,
banUser, unbanUser, bulkBanUsers, bulkUnbanUsers, fetchUserBans,
}}> }}>
{children} {children}
</UserContext.Provider> </UserContext.Provider>
+2 -1
View File
@@ -33,8 +33,9 @@ export function AuthProvider({ children }) {
return { success: true, user: data.data.user } return { success: true, user: data.data.user }
} catch (err) { } catch (err) {
const message = err.response?.data?.message || 'Login failed. Please try again.' const message = err.response?.data?.message || 'Login failed. Please try again.'
const errors = err.response?.data?.errors ?? null
setAuthError(message) setAuthError(message)
return { success: false, message } return { success: false, message, errors }
} }
}, []) }, [])
@@ -10,6 +10,9 @@
* the map is reconciled with the server response for all three levels * the map is reconciled with the server response for all three levels
* (lesson / unit / course) after each call. * (lesson / unit / course) after each call.
* *
* completedTasks is set when the server returns tasks whose all read
* requirements are now satisfied — UnitList watches this for toasts.
*
* Used by: UnitList.jsx (sidebar indicators + scroll-triggered completion) * Used by: UnitList.jsx (sidebar indicators + scroll-triggered completion)
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
import { createContext, useCallback, useContext, useState } from 'react'; import { createContext, useCallback, useContext, useState } from 'react';
@@ -28,6 +31,8 @@ export function CourseReadingProgressProvider({ children }) {
// { [reference_id]: 'in_progress' | 'completed' } // { [reference_id]: 'in_progress' | 'completed' }
const [progressMap, setProgressMap] = useState({}); const [progressMap, setProgressMap] = useState({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Tasks whose all read requirements just became complete — consumed by UnitList for toasts
const [completedTasks, setCompletedTasks] = useState([]);
// ─── Lookup helpers ─────────────────────────────────────────────────────── // ─── Lookup helpers ───────────────────────────────────────────────────────
@@ -43,8 +48,6 @@ export function CourseReadingProgressProvider({ children }) {
// ─── Fetch full progress snapshot ───────────────────────────────────────── // ─── Fetch full progress snapshot ─────────────────────────────────────────
// GET /client/courses/:courseId/progress
// Called on UnitList mount — seeds the map for the whole course.
const fetchCourseProgress = useCallback(async (courseId) => { const fetchCourseProgress = useCallback(async (courseId) => {
setLoading(true); setLoading(true);
try { try {
@@ -64,11 +67,6 @@ export function CourseReadingProgressProvider({ children }) {
// ─── UPSERT lesson progress ─────────────────────────────────────────────── // ─── UPSERT lesson progress ───────────────────────────────────────────────
// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress
// Body: { status: 'in_progress' | 'completed' }
//
// Optimistically patches the lesson in the map, then reconciles lesson + unit + course
// from the server response so all sidebar indicators update without a full refetch.
const upsertLessonProgress = useCallback(async (courseId, unitId, lessonId, lessonUuid, status) => { const upsertLessonProgress = useCallback(async (courseId, unitId, lessonId, lessonUuid, status) => {
// Optimistic update — lesson only // Optimistic update — lesson only
setProgressMap((prev) => ({ ...prev, [lessonUuid]: status })); setProgressMap((prev) => ({ ...prev, [lessonUuid]: status }));
@@ -90,6 +88,11 @@ export function CourseReadingProgressProvider({ children }) {
return next; return next;
}); });
// Signal any tasks that just had all read requirements completed
if (result.completed_tasks?.length) {
setCompletedTasks(result.completed_tasks);
}
return result; return result;
} catch (err) { } catch (err) {
// Rollback optimistic update // Rollback optimistic update
@@ -103,9 +106,16 @@ export function CourseReadingProgressProvider({ children }) {
} }
}, []); }, []);
// ─── Clear completed tasks signal after consumption ───────────────────────
const clearCompletedTasks = useCallback(() => setCompletedTasks([]), []);
// ─── Reset — call when leaving a course ────────────────────────────────── // ─── Reset — call when leaving a course ──────────────────────────────────
const resetProgress = useCallback(() => setProgressMap({}), []); const resetProgress = useCallback(() => {
setProgressMap({});
setCompletedTasks([]);
}, []);
return ( return (
<CourseReadingProgressContext.Provider value={{ <CourseReadingProgressContext.Provider value={{
@@ -115,6 +125,8 @@ export function CourseReadingProgressProvider({ children }) {
isCompleted, isCompleted,
fetchCourseProgress, fetchCourseProgress,
upsertLessonProgress, upsertLessonProgress,
completedTasks,
clearCompletedTasks,
resetProgress, resetProgress,
}}> }}>
{children} {children}
+7
View File
@@ -146,6 +146,12 @@ export function ClientCoursesProvider({ children }) {
} catch { /* silent — draft saves are best-effort */ } } catch { /* silent — draft saves are best-effort */ }
}, []); }, []);
const saveQuizDraft = useCallback(async (courseId, unitId, quizId, answers) => {
try {
await api.patch(`/client/courses/${courseId}/units/${unitId}/quiz/${quizId}/draft`, { answers });
} catch { /* silent — draft saves are best-effort */ }
}, []);
const refreshAssessmentSession = useCallback(async (courseId, assessmentId) => { const refreshAssessmentSession = useCallback(async (courseId, assessmentId) => {
try { try {
const { data } = await api.get(`/client/courses/${courseId}/assessment/${assessmentId}/session`); const { data } = await api.get(`/client/courses/${courseId}/assessment/${assessmentId}/session`);
@@ -239,6 +245,7 @@ export function ClientCoursesProvider({ children }) {
getCourseAssessment, getCourseAssessment,
startCourseAssessment, startCourseAssessment,
saveDraft, saveDraft,
saveQuizDraft,
refreshAssessmentSession, refreshAssessmentSession,
submitUnitQuiz, submitUnitQuiz,
submitCourseAssessment, submitCourseAssessment,
@@ -137,6 +137,29 @@ export function TaskProgressProvider({ children }) {
[request] [request]
); );
// ══════════════════════════════════════════════════════════════════════════
// UNVISIT LINK (DELETE)
// ══════════════════════════════════════════════════════════════════════════
const unvisitLink = useCallback(
(groupId, taskListId, taskId, requirementId) =>
request(async () => {
// Optimistic update — remove from visited map
setVisitedMap((prev) => {
const next = { ...prev };
delete next[requirementId];
return next;
});
const res = await api.delete(
`${BASE}/${groupId}/task-lists/${taskListId}/tasks/${taskId}/requirements/${requirementId}/visit`
);
return res.data?.data;
}),
[request]
);
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
// UPDATE LESSON PROGRESS (UPSERT — derives unit + course) // UPDATE LESSON PROGRESS (UPSERT — derives unit + course)
// ══════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════
@@ -238,6 +261,7 @@ export function TaskProgressProvider({ children }) {
// ── Actions ─────────────────────────────────────────────────────── // ── Actions ───────────────────────────────────────────────────────
fetchProgress, fetchProgress,
visitLink, visitLink,
unvisitLink,
updateLessonProgress, updateLessonProgress,
resetProgress, resetProgress,
}}> }}>
+35
View File
@@ -24,6 +24,13 @@ export function ClientTiersProvider({ children }) {
const [payments, setPayments] = useState([]); const [payments, setPayments] = useState([]);
const [paymentsLoading, setPaymentsLoading] = useState(false); const [paymentsLoading, setPaymentsLoading] = useState(false);
// ── System badges
const [systemBadges, setSystemBadges] = useState([]);
const [systemBadgesLoading, setSystemBadgesLoading] = useState(false);
// ── Tier categories (public, for badge colours)
const [tierCategories, setTierCategories] = useState([]);
// ─── Actions ──────────────────────────────────────────────────────────────── // ─── Actions ────────────────────────────────────────────────────────────────
const getMyTier = useCallback(async () => { const getMyTier = useCallback(async () => {
@@ -118,6 +125,30 @@ export function ClientTiersProvider({ children }) {
} }
}, []); }, []);
const getSystemBadges = useCallback(async () => {
setSystemBadgesLoading(true);
try {
const { data } = await api.get("/client/tiers/system-badges");
setSystemBadges(data.data ?? []);
} catch (err) {
console.error("[CLIENT TIERS] system badges fetch failed", err);
} finally {
setSystemBadgesLoading(false);
}
}, []);
const getTierCategories = useCallback(async () => {
try {
const { data } = await api.get("/client/tiers/categories");
setTierCategories(data.data ?? []);
} catch (err) {
console.error("[CLIENT TIERS] tier categories fetch failed", err);
}
}, []);
// slug → category object — derived, no extra state
const tierMap = Object.fromEntries(tierCategories.map((c) => [c.slug, c]));
// ─── Reset helpers ────────────────────────────────────────────────────────── // ─── Reset helpers ──────────────────────────────────────────────────────────
const resetMyTier = useCallback(() => setMyTier(null), []); const resetMyTier = useCallback(() => setMyTier(null), []);
@@ -132,6 +163,8 @@ export function ClientTiersProvider({ children }) {
plans, plansLoading, plans, plansLoading,
checkoutLoading, checkoutLoading,
payments, paymentsLoading, payments, paymentsLoading,
systemBadges, systemBadgesLoading,
tierCategories, tierMap,
// actions // actions
getMyTier, getMyTier,
@@ -141,6 +174,8 @@ export function ClientTiersProvider({ children }) {
captureOrder, captureOrder,
cancelOrder, cancelOrder,
getMyPayments, getMyPayments,
getSystemBadges,
getTierCategories,
// resets // resets
resetMyTier, resetMyTier,
@@ -0,0 +1,32 @@
import { createContext, useContext, useState, useCallback } from 'react';
const STORAGE_KEY = 'tz-preference';
const DateTimePreferenceContext = createContext(null);
export function DateTimePreferenceProvider({ children }) {
const [timezone, setTimezoneState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'UTC' ? 'UTC' : 'local'
);
const setTimezone = useCallback((tz) => {
localStorage.setItem(STORAGE_KEY, tz);
setTimezoneState(tz);
}, []);
const toggleTimezone = useCallback(() => {
setTimezone(timezone === 'local' ? 'UTC' : 'local');
}, [timezone, setTimezone]);
return (
<DateTimePreferenceContext.Provider value={{ timezone, setTimezone, toggleTimezone }}>
{children}
</DateTimePreferenceContext.Provider>
);
}
export function useDateTimePreference() {
const ctx = useContext(DateTimePreferenceContext);
if (!ctx) throw new Error('useDateTimePreference must be used inside DateTimePreferenceProvider');
return ctx;
}
+37
View File
@@ -0,0 +1,37 @@
import { useDateTimePreference } from '@/contexts/DateTimePreferenceContext';
import {
fmtDate,
fmtDateTime,
fmtDateShort,
fmtTime,
fmtCurrency,
fmtNumber,
fmtISO,
} from '@/utils/datetime.util';
/**
* Returns bound date/time formatters that automatically apply the user's
* timezone preference (Local or UTC) from DateTimePreferenceContext.
*
* Usage:
* const { fmtDate, fmtDateTime } = useDateFormat()
* <span>{fmtDateTime(row.createdAt)}</span>
*
* fmtCurrency, fmtNumber, and fmtISO are also returned but are not
* affected by the timezone preference.
*/
export function useDateFormat() {
const { timezone } = useDateTimePreference();
const opts = { timezone };
return {
fmtDate: (v) => fmtDate(v, opts),
fmtDateTime: (v) => fmtDateTime(v, opts),
fmtDateShort: (v) => fmtDateShort(v, opts),
fmtTime: (v) => fmtTime(v, opts),
fmtCurrency: (v, currency) => fmtCurrency(v, currency),
fmtNumber: (v, decimals) => fmtNumber(v, { minimumFractionDigits: decimals }),
fmtISO,
timezone,
};
}
@@ -17,6 +17,7 @@ import {
PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis, PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis,
} from '@/components/ui/pagination'; } from '@/components/ui/pagination';
import { useAdminCourseReadingProgress } from '@/contexts/AdminCourseReadingProgressContext'; import { useAdminCourseReadingProgress } from '@/contexts/AdminCourseReadingProgressContext';
import { useDateFormat } from '@/hooks/useDateFormat';
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
@@ -64,6 +65,7 @@ function UserAvatar({ name, email, avatarUrl }) {
function UserDetailDialog({ open, onOpenChange, entry, courseId }) { function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
const { detailCache, detailLoading, fetchUserReadingProgress } = useAdminCourseReadingProgress(); const { detailCache, detailLoading, fetchUserReadingProgress } = useAdminCourseReadingProgress();
const { fmtDate, fmtDateShort } = useDateFormat();
const breakdown = entry ? detailCache[entry.user_id] : null; const breakdown = entry ? detailCache[entry.user_id] : null;
useEffect(() => { useEffect(() => {
@@ -72,9 +74,7 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
} }
}, [open, entry]); }, [open, entry]);
const lastSeen = entry?.last_accessed_at const lastSeen = entry?.last_accessed_at ? fmtDate(entry.last_accessed_at) : '—';
? new Date(entry.last_accessed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—';
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -154,7 +154,7 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
</span> </span>
{lesson.status === 'completed' && lesson.completed_at && ( {lesson.status === 'completed' && lesson.completed_at && (
<span className="text-xs text-muted-foreground whitespace-nowrap shrink-0"> <span className="text-xs text-muted-foreground whitespace-nowrap shrink-0">
{new Date(lesson.completed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} {fmtDateShort(lesson.completed_at)}
</span> </span>
)} )}
</div> </div>
@@ -178,9 +178,8 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
// ─── User summary card ──────────────────────────────────────────────────────── // ─── User summary card ────────────────────────────────────────────────────────
function UserCard({ entry, onOpen }) { function UserCard({ entry, onOpen }) {
const lastSeen = entry.last_accessed_at const { fmtDate } = useDateFormat();
? new Date(entry.last_accessed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) const lastSeen = entry.last_accessed_at ? fmtDate(entry.last_accessed_at) : '—';
: '—';
return ( return (
<button <button
@@ -51,7 +51,7 @@ export default function UnitsTable({ courseId }) {
const rowActions = useMemo(() => buildRowActions({ const rowActions = useMemo(() => buildRowActions({
onViewQuiz: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/quiz/view`), onViewQuiz: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/quiz/view`),
onQuiz: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/quiz`), onQuiz: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/quiz/edit`),
onViewLessons: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/lessons`), onViewLessons: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/lessons`),
onView: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/view`), onView: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/view`),
onEdit: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/edit`), onEdit: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/edit`),
@@ -0,0 +1,146 @@
import { useEffect, useState, useMemo } from "react";
import { BookOpen, Check, RotateCcw } from "lucide-react";
import api from "@/utils/api.util";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Command, CommandInput, CommandEmpty, CommandList, CommandItem } from "@/components/ui/command";
import { Skeleton } from "@/components/ui/skeleton";
/**
* CoursePicker
* Props:
* subscription — tier slug to filter courses (e.g. "premium"). Pass null/undefined to hide.
* selectedIds — Set<string> of selected course_id strings
* onChange — (Set<string>) => void
*/
export function CoursePicker({ subscription, selectedIds, onChange }) {
const [courses, setCourses] = useState([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
useEffect(() => {
if (!subscription) { setCourses([]); return; }
setLoading(true);
setSearch("");
api.get(`/admin/courses/by-subscription?slug=${encodeURIComponent(subscription)}`)
.then(({ data }) => setCourses(data.data ?? []))
.catch(() => setCourses([]))
.finally(() => setLoading(false));
}, [subscription]);
const filtered = useMemo(() => {
const q = search.toLowerCase();
if (!q) return courses;
return courses.filter(
(c) =>
c.title?.toLowerCase().includes(q) ||
c.description?.toLowerCase().includes(q)
);
}, [courses, search]);
const toggle = (id) => {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
onChange(next);
};
const checkAll = () => onChange(new Set(filtered.map((c) => String(c.course_id))));
const resetAll = () => onChange(new Set());
const selectedCount = selectedIds.size;
if (!subscription) return null;
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<p className="text-xs text-muted-foreground">
{loading
? "Loading courses…"
: `${courses.length} course${courses.length !== 1 ? "s" : ""} in this tier${selectedCount > 0 ? ` — ${selectedCount} selected` : ""}`
}
</p>
<div className="flex items-center gap-1.5">
<Button
type="button"
size="sm"
variant="outline"
className="h-7 px-2.5 text-xs"
disabled={loading || filtered.length === 0}
onClick={checkAll}
>
<Check className="size-3 mr-1" />
Check all
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 px-2.5 text-xs"
disabled={selectedCount === 0}
onClick={resetAll}
>
<RotateCcw className="size-3 mr-1" />
Reset
</Button>
</div>
</div>
{loading ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-12 w-full rounded-lg" />)}
</div>
) : courses.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-5 text-sm text-muted-foreground">
<BookOpen className="size-4 shrink-0" />
No courses found with subscription <span className="font-mono font-medium ml-1">"{subscription}"</span>.
</div>
) : (
<Command className="rounded-lg border shadow-none" shouldFilter={false}>
<CommandInput
placeholder="Search courses…"
value={search}
onValueChange={setSearch}
/>
<CommandList>
{filtered.length === 0 ? (
<CommandEmpty>No courses match your search.</CommandEmpty>
) : (
<ScrollArea className="h-64">
{filtered.map((course) => {
const id = String(course.course_id);
const checked = selectedIds.has(id);
return (
<CommandItem
key={id}
value={id}
onSelect={() => toggle(id)}
className="flex items-start gap-3 px-3 py-2.5 cursor-pointer"
>
<Checkbox
checked={checked}
onCheckedChange={() => toggle(id)}
className="mt-0.5 shrink-0"
onClick={(e) => e.stopPropagation()}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium leading-snug">{course.title}</span>
{course.description && (
<span className="text-xs text-muted-foreground line-clamp-1">
{course.description}
</span>
)}
</div>
</CommandItem>
);
})}
</ScrollArea>
)}
</CommandList>
</Command>
)}
</div>
);
}
@@ -1,6 +1,8 @@
import { useMemo, useRef, useCallback } from "react"; import { useMemo, useRef, useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTiers } from "@/contexts/AdminTiersContext"; import { useTiers } from "@/contexts/AdminTiersContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util";
import DataTable from "@/components/generic/Table/DataTable"; import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { buildDataColumns, columnPinning } from "../../config/tiers/payments/columns.config"; import { buildDataColumns, columnPinning } from "../../config/tiers/payments/columns.config";
@@ -18,6 +20,7 @@ export default function PaymentsTable({ planId = null }) {
}); });
const navigate = useNavigate(); const navigate = useNavigate();
const { fmtDateTime } = useDateFormat();
const { const {
payments, paymentAttributes, payments, paymentAttributes,
@@ -25,6 +28,13 @@ export default function PaymentsTable({ planId = null }) {
loading, fetchPayments, loading, fetchPayments,
} = useTiers(); } = useTiers();
const [tierCategories, setTierCategories] = useState([]);
const tierMap = useMemo(() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])), [tierCategories]);
useEffect(() => {
api.get("/admin/tiers/categories").then(({ data }) => setTierCategories(data.data ?? [])).catch(() => {});
}, []);
const exportConfig = { const exportConfig = {
allData: payments, allData: payments,
attributes: paymentAttributes, attributes: paymentAttributes,
@@ -51,8 +61,8 @@ export default function PaymentsTable({ planId = null }) {
}); });
const columns = useMemo( const columns = useMemo(
() => buildDataColumns(paymentAttributes, rowActions), () => buildDataColumns(paymentAttributes, rowActions, fmtDateTime, tierMap),
[paymentAttributes] [paymentAttributes, fmtDateTime, tierMap]
); );
// Pass planId as a locked filter to DataTable's onFetch // Pass planId as a locked filter to DataTable's onFetch
@@ -1,7 +1,9 @@
import { useMemo, useRef, useState, useCallback } from "react"; import { useMemo, useRef, useState, useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, Link } from "react-router-dom";
import { Layers } from "lucide-react";
import { useTiers } from "@/contexts/AdminTiersContext"; import { useTiers } from "@/contexts/AdminTiersContext";
import api from "@/utils/api.util";
import DataTable from "@/components/generic/Table/DataTable"; import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
@@ -24,6 +26,17 @@ export default function TierPlansTable() {
bulkDeletePlans, bulkRestorePlans, bulkDeletePlans, bulkRestorePlans,
} = useTiers(); } = useTiers();
const [hasAvailableCategories, setHasAvailableCategories] = useState(true);
useEffect(() => {
api.get("/admin/tiers/categories")
.then(({ data }) => {
const available = (data.data ?? []).filter((c) => !c.is_default && c.is_active);
setHasAvailableCategories(available.length > 0);
})
.catch(() => {});
}, []);
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const [archiveTarget, setArchiveTarget] = useState(null); const [archiveTarget, setArchiveTarget] = useState(null);
const [restoreTarget, setRestoreTarget] = useState(null); const [restoreTarget, setRestoreTarget] = useState(null);
@@ -86,6 +99,7 @@ export default function TierPlansTable() {
exportConfig, exportConfig,
navigate, navigate,
showArchived, showArchived,
hasAvailableCategories,
onToggleArchived: handleToggleArchived, onToggleArchived: handleToggleArchived,
getFilters: () => tableRefsRef.current.getFilters(), getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(), getSort: () => tableRefsRef.current.getSort(),
@@ -108,6 +122,18 @@ export default function TierPlansTable() {
return ( return (
<> <>
{!hasAvailableCategories && (
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-4">
<Layers className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-sm text-muted-foreground">
No tier categories defined.{" "}
<Link to="/admin/tiers/categories/add" className="underline text-primary font-medium">
Add a tier category
</Link>{" "}
before creating plans.
</p>
</div>
)}
<DataTable <DataTable
title="Tier Plans" title="Tier Plans"
data={plans} data={plans}
@@ -9,6 +9,8 @@ import { useDashboard } from "@/contexts/AdminDashboardContext";
import DataTable from "@/components/generic/Table/DataTable"; import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog"; import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
import { BanUserDialog } from "@/components/generic/Dialogs/BanUserDialog";
import { UnbanDialog } from "@/components/generic/Dialogs/UnbanDialog";
import { TableDashboard } from "@/components/generic/Dashboard/TableDashboard"; import { TableDashboard } from "@/components/generic/Dashboard/TableDashboard";
import { buildDataColumns, columnPinning } from "../../config/users/columns.config"; import { buildDataColumns, columnPinning } from "../../config/users/columns.config";
@@ -22,6 +24,10 @@ import { getTimestamp } from "@/utils/timestamp.util";
export default function UsersTable() { export default function UsersTable() {
const [archiveTarget, setArchiveTarget] = useState(null); const [archiveTarget, setArchiveTarget] = useState(null);
const [archiveIds, setArchiveIds] = useState(null); const [archiveIds, setArchiveIds] = useState(null);
const [banTarget, setBanTarget] = useState(null);
const [banIds, setBanIds] = useState(null);
const [unbanTarget, setUnbanTarget] = useState(null);
const [unbanIds, setUnbanIds] = useState(null);
const tableRefsRef = useRef({ const tableRefsRef = useRef({
getFilters: () => [], getFilters: () => [],
@@ -35,6 +41,7 @@ export default function UsersTable() {
const { const {
users, attributes, pagination, setPagination, loading, users, attributes, pagination, setPagination, loading,
fetchUsers, fetchUserFieldValues, deactivateUser, deactivateUsers, fetchUsers, fetchUserFieldValues, deactivateUser, deactivateUsers,
banUser, unbanUser, bulkBanUsers, bulkUnbanUsers,
} = useUsers(); } = useUsers();
const { usersDashboard, fetchUsersDashboard } = useDashboard(); const { usersDashboard, fetchUsersDashboard } = useDashboard();
@@ -55,7 +62,12 @@ export default function UsersTable() {
sheetName: "Users", sheetName: "Users",
}; };
const rowActions = buildRowActions({ navigate, onArchive: (row) => setArchiveTarget(row) }); const rowActions = buildRowActions({
navigate,
onArchive: (row) => setArchiveTarget(row),
onBan: (row) => setBanTarget(row),
onUnban: (row) => setUnbanTarget(row),
});
const toolbarActions = buildToolbarActions({ const toolbarActions = buildToolbarActions({
fetchUsers, pagination, exportConfig, navigate, fetchUsers, pagination, exportConfig, navigate,
getFilters: () => tableRefsRef.current.getFilters(), getFilters: () => tableRefsRef.current.getFilters(),
@@ -66,6 +78,8 @@ export default function UsersTable() {
exportConfig, exportConfig,
archiveUser: (row) => setArchiveTarget(row), archiveUser: (row) => setArchiveTarget(row),
archiveUsers: (ids) => setArchiveIds(ids), archiveUsers: (ids) => setArchiveIds(ids),
banUsers: (ids) => setBanIds(ids),
unbanUsers: (ids) => setUnbanIds(ids),
getTableInstance: () => tableRefsRef.current.tableInstance, getTableInstance: () => tableRefsRef.current.tableInstance,
}); });
@@ -79,6 +93,20 @@ export default function UsersTable() {
fetchUsersDashboard(); fetchUsersDashboard();
}; };
const handleBanSuccess = () => {
setBanTarget(null);
setBanIds(null);
tableRefsRef.current.resetSelection?.();
fetchUsers({ page: 1, limit: pagination.limit });
};
const handleUnbanSuccess = () => {
setUnbanTarget(null);
setUnbanIds(null);
tableRefsRef.current.resetSelection?.();
fetchUsers({ page: 1, limit: pagination.limit });
};
// ─── Attach filterId + filterValue to each stat so TableDashboard // ─── Attach filterId + filterValue to each stat so TableDashboard
// knows which column/value to apply when clicked ────────────────────── // knows which column/value to apply when clicked ──────────────────────
const dashboardStats = (usersDashboard?.stats ?? []).map((s) => ({ // ← was dashboard?.users?.stats const dashboardStats = (usersDashboard?.stats ?? []).map((s) => ({ // ← was dashboard?.users?.stats
@@ -167,6 +195,52 @@ export default function UsersTable() {
loading={loading} loading={loading}
onSuccess={handleArchiveSuccess} onSuccess={handleArchiveSuccess}
/> />
{/* Single ban */}
<BanUserDialog
open={!!banTarget}
onOpenChange={(v) => !v && setBanTarget(null)}
entity={banTarget}
entityLabel="User"
getName={(u) => u?.personal_info?.name?.full_name ?? u?.email}
onBan={(payload) => banUser(banTarget?.user_id, payload)}
loading={loading}
onSuccess={handleBanSuccess}
/>
{/* Bulk ban */}
<BanUserDialog
open={!!banIds}
onOpenChange={(v) => !v && setBanIds(null)}
ids={banIds ?? []}
entityLabel="User"
onBan={(payload) => bulkBanUsers({ ids: banIds, ...payload })}
loading={loading}
onSuccess={handleBanSuccess}
/>
{/* Single unban */}
<UnbanDialog
open={!!unbanTarget}
onOpenChange={(v) => !v && setUnbanTarget(null)}
entity={unbanTarget}
entityLabel="User"
getName={(u) => u?.personal_info?.name?.full_name ?? u?.email}
onUnban={(payload) => unbanUser(unbanTarget?.user_id, payload)}
loading={loading}
onSuccess={handleUnbanSuccess}
/>
{/* Bulk unban */}
<UnbanDialog
open={!!unbanIds}
onOpenChange={(v) => !v && setUnbanIds(null)}
ids={unbanIds ?? []}
entityLabel="User"
onUnban={(payload) => bulkUnbanUsers({ ids: unbanIds, ...payload })}
loading={loading}
onSuccess={handleUnbanSuccess}
/>
</> </>
); );
} }
@@ -2,6 +2,7 @@ import { Badge } from "@/components/ui/badge";
import { buildColumns } from "@/utils/table.util"; import { buildColumns } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn"; import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn"; import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
import { resolveTierBadge } from "@/utils/tierBadge.util";
export const columnPinning = { export const columnPinning = {
right: ["actions"], right: ["actions"],
@@ -17,9 +18,8 @@ const STATUS_BADGE = {
refunded: "outline", refunded: "outline",
}; };
const TIER_BADGE = { premium: "default", exclusive: "destructive" }; export function buildDataColumns(attributes, rowActions, fmtDateTime = (v) => v ? new Date(v).toLocaleString() : "—", tierMap = {}) {
const cellOverrides = {
const cellOverrides = {
status: (info) => ( status: (info) => (
<Badge variant={STATUS_BADGE[info.getValue()] ?? "outline"} className="capitalize"> <Badge variant={STATUS_BADGE[info.getValue()] ?? "outline"} className="capitalize">
{info.getValue()} {info.getValue()}
@@ -33,23 +33,20 @@ const cellOverrides = {
</span> </span>
); );
}, },
"plan.tier": (info) => ( "plan.tier": (info) => {
<Badge variant={TIER_BADGE[info.getValue()] ?? "outline"} className="capitalize"> const { cls, label } = resolveTierBadge(info.getValue(), tierMap);
{info.getValue()} return <Badge className={`${cls} capitalize`}>{label}</Badge>;
</Badge> },
),
paid_at: (info) => ( paid_at: (info) => (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{info.getValue() ? new Date(info.getValue()).toLocaleString() : "—"} {fmtDateTime(info.getValue())}
</span> </span>
), ),
"user.email": (info) => ( "user.email": (info) => (
<span className="text-sm">{info.getValue() ?? "—"}</span> <span className="text-sm">{info.getValue() ?? "—"}</span>
), ),
};
};
export function buildDataColumns(attributes, rowActions) {
const visibleAttributes = attributes.filter((a) => !a.hidden); const visibleAttributes = attributes.filter((a) => !a.hidden);
return [ return [
buildSelectionColumn(), buildSelectionColumn(),
@@ -1,4 +1,4 @@
import { Plus, RefreshCw, Download, Archive } from "lucide-react"; import { Plus, RefreshCw, Download, Archive, Layers } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util"; import { exportTableToExcel } from "@/utils/excel.util";
export function buildToolbarActions({ export function buildToolbarActions({
@@ -7,6 +7,7 @@ export function buildToolbarActions({
exportConfig, exportConfig,
navigate, navigate,
showArchived, showArchived,
hasAvailableCategories,
onToggleArchived, onToggleArchived,
getFilters, getFilters,
getSort, getSort,
@@ -38,13 +39,21 @@ export function buildToolbarActions({
tableInstance: getTableInstance(), tableInstance: getTableInstance(),
}), }),
}, },
{
key: "categories",
type: "button",
label: "Tier Categories",
icon: <Layers className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => navigate("/admin/tiers/categories"),
},
{ {
key: "create", key: "create",
type: "button", type: "button",
label: "New Plan", label: "New Plan",
icon: <Plus className="h-3.5 w-3.5" />, icon: <Plus className="h-3.5 w-3.5" />,
variant: "default", variant: "default",
hidden: showArchived, hidden: showArchived || !hasAvailableCategories,
onClick: () => navigate("/admin/tiers/plans/add"), onClick: () => navigate("/admin/tiers/plans/add"),
}, },
{ {
@@ -1,13 +1,26 @@
// modules/admin/config/user_groups/view/rowActions.config.jsx // modules/admin/config/user_groups/view/rowActions.config.jsx
import { UserMinus } from "lucide-react"; import { UserMinus, UserCheck } from "lucide-react";
/** /**
* @param {Object} deps * @param {Object} deps
* @param {Function} deps.onRemove Opens remove-member confirm dialog * @param {Function} deps.onRemove Opens remove-member confirm dialog
* @param {Function} [deps.onAssign] Opens assign-to-group dialog (NOGRP only)
* @param {boolean} [deps.isNoGroup] Switches to assign mode when viewing NOGRP
* @returns {Array} rowActions * @returns {Array} rowActions
*/ */
export function buildRowActions({ onRemove }) { export function buildRowActions({ onRemove, onAssign, isNoGroup }) {
if (isNoGroup) {
return [
{
key: "assign",
label: "Assign to Group",
icon: <UserCheck className="h-3.5 w-3.5" />,
onClick: (row) => onAssign(row),
},
];
}
return [ return [
{ {
key: "remove", key: "remove",
@@ -1,6 +1,6 @@
// modules/admin/config/user_groups/view/selection.config.jsx // modules/admin/config/user_groups/view/selection.config.jsx
import { Download, UserMinus } from "lucide-react"; import { Download, UserMinus, UserCheck } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util"; import { exportTableToExcel } from "@/utils/excel.util";
/** /**
@@ -8,9 +8,11 @@ import { exportTableToExcel } from "@/utils/excel.util";
* @param {Object} deps.exportConfig { allData, attributes, filename, sheetName } * @param {Object} deps.exportConfig { allData, attributes, filename, sheetName }
* @param {Function} deps.onRemoveMember Opens single remove dialog (row) * @param {Function} deps.onRemoveMember Opens single remove dialog (row)
* @param {Function} deps.onRemoveMembers Opens bulk remove dialog (ids[]) * @param {Function} deps.onRemoveMembers Opens bulk remove dialog (ids[])
* @param {Function} [deps.onAssignMembers] Opens assign dialog (ids[]) — NOGRP only
* @param {boolean} [deps.isNoGroup] Switches to assign mode when viewing NOGRP
*/ */
export function buildSelectionActions({ exportConfig, onRemoveMember, onRemoveMembers }) { export function buildSelectionActions({ exportConfig, onRemoveMember, onRemoveMembers, onAssignMembers, isNoGroup }) {
return [ const actions = [
{ {
key: "export-selected", key: "export-selected",
label: "Export", label: "Export",
@@ -18,7 +20,17 @@ export function buildSelectionActions({ exportConfig, onRemoveMember, onRemoveMe
onClick: (rows, table) => onClick: (rows, table) =>
exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table }), exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table }),
}, },
{ ];
if (isNoGroup) {
actions.push({
key: "assign-selected",
label: "Assign to Group",
icon: <UserCheck className="h-3.5 w-3.5" />,
onClick: (rows) => onAssignMembers(rows.map((r) => r.user_id)),
});
} else {
actions.push({
key: "remove-selected", key: "remove-selected",
label: "Remove", label: "Remove",
icon: <UserMinus className="h-3.5 w-3.5" />, icon: <UserMinus className="h-3.5 w-3.5" />,
@@ -26,9 +38,11 @@ export function buildSelectionActions({ exportConfig, onRemoveMember, onRemoveMe
onClick: (rows) => { onClick: (rows) => {
const ids = rows.map((r) => r.user_id); const ids = rows.map((r) => r.user_id);
ids.length === 1 ids.length === 1
? onRemoveMember(rows[0]) // single confirm dialog ? onRemoveMember(rows[0])
: onRemoveMembers(ids); // bulk confirm dialog : onRemoveMembers(ids);
}, },
}, });
]; }
return actions;
} }
@@ -3,15 +3,17 @@
// //
// Each onClick receives the row's data object from buildRowActionsColumn. // Each onClick receives the row's data object from buildRowActionsColumn.
import { Eye, Pencil, Archive } from "lucide-react"; import { Eye, Pencil, Archive, ShieldBan, ShieldCheck } from "lucide-react";
/** /**
* @param {Object} deps * @param {Object} deps
* @param {Function} deps.navigate React Router navigate * @param {Function} deps.navigate React Router navigate
* @param {Function} deps.archiveUser Archive handler from useManagement * @param {Function} deps.onArchive Opens archive dialog
* @param {Function} deps.onBan Opens ban dialog
* @param {Function} deps.onUnban Opens unban dialog
* @returns {Array} rowActions * @returns {Array} rowActions
*/ */
export function buildRowActions({ navigate, onArchive }) { export function buildRowActions({ navigate, onArchive, onBan, onUnban }) {
return [ return [
{ {
key: "view", key: "view",
@@ -26,14 +28,31 @@ export function buildRowActions({ navigate, onArchive }) {
onClick: (row) => navigate(`edit/${row.user_id}`), onClick: (row) => navigate(`edit/${row.user_id}`),
disabled: (row) => row.role === "super_admin", disabled: (row) => row.role === "super_admin",
}, },
{
key: "ban",
label: "Ban User",
className: "text-destructive focus:text-destructive",
icon: <ShieldBan className="h-3.5 w-3.5" />,
onClick: (row) => onBan(row),
hidden: (row) => !!row.is_banned || !row.is_active,
separator: true,
},
{
key: "unban",
label: "Unban User",
className: "text-emerald-600 focus:text-emerald-600",
icon: <ShieldCheck className="h-3.5 w-3.5" />,
onClick: (row) => onUnban(row),
hidden: (row) => !row.is_banned,
separator: true,
},
{ {
key: "archive", key: "archive",
label: "Archive", label: "Archive",
className: "text-destructive focus:text-destructive", className: "text-destructive focus:text-destructive",
icon: <Archive className="h-3.5 w-3.5" />, icon: <Archive className="h-3.5 w-3.5" />,
onClick: (row) => onArchive(row), // ← opens dialog, not deactivateUser onClick: (row) => onArchive(row),
hidden: (row) => !row.is_active, // ← hide if already inactive hidden: (row) => !row.is_active,
separator: true,
}, },
]; ];
} }
@@ -1,14 +1,8 @@
// config/selection.config.jsx // config/selection.config.jsx
import { Download, Archive, Trash2 } from "lucide-react"; import { Download, Archive, ShieldBan, ShieldCheck } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util"; import { exportTableToExcel } from "@/utils/excel.util";
/** export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers, banUsers, unbanUsers, getTableInstance }) {
* @param {Object} deps
* @param {Object} deps.exportConfig { allData, attributes, filename, sheetName }
* @param {Function} deps.archiveUser Archive handler from useManagement
* @param {Function} deps.deleteUser Delete handler from useManagement
*/
export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers, getTableInstance }) {
return [ return [
{ {
key: "export-selected", key: "export-selected",
@@ -17,6 +11,27 @@ export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers,
onClick: (rows, table) => onClick: (rows, table) =>
exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table ?? getTableInstance() }), exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table ?? getTableInstance() }),
}, },
{
key: "ban-selected",
label: "Ban",
icon: <ShieldBan className="h-3.5 w-3.5" />,
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
onClick: (rows) => {
const ids = rows.filter((r) => !r.is_banned).map((r) => r.user_id);
if (ids.length) banUsers(ids);
},
hidden: (rows) => rows.every((r) => r.is_banned || !r.is_active),
},
{
key: "unban-selected",
label: "Unban",
icon: <ShieldCheck className="h-3.5 w-3.5" />,
onClick: (rows) => {
const ids = rows.filter((r) => r.is_banned).map((r) => r.user_id);
if (ids.length) unbanUsers(ids);
},
hidden: (rows) => rows.every((r) => !r.is_banned),
},
{ {
key: "archive-selected", key: "archive-selected",
label: "Archive", label: "Archive",
@@ -25,8 +40,8 @@ export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers,
onClick: (rows) => { onClick: (rows) => {
const ids = rows.map((r) => r.user_id); const ids = rows.map((r) => r.user_id);
ids.length === 1 ids.length === 1
? archiveUser(rows[0]) // opens single dialog ? archiveUser(rows[0])
: archiveUsers(ids); // opens bulk dialog : archiveUsers(ids);
}, },
hidden: (rows) => rows.every((r) => r.status === "archived"), hidden: (rows) => rows.every((r) => r.status === "archived"),
}, },
@@ -15,6 +15,8 @@ import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data"; import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
import { timeAgo } from "@/utils/timestamp.util"; import { timeAgo } from "@/utils/timestamp.util";
import { fmtISO } from "@/utils/datetime.util";
import { useDateFormat } from "@/hooks/useDateFormat";
const BREADCRUMB = [ const BREADCRUMB = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" }, { label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -25,7 +27,7 @@ const LIMIT = 20;
function toDateStr(d) { function toDateStr(d) {
if (!d) return undefined; if (!d) return undefined;
return d.toLocaleDateString("en-CA"); // YYYY-MM-DD return fmtISO(d);
} }
export default function ActivityFeed() { export default function ActivityFeed() {
@@ -196,10 +198,9 @@ export default function ActivityFeed() {
// ─── DatePickerButton ───────────────────────────────────────────────────────── // ─── DatePickerButton ─────────────────────────────────────────────────────────
function DatePickerButton({ value, onChange, placeholder, disabled }) { function DatePickerButton({ value, onChange, placeholder, disabled }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { fmtDate } = useDateFormat();
const label = value const label = value ? fmtDate(value) : placeholder;
? value.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
: placeholder;
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
@@ -252,6 +253,7 @@ function initials(name, email) {
} }
function ActivityRow({ row, onViewUser }) { function ActivityRow({ row, onViewUser }) {
const { fmtDateTime } = useDateFormat();
const { label, className } = getActionBadge(row.action); const { label, className } = getActionBadge(row.action);
const ts = row.created_at; const ts = row.created_at;
const displayName = row.full_name ?? row.email ?? `User #${row.user_id}`; const displayName = row.full_name ?? row.email ?? `User #${row.user_id}`;
@@ -294,10 +296,7 @@ function ActivityRow({ row, onViewUser }) {
<span className="text-muted-foreground cursor-default">{timeAgo(ts)}</span> <span className="text-muted-foreground cursor-default">{timeAgo(ts)}</span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="left"> <TooltipContent side="left">
{new Date(ts).toLocaleString("en-US", { {fmtDateTime(ts)}
month: "short", day: "numeric", year: "numeric",
hour: "numeric", minute: "2-digit", second: "2-digit",
})}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
@@ -11,6 +11,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data"; import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
import { timeAgo } from "@/utils/timestamp.util"; import { timeAgo } from "@/utils/timestamp.util";
import { useDateFormat } from "@/hooks/useDateFormat";
const LIMIT = 20; const LIMIT = 20;
@@ -156,6 +157,7 @@ export default function UserActivityPage() {
// ─── Item ────────────────────────────────────────────────────────────────────── // ─── Item ──────────────────────────────────────────────────────────────────────
function ActivityItem({ row }) { function ActivityItem({ row }) {
const { fmtDateTime } = useDateFormat();
const { label, className } = getActionBadge(row.action); const { label, className } = getActionBadge(row.action);
const ts = row.created_at; const ts = row.created_at;
@@ -188,10 +190,7 @@ function ActivityItem({ row }) {
<span className="text-muted-foreground cursor-default">{timeAgo(ts)}</span> <span className="text-muted-foreground cursor-default">{timeAgo(ts)}</span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="left"> <TooltipContent side="left">
{new Date(ts).toLocaleString("en-US", { {fmtDateTime(ts)}
month: "short", day: "numeric", year: "numeric",
hour: "numeric", minute: "2-digit", second: "2-digit",
})}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
@@ -6,6 +6,7 @@ import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2 } from
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext"; import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -162,6 +163,7 @@ function StatCard({ label, value, tone = "default" }) {
// ─── Advertisement card ───────────────────────────────────────────────────── // ─── Advertisement card ─────────────────────────────────────────────────────
function AdvertisementCard({ ad, onView, onEdit, onArchive }) { function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
const { fmtDate } = useDateFormat();
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {}; const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {}; const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
const TypeIcon = typeMeta.icon ?? Megaphone; const TypeIcon = typeMeta.icon ?? Megaphone;
@@ -169,7 +171,7 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
const previewSrc = ad.image?.thumbnail_url || ad.image?.file_url || ad.image_url || null; const previewSrc = ad.image?.thumbnail_url || ad.image?.file_url || ad.image_url || null;
const isDimmed = ad.status === "expired" || ad.status === "archived"; const isDimmed = ad.status === "expired" || ad.status === "archived";
const dateRange = formatDateRange(ad.start_date, ad.end_date); const dateRange = formatDateRange(ad.start_date, ad.end_date, fmtDate);
return ( return (
<div className={`bg-background rounded-lg border overflow-hidden flex flex-col ${isDimmed ? "opacity-70" : ""}`}> <div className={`bg-background rounded-lg border overflow-hidden flex flex-col ${isDimmed ? "opacity-70" : ""}`}>
@@ -255,12 +257,10 @@ function EmptyState({ onCreate }) {
// ─── Helpers ──────────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
function formatDateRange(start, end) { function formatDateRange(start, end, fmtDate) {
if (!start && !end) return null; if (!start && !end) return null;
const fmt = (d) => new Date(d).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); if (start && end) return `${fmtDate(start)} - ${fmtDate(end)}`;
if (start) return `Starts ${fmtDate(start)}`;
if (start && end) return `${fmt(start)} - ${fmt(end)}`; if (end) return `Ends ${fmtDate(end)}`;
if (start) return `Starts ${fmt(start)}`;
if (end) return `Ends ${fmt(end)}`;
return null; return null;
} }
@@ -6,6 +6,7 @@ import { House, Edit, ArrowLeft, Megaphone, MousePointerClick, ExternalLink } fr
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext"; import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
@@ -37,19 +38,13 @@ function Field({ label, children }) {
); );
} }
function formatDateTime(iso) {
if (!iso) return "—";
return new Date(iso).toLocaleString(undefined, {
month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit",
});
}
// ─── Page ─────────────────────────────────────────────────────────────────── // ─── Page ───────────────────────────────────────────────────────────────────
export default function ViewAdvertisement() { export default function ViewAdvertisement() {
const navigate = useNavigate(); const navigate = useNavigate();
const { advertisementId } = useParams(); const { advertisementId } = useParams();
const { fetchAdvertisement, loading } = useAdvertisements(); const { fetchAdvertisement, loading } = useAdvertisements();
const { fmtDateTime } = useDateFormat();
const [advertisement, setAdvertisement] = useState(null); const [advertisement, setAdvertisement] = useState(null);
@@ -171,8 +166,8 @@ export default function ViewAdvertisement() {
{/* ── Scheduling & display ──────────────────────────────────── */} {/* ── Scheduling & display ──────────────────────────────────── */}
<SectionCard title="Scheduling & display"> <SectionCard title="Scheduling & display">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Field label="Start date">{formatDateTime(advertisement.start_date)}</Field> <Field label="Start date">{fmtDateTime(advertisement.start_date)}</Field>
<Field label="End date">{formatDateTime(advertisement.end_date)}</Field> <Field label="End date">{fmtDateTime(advertisement.end_date)}</Field>
<Field label="Order">{advertisement.order ?? 0}</Field> <Field label="Order">{advertisement.order ?? 0}</Field>
<Field label="Active">{advertisement.is_active ? "Yes" : "No"}</Field> <Field label="Active">{advertisement.is_active ? "Yes" : "No"}</Field>
</div> </div>
@@ -191,9 +186,9 @@ export default function ViewAdvertisement() {
<SectionCard title="Audit"> <SectionCard title="Audit">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Field label="Created by">{advertisement.creator?.full_name || "—"}</Field> <Field label="Created by">{advertisement.creator?.full_name || "—"}</Field>
<Field label="Created at">{formatDateTime(advertisement.createdAt)}</Field> <Field label="Created at">{fmtDateTime(advertisement.createdAt)}</Field>
<Field label="Last updated by">{advertisement.updater?.full_name || "—"}</Field> <Field label="Last updated by">{advertisement.updater?.full_name || "—"}</Field>
<Field label="Last updated at">{formatDateTime(advertisement.updatedAt)}</Field> <Field label="Last updated at">{fmtDateTime(advertisement.updatedAt)}</Field>
</div> </div>
</SectionCard> </SectionCard>
</div> </div>
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, Music2 } from "lucide-react"; import { ArrowLeft, Lock, Globe, Music2 } from "lucide-react";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext"; import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
@@ -29,6 +30,7 @@ function MetaRow({ label, value }) {
export default function ViewAudioAsset() { export default function ViewAudioAsset() {
const { assetId } = useParams(); const { assetId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { fmtDateTime } = useDateFormat();
const { selectedAsset, loading, fetchAsset } = useAssets(); const { selectedAsset, loading, fetchAsset } = useAssets();
const [streamUrl, setStreamUrl] = useState(null); const [streamUrl, setStreamUrl] = useState(null);
@@ -138,8 +140,8 @@ export default function ViewAudioAsset() {
<Separator className="my-2" /> <Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} /> <MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} /> <MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} /> <MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div> </div>
{a.description && ( {a.description && (
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, FileText } from "lucide-react"; import { ArrowLeft, Lock, Globe, FileText } from "lucide-react";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext"; import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
@@ -26,6 +27,7 @@ const PREVIEWABLE = ["pdf", "txt", "html", "htm", "csv", "md"];
export default function ViewDocumentAsset() { export default function ViewDocumentAsset() {
const { assetId } = useParams(); const { assetId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { fmtDateTime } = useDateFormat();
const { selectedAsset, loading, fetchAsset } = useAssets(); const { selectedAsset, loading, fetchAsset } = useAssets();
const [streamUrl, setStreamUrl] = useState(null); const [streamUrl, setStreamUrl] = useState(null);
@@ -132,8 +134,8 @@ export default function ViewDocumentAsset() {
<Separator className="my-2" /> <Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} /> <MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} /> <MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} /> <MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div> </div>
{a.description && ( {a.description && (
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { ArrowLeft, Lock, Globe } from "lucide-react"; import { ArrowLeft, Lock, Globe } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext"; import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
@@ -24,6 +25,7 @@ function MetaRow({ label, value }) {
export default function ViewImageAsset() { export default function ViewImageAsset() {
const { assetId } = useParams(); const { assetId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { fmtDateTime } = useDateFormat();
const { selectedAsset, loading, fetchAsset } = useAssets(); const { selectedAsset, loading, fetchAsset } = useAssets();
const [streamUrl, setStreamUrl] = useState(null); const [streamUrl, setStreamUrl] = useState(null);
@@ -124,8 +126,8 @@ export default function ViewImageAsset() {
<Separator className="my-2" /> <Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} /> <MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} /> <MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} /> <MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div> </div>
{a.description && ( {a.description && (
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe } from "lucide-react"; import { ArrowLeft, Lock, Globe } from "lucide-react";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext"; import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
@@ -34,6 +35,7 @@ function formatDuration(seconds) {
export default function ViewVideoAsset() { export default function ViewVideoAsset() {
const { assetId } = useParams(); const { assetId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { fmtDateTime } = useDateFormat();
const { selectedAsset, loading, fetchAsset } = useAssets(); const { selectedAsset, loading, fetchAsset } = useAssets();
const [streamUrl, setStreamUrl] = useState(null); const [streamUrl, setStreamUrl] = useState(null);
@@ -160,8 +162,8 @@ export default function ViewVideoAsset() {
<Separator className="my-2" /> <Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p> <p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} /> <MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} /> <MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} /> <MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div> </div>
{a.description && ( {a.description && (
+15 -3
View File
@@ -1,4 +1,5 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { useForm, useFieldArray } from "react-hook-form"; import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -6,6 +7,7 @@ import { ArrowLeft, House, Plus, Trash2 } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import api from "@/utils/api.util";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -29,7 +31,7 @@ const schema = z.object({
course_code: z.string().optional(), course_code: z.string().optional(),
order_index: z.coerce.number().min(0).default(0), order_index: z.coerce.number().min(0).default(0),
level: z.enum(["beginner", "intermediate", "advanced"]).optional(), level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
subscription: z.enum(["free", "premium"]).default("free"), subscription: z.string().min(1, "Subscription is required.").default("free"),
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]), objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]),
}); });
@@ -61,6 +63,13 @@ export default function AddCourse() {
const { createCourse, loading } = useCourses(); const { createCourse, loading } = useCourses();
const { user } = useAuth(); const { user } = useAuth();
const [tierCategories, setTierCategories] = useState([]);
useEffect(() => {
api.get("/admin/tiers/categories")
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
.catch(() => {});
}, []);
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -178,8 +187,11 @@ export default function AddCourse() {
<SelectValue placeholder="Select subscription" /> <SelectValue placeholder="Select subscription" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="free">Free</SelectItem> {tierCategories.map((c) => (
<SelectItem value="premium">Premium</SelectItem> <SelectItem key={c.slug} value={c.slug}>
{c.name}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<FieldError message={errors.subscription?.message} /> <FieldError message={errors.subscription?.message} />
@@ -16,9 +16,9 @@ import api from "@/utils/api.util";
// ── Dirty-check snapshot ────────────────────────────────────────────────────── // ── Dirty-check snapshot ──────────────────────────────────────────────────────
function snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, questions }) { function snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions }) {
return JSON.stringify({ return JSON.stringify({
title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions,
questions: questions.map((q) => ({ questions: questions.map((q) => ({
question_id: q.question_id ?? null, question_id: q.question_id ?? null,
question: q.question, question: q.question,
@@ -222,6 +222,7 @@ export default function CourseAssessment() {
const [maxQuestions, setMaxQuestions] = useState(""); const [maxQuestions, setMaxQuestions] = useState("");
const [maxAttempts, setMaxAttempts] = useState(3); const [maxAttempts, setMaxAttempts] = useState(3);
const [cooldownHours, setCooldownHours] = useState(24); const [cooldownHours, setCooldownHours] = useState(24);
const [shuffleQuestions, setShuffleQuestions] = useState(false);
// ── Update confirmation dialog ───────────────────────────────────────────── // ── Update confirmation dialog ─────────────────────────────────────────────
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
@@ -253,10 +254,11 @@ export default function CourseAssessment() {
const mq = assessment.max_questions ?? ""; const mq = assessment.max_questions ?? "";
const ma = assessment.max_attempts ?? 3; const ma = assessment.max_attempts ?? 3;
const ch = assessment.cooldown_hours ?? 24; const ch = assessment.cooldown_hours ?? 24;
const sq = assessment.shuffle_questions === true || assessment.shuffle_questions === 1;
const qs = (assessment.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] })); const qs = (assessment.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
setTitle(t); setPassingScore(ps); setTimeLimit(tl); setIsRequired(ir); setTitle(t); setPassingScore(ps); setTimeLimit(tl); setIsRequired(ir);
setMaxQuestions(mq); setMaxAttempts(ma); setCooldownHours(ch); setQuestions(qs); setMaxQuestions(mq); setMaxAttempts(ma); setCooldownHours(ch); setShuffleQuestions(sq); setQuestions(qs);
initialSnapshot.current = snapAssessment({ title: t, passingScore: ps, timeLimit: tl, isRequired: ir, maxQuestions: mq, maxAttempts: ma, cooldownHours: ch, questions: qs }); initialSnapshot.current = snapAssessment({ title: t, passingScore: ps, timeLimit: tl, isRequired: ir, maxQuestions: mq, maxAttempts: ma, cooldownHours: ch, shuffleQuestions: sq, questions: qs });
}, [assessment]); }, [assessment]);
// ── Measure sticky header → --assessment-h ──────────────────────────────── // ── Measure sticky header → --assessment-h ────────────────────────────────
@@ -366,7 +368,7 @@ export default function CourseAssessment() {
// ── Dirty tracking ───────────────────────────────────────────────────────── // ── Dirty tracking ─────────────────────────────────────────────────────────
const isDirty = initialSnapshot.current === null const isDirty = initialSnapshot.current === null
? questions.length > 0 ? questions.length > 0
: snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, questions }) !== initialSnapshot.current; : snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions }) !== initialSnapshot.current;
// ── Save ─────────────────────────────────────────────────────────────────── // ── Save ───────────────────────────────────────────────────────────────────
const handleSave = async () => { const handleSave = async () => {
@@ -386,6 +388,7 @@ export default function CourseAssessment() {
max_questions: maxQuestions ? parseInt(maxQuestions) : null, max_questions: maxQuestions ? parseInt(maxQuestions) : null,
max_attempts: parseInt(maxAttempts) || 3, max_attempts: parseInt(maxAttempts) || 3,
cooldown_hours: parseInt(cooldownHours) || 24, cooldown_hours: parseInt(cooldownHours) || 24,
shuffle_questions: shuffleQuestions,
updatedBy: user?.user_id, updatedBy: user?.user_id,
createdBy: user?.user_id, createdBy: user?.user_id,
}; };
@@ -430,7 +433,7 @@ export default function CourseAssessment() {
} }
} }
initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, questions }); initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions });
}; };
const handleConfirmSave = async () => { const handleConfirmSave = async () => {
@@ -589,6 +592,17 @@ export default function CourseAssessment() {
Required to complete course Required to complete course
</Label> </Label>
</div> </div>
<div className="flex items-center gap-3">
<Checkbox
id="assessment_shuffle"
checked={shuffleQuestions === true}
onCheckedChange={(val) => setShuffleQuestions(val)}
/>
<Label htmlFor="assessment_shuffle" className="cursor-pointer">
Shuffle question order for each attempt
</Label>
</div>
</div> </div>
{maxQuestions && parseInt(maxQuestions) < questions.length && ( {maxQuestions && parseInt(maxQuestions) < questions.length && (
+17 -7
View File
@@ -10,6 +10,7 @@ import { PageMeta } from "@/contexts/MetadataContext";
import CourseInstructorPicker from "@/modules/admin/components/courses/CourseInstructorPicker"; import CourseInstructorPicker from "@/modules/admin/components/courses/CourseInstructorPicker";
import { useCategories } from "@/contexts/AdminCategoriesContext"; import { useCategories } from "@/contexts/AdminCategoriesContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import api from "@/utils/api.util";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -47,7 +48,7 @@ const schema = z.object({
course_code: z.string().optional(), course_code: z.string().optional(),
order_index: z.coerce.number().min(0).default(0), order_index: z.coerce.number().min(0).default(0),
level: z.enum(["beginner", "intermediate", "advanced"]).optional(), level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
subscription: z.enum(["free", "premium"]).default("free"), subscription: z.string().min(1, "Subscription is required.").default("free"),
objectives: z objectives: z
.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })) .array(z.object({ text: z.string().min(1, "Objective cannot be empty.") }))
.default([]), .default([]),
@@ -100,6 +101,14 @@ export default function EditCourse() {
const { categories: allCategories, fetchCategories } = useCategories(); const { categories: allCategories, fetchCategories } = useCategories();
const { user } = useAuth(); const { user } = useAuth();
// ─── Tier categories ──────────────────────────────────────────────────────
const [tierCategories, setTierCategories] = useState([]);
useEffect(() => {
api.get("/admin/tiers/categories")
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
.catch(() => {});
}, []);
// ─── Categories state ───────────────────────────────────────────────────── // ─── Categories state ─────────────────────────────────────────────────────
const [selectedCategoryIds, setSelectedCategoryIds] = useState([]); const [selectedCategoryIds, setSelectedCategoryIds] = useState([]);
const [categoriesDirty, setCategoriesDirty] = useState(false); const [categoriesDirty, setCategoriesDirty] = useState(false);
@@ -290,7 +299,7 @@ export default function EditCourse() {
const result = await updateCourse(courseId, payload); const result = await updateCourse(courseId, payload);
if (!result) return; if (!result) return;
navigate(-1); navigate(`/admin/courses/${courseId}/view`);
}; };
return ( return (
@@ -393,16 +402,17 @@ export default function EditCourse() {
<Label>Subscription</Label> <Label>Subscription</Label>
<Select <Select
value={watch("subscription") ?? "free"} value={watch("subscription") ?? "free"}
onValueChange={(val) => onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
setValue("subscription", val, { shouldDirty: true })
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select subscription" /> <SelectValue placeholder="Select subscription" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="free">Free</SelectItem> {tierCategories.map((c) => (
<SelectItem value="premium">Premium</SelectItem> <SelectItem key={c.slug} value={c.slug}>
{c.name}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<FieldError message={errors.subscription?.message} /> <FieldError message={errors.subscription?.message} />
@@ -7,6 +7,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -103,6 +104,7 @@ function QuestionView({ question, index }) {
// ─── Completions tab ────────────────────────────────────────────────────────── // ─── Completions tab ──────────────────────────────────────────────────────────
function CompletionRow({ row }) { function CompletionRow({ row }) {
const { fmtDate, fmtDateTime } = useDateFormat();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
<> <>
@@ -112,8 +114,15 @@ function CompletionRow({ row }) {
> >
<td className="px-4 py-3"> <td className="px-4 py-3">
<div> <div>
<p className="text-sm font-medium">{row.full_name}</p> <div className="flex items-center gap-1.5">
<p className="text-xs text-muted-foreground">{row.email}</p> <p className="text-sm font-medium">{row.full_name ?? "Unknown User"}</p>
{row.deleted && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-[18px] leading-none text-muted-foreground border-muted-foreground/30 shrink-0">
Deleted
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{row.email ?? "—"}</p>
</div> </div>
</td> </td>
<td className="px-4 py-3 text-center text-sm">{row.attempt_count}</td> <td className="px-4 py-3 text-center text-sm">{row.attempt_count}</td>
@@ -128,7 +137,7 @@ function CompletionRow({ row }) {
)} )}
</td> </td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap"> <td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{row.latest_at ? new Date(row.latest_at).toLocaleDateString() : "—"} {row.latest_at ? fmtDate(row.latest_at) : "—"}
</td> </td>
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">
{open ? <ChevronUp className="h-4 w-4 mx-auto text-muted-foreground" /> : <ChevronDown className="h-4 w-4 mx-auto text-muted-foreground" />} {open ? <ChevronUp className="h-4 w-4 mx-auto text-muted-foreground" /> : <ChevronDown className="h-4 w-4 mx-auto text-muted-foreground" />}
@@ -158,7 +167,7 @@ function CompletionRow({ row }) {
? <span className="text-green-600 dark:text-green-400 font-medium">Pass</span> ? <span className="text-green-600 dark:text-green-400 font-medium">Pass</span>
: <span className="text-red-500 font-medium">Fail</span>} : <span className="text-red-500 font-medium">Fail</span>}
</td> </td>
<td className="py-1.5 text-muted-foreground">{new Date(a.createdAt).toLocaleString()}</td> <td className="py-1.5 text-muted-foreground">{fmtDateTime(a.createdAt)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -235,6 +244,7 @@ function fmtDuration(secs) {
} }
function SessionsTab({ sessions, loading }) { function SessionsTab({ sessions, loading }) {
const { fmtDateTime } = useDateFormat();
if (loading) return <div className="space-y-3">{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-10 w-full rounded-lg" />)}</div>; if (loading) return <div className="space-y-3">{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-10 w-full rounded-lg" />)}</div>;
if (!sessions) return ( if (!sessions) return (
@@ -274,8 +284,15 @@ function SessionsTab({ sessions, loading }) {
{rows.map((s) => ( {rows.map((s) => (
<tr key={s.session_id} className="border-b last:border-0 hover:bg-muted/30 transition-colors"> <tr key={s.session_id} className="border-b last:border-0 hover:bg-muted/30 transition-colors">
<td className="px-4 py-3"> <td className="px-4 py-3">
<p className="text-sm font-medium">{s.full_name}</p> <div className="flex items-center gap-1.5">
<p className="text-xs text-muted-foreground">{s.email}</p> <p className="text-sm font-medium">{s.full_name ?? "Unknown User"}</p>
{s.deleted && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-[18px] leading-none text-muted-foreground border-muted-foreground/30 shrink-0">
Deleted
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{s.email ?? "—"}</p>
</td> </td>
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">
<Badge className={SESSION_BADGE[s.status] ?? ""}> <Badge className={SESSION_BADGE[s.status] ?? ""}>
@@ -283,10 +300,10 @@ function SessionsTab({ sessions, loading }) {
</Badge> </Badge>
</td> </td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap"> <td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{new Date(s.started_at).toLocaleString()} {fmtDateTime(s.started_at)}
</td> </td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap"> <td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{s.expires_at ? new Date(s.expires_at).toLocaleString() : "—"} {s.expires_at ? fmtDateTime(s.expires_at) : "—"}
</td> </td>
<td className="px-4 py-3 text-center text-sm">{fmtDuration(s.time_spent_seconds)}</td> <td className="px-4 py-3 text-center text-sm">{fmtDuration(s.time_spent_seconds)}</td>
</tr> </tr>
@@ -6,6 +6,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList"; import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -46,7 +47,6 @@ const LEVEL_BADGE = {
const SUBSCRIPTION_BADGE = { const SUBSCRIPTION_BADGE = {
free: "secondary", free: "secondary",
premium: "default",
}; };
function LoadingSkeleton() { function LoadingSkeleton() {
@@ -69,6 +69,7 @@ export default function ViewCourse() {
const navigate = useNavigate(); const navigate = useNavigate();
const { courseId } = useParams(); const { courseId } = useParams();
const { fetchCourse, course, loading } = useCourses(); const { fetchCourse, course, loading } = useCourses();
const { fmtDateTime } = useDateFormat();
useEffect(() => { useEffect(() => {
fetchCourse(courseId); fetchCourse(courseId);
@@ -232,10 +233,10 @@ export default function ViewCourse() {
<InfoRow label="Created By">{course.creator?.full_name ?? course.createdBy ?? "—"}</InfoRow> <InfoRow label="Created By">{course.creator?.full_name ?? course.createdBy ?? "—"}</InfoRow>
<InfoRow label="Updated By">{course.updater?.full_name ?? course.updatedBy ?? "—"}</InfoRow> <InfoRow label="Updated By">{course.updater?.full_name ?? course.updatedBy ?? "—"}</InfoRow>
<InfoRow label="Created At"> <InfoRow label="Created At">
{course.createdAt ? new Date(course.createdAt).toLocaleString() : "—"} {course.createdAt ? fmtDateTime(course.createdAt) : "—"}
</InfoRow> </InfoRow>
<InfoRow label="Updated At"> <InfoRow label="Updated At">
{course.updatedAt ? new Date(course.updatedAt).toLocaleString() : "—"} {course.updatedAt ? fmtDateTime(course.updatedAt) : "—"}
</InfoRow> </InfoRow>
</div> </div>
</SectionCard> </SectionCard>
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House } from "lucide-react"; import { ArrowLeft, House } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -13,6 +14,7 @@ export default function LessonsList() {
const navigate = useNavigate(); const navigate = useNavigate();
const { courseId, unitId } = useParams(); const { courseId, unitId } = useParams();
const { fetchCourse, fetchUnit, course, unit, loading } = useCourses(); const { fetchCourse, fetchUnit, course, unit, loading } = useCourses();
const { fmtDate } = useDateFormat();
const [initializing, setInitializing] = useState(true); const [initializing, setInitializing] = useState(true);
useEffect(() => { useEffect(() => {
@@ -81,13 +83,13 @@ export default function LessonsList() {
<div className="bg-muted/60 rounded-lg p-3 space-y-1"> <div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p> <p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p>
<p className="font-semibold text-sm"> <p className="font-semibold text-sm">
{unit?.createdAt ? new Date(unit.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'} {unit?.createdAt ? fmtDate(unit.createdAt) : '—'}
</p> </p>
</div> </div>
<div className="bg-muted/60 rounded-lg p-3 space-y-1"> <div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p> <p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p>
<p className="font-semibold text-sm"> <p className="font-semibold text-sm">
{unit?.updatedAt ? new Date(unit.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'} {unit?.updatedAt ? fmtDate(unit.updatedAt) : '—'}
</p> </p>
</div> </div>
</div> </div>
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { House, Pencil, ArrowLeft, Clock, ListChecks, FileText } from "lucide-react"; import { House, Pencil, ArrowLeft, Clock, ListChecks, FileText } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -21,6 +22,7 @@ export default function ViewLesson() {
const navigate = useNavigate(); const navigate = useNavigate();
const { courseId, unitId, lessonId } = useParams(); const { courseId, unitId, lessonId } = useParams();
const { fetchLesson, course, unit } = useCourses(); const { fetchLesson, course, unit } = useCourses();
const { fmtDate } = useDateFormat();
const [lesson, setLesson] = useState(null); const [lesson, setLesson] = useState(null);
const [initializing, setInitializing] = useState(true); const [initializing, setInitializing] = useState(true);
@@ -40,9 +42,6 @@ export default function ViewLesson() {
); );
} }
const formatDate = (iso) =>
iso ? new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "—";
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted/60 h-full">
<PageMeta title={lesson ? `${lesson.title} - STARR` : undefined} /> <PageMeta title={lesson ? `${lesson.title} - STARR` : undefined} />
@@ -83,11 +82,11 @@ export default function ViewLesson() {
</div> </div>
<div className="bg-white rounded-lg border p-3 space-y-1"> <div className="bg-white rounded-lg border p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p> <p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p>
<p className="font-semibold text-sm">{formatDate(lesson?.createdAt)}</p> <p className="font-semibold text-sm">{fmtDate(lesson?.createdAt)}</p>
</div> </div>
<div className="bg-white rounded-lg border p-3 space-y-1"> <div className="bg-white rounded-lg border p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p> <p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p>
<p className="font-semibold text-sm">{formatDate(lesson?.updatedAt)}</p> <p className="font-semibold text-sm">{fmtDate(lesson?.updatedAt)}</p>
</div> </div>
</div> </div>
@@ -56,7 +56,7 @@ export default function EditUnit() {
if (!isDirty) return navigate(-1); if (!isDirty) return navigate(-1);
const result = await updateUnit(courseId, unitId, { ...data, updatedBy: user?.user_id }); const result = await updateUnit(courseId, unitId, { ...data, updatedBy: user?.user_id });
if (!result) return; if (!result) return;
navigate(-1); navigate(`/admin/courses/${courseId}/units/${unitId}/view`);
}; };
return ( return (
@@ -30,9 +30,9 @@ function validate(questions) {
// ── Dirty-check snapshot (stable fields only, strips internal _tempId) ──────── // ── Dirty-check snapshot (stable fields only, strips internal _tempId) ────────
function snapQuiz({ title, passingScore, isRequired, maxQuestions, questions }) { function snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions }) {
return JSON.stringify({ return JSON.stringify({
title, passingScore, isRequired, maxQuestions, title, passingScore, isRequired, maxQuestions, shuffleQuestions,
questions: questions.map((q) => ({ questions: questions.map((q) => ({
question_id: q.question_id ?? null, question_id: q.question_id ?? null,
question: q.question, question: q.question,
@@ -192,7 +192,7 @@ function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs
// ── Main Page ───────────────────────────────────────────────────────────────── // ── Main Page ─────────────────────────────────────────────────────────────────
export default function UnitQuiz() { export default function ModifyQuiz() {
const navigate = useNavigate(); const navigate = useNavigate();
const { courseId, unitId } = useParams(); const { courseId, unitId } = useParams();
const { const {
@@ -211,6 +211,7 @@ export default function UnitQuiz() {
const [passingScore, setPassingScore] = useState(70); const [passingScore, setPassingScore] = useState(70);
const [isRequired, setIsRequired] = useState(false); const [isRequired, setIsRequired] = useState(false);
const [maxQuestions, setMaxQuestions] = useState(""); const [maxQuestions, setMaxQuestions] = useState("");
const [shuffleQuestions, setShuffleQuestions] = useState(false);
const questionRefs = useRef([]); const questionRefs = useRef([]);
const navItemRefs = useRef([]); const navItemRefs = useRef([]);
@@ -241,9 +242,10 @@ export default function UnitQuiz() {
const ps = quiz.passing_score ?? 70; const ps = quiz.passing_score ?? 70;
const ir = quiz.is_required === true || quiz.is_required === 1; const ir = quiz.is_required === true || quiz.is_required === 1;
const mq = quiz.max_questions ?? ""; const mq = quiz.max_questions ?? "";
const sq = quiz.shuffle_questions === true || quiz.shuffle_questions === 1;
const qs = (quiz.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] })); const qs = (quiz.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
setTitle(t); setPassingScore(ps); setIsRequired(ir); setMaxQuestions(mq); setQuestions(qs); setTitle(t); setPassingScore(ps); setIsRequired(ir); setMaxQuestions(mq); setShuffleQuestions(sq); setQuestions(qs);
initialSnapshot.current = snapQuiz({ title: t, passingScore: ps, isRequired: ir, maxQuestions: mq, questions: qs }); initialSnapshot.current = snapQuiz({ title: t, passingScore: ps, isRequired: ir, maxQuestions: mq, shuffleQuestions: sq, questions: qs });
}, [quiz]); }, [quiz]);
// ── Measure sticky header → --quiz-h ────────────────────────────────────── // ── Measure sticky header → --quiz-h ──────────────────────────────────────
@@ -353,7 +355,7 @@ export default function UnitQuiz() {
// ── Dirty tracking ───────────────────────────────────────────────────────── // ── Dirty tracking ─────────────────────────────────────────────────────────
const isDirty = initialSnapshot.current === null const isDirty = initialSnapshot.current === null
? questions.length > 0 // new quiz — enable once they've added a question ? questions.length > 0 // new quiz — enable once they've added a question
: snapQuiz({ title, passingScore, isRequired, maxQuestions, questions }) !== initialSnapshot.current; : snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions }) !== initialSnapshot.current;
// ── Save ─────────────────────────────────────────────────────────────────── // ── Save ───────────────────────────────────────────────────────────────────
const handleSave = async () => { const handleSave = async () => {
@@ -370,6 +372,7 @@ export default function UnitQuiz() {
passing_score: passingScore, passing_score: passingScore,
is_required: isRequired, is_required: isRequired,
max_questions: maxQuestions ? parseInt(maxQuestions) : null, max_questions: maxQuestions ? parseInt(maxQuestions) : null,
shuffle_questions: shuffleQuestions,
updatedBy: user?.user_id, updatedBy: user?.user_id,
createdBy: user?.user_id, createdBy: user?.user_id,
}; };
@@ -391,7 +394,7 @@ export default function UnitQuiz() {
} }
} }
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, questions }); initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions });
navigate(-1); navigate(-1);
}; };
@@ -507,6 +510,17 @@ export default function UnitQuiz() {
Required to proceed to next unit Required to proceed to next unit
</Label> </Label>
</div> </div>
<div className="flex items-center gap-3">
<Checkbox
id="quiz_shuffle"
checked={shuffleQuestions === true}
onCheckedChange={(val) => setShuffleQuestions(val)}
/>
<Label htmlFor="quiz_shuffle" className="cursor-pointer">
Shuffle question order for each attempt
</Label>
</div>
</div> </div>
{maxQuestions && parseInt(maxQuestions) < questions.length && ( {maxQuestions && parseInt(maxQuestions) < questions.length && (
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Plus } from "lucide-react"; import { ArrowLeft, House, Plus } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
@@ -13,6 +14,7 @@ export default function UnitsList() {
const navigate = useNavigate(); const navigate = useNavigate();
const { courseId } = useParams(); const { courseId } = useParams();
const { fetchCourse, course, loading } = useCourses(); const { fetchCourse, course, loading } = useCourses();
const { fmtDate } = useDateFormat();
const [initializing, setInitializing] = useState(true); const [initializing, setInitializing] = useState(true);
useEffect(() => { useEffect(() => {
@@ -39,7 +41,7 @@ export default function UnitsList() {
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted/60 h-full">
<PageMeta title={course ? `Units – ${course.title} - STARR` : undefined} /> <PageMeta title={course ? `Units - ${course.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6"> <div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={items} /> <AppBreadcrumb items={items} />
@@ -81,13 +83,13 @@ export default function UnitsList() {
<div className="bg-muted/60 rounded-lg p-3 space-y-1"> <div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p> <p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p>
<p className="font-semibold text-sm"> <p className="font-semibold text-sm">
{course?.createdAt ? new Date(course.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'} {course?.createdAt ? fmtDate(course.createdAt) : '—'}
</p> </p>
</div> </div>
<div className="bg-muted/60 rounded-lg p-3 space-y-1"> <div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p> <p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p>
<p className="font-semibold text-sm"> <p className="font-semibold text-sm">
{course?.updatedAt ? new Date(course.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'} {course?.updatedAt ? fmtDate(course.updatedAt) : '—'}
</p> </p>
</div> </div>
</div> </div>
@@ -5,6 +5,7 @@ import {
CheckCircle2, Circle, Users, CheckCircle2, Circle, Users,
ChevronDown, ChevronUp, ChevronDown, ChevronUp,
} from "lucide-react"; } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
@@ -103,6 +104,7 @@ function QuestionView({ question, index }) {
function CompletionRow({ row }) { function CompletionRow({ row }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { fmtDate, fmtDateTime } = useDateFormat();
return ( return (
<> <>
<tr <tr
@@ -111,8 +113,15 @@ function CompletionRow({ row }) {
> >
<td className="px-4 py-3"> <td className="px-4 py-3">
<div> <div>
<p className="text-sm font-medium">{row.full_name}</p> <div className="flex items-center gap-1.5">
<p className="text-xs text-muted-foreground">{row.email}</p> <p className="text-sm font-medium">{row.full_name ?? "Unknown User"}</p>
{row.deleted && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-[18px] leading-none text-muted-foreground border-muted-foreground/30 shrink-0">
Deleted
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{row.email ?? "—"}</p>
</div> </div>
</td> </td>
<td className="px-4 py-3 text-center text-sm">{row.attempt_count}</td> <td className="px-4 py-3 text-center text-sm">{row.attempt_count}</td>
@@ -127,7 +136,7 @@ function CompletionRow({ row }) {
)} )}
</td> </td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap"> <td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{row.latest_at ? new Date(row.latest_at).toLocaleDateString() : "—"} {row.latest_at ? fmtDate(row.latest_at) : "—"}
</td> </td>
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">
{open ? <ChevronUp className="h-4 w-4 mx-auto text-muted-foreground" /> : <ChevronDown className="h-4 w-4 mx-auto text-muted-foreground" />} {open ? <ChevronUp className="h-4 w-4 mx-auto text-muted-foreground" /> : <ChevronDown className="h-4 w-4 mx-auto text-muted-foreground" />}
@@ -157,7 +166,7 @@ function CompletionRow({ row }) {
? <span className="text-green-600 dark:text-green-400 font-medium">Pass</span> ? <span className="text-green-600 dark:text-green-400 font-medium">Pass</span>
: <span className="text-red-500 font-medium">Fail</span>} : <span className="text-red-500 font-medium">Fail</span>}
</td> </td>
<td className="py-1.5 text-muted-foreground">{new Date(a.createdAt).toLocaleString()}</td> <td className="py-1.5 text-muted-foreground">{fmtDateTime(a.createdAt)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -284,7 +293,7 @@ export default function ViewUnitQuiz() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz`)} onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz/edit`)}
> >
<NotebookPen className="h-4 w-4 mr-2" /> <NotebookPen className="h-4 w-4 mr-2" />
Modify Quiz Modify Quiz
@@ -321,7 +330,7 @@ export default function ViewUnitQuiz() {
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center"> <div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
<HelpCircle className="h-8 w-8 text-muted-foreground/40" /> <HelpCircle className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No quiz has been created for this unit yet.</p> <p className="text-sm text-muted-foreground">No quiz has been created for this unit yet.</p>
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz`)}> <Button size="sm" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz/edit`)}>
<NotebookPen className="h-4 w-4 mr-2" /> <NotebookPen className="h-4 w-4 mr-2" />
Create Quiz Create Quiz
</Button> </Button>
@@ -41,6 +41,8 @@ export default function CreateTaskList() {
if (selectedGroupIds.length > 0) { if (selectedGroupIds.length > 0) {
await assignGroups(created.task_list_id, selectedGroupIds); await assignGroups(created.task_list_id, selectedGroupIds);
} }
navigate(`/admin/taskList/${created.task_list_id}/view`);
}; };
return ( return (
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useAdminTask } from '@/contexts/AdminTaskContext'; import { useAdminTask } from '@/contexts/AdminTaskContext';
import { useDateFormat } from '@/hooks/useDateFormat';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -136,6 +137,7 @@ export default function ViewTaskList() {
const navigate = useNavigate(); const navigate = useNavigate();
const { taskListId } = useParams(); const { taskListId } = useParams();
const { fetchTaskList } = useAdminTask(); const { fetchTaskList } = useAdminTask();
const { fmtDate } = useDateFormat();
const [taskList, setTaskList] = useState(null); const [taskList, setTaskList] = useState(null);
@@ -273,11 +275,7 @@ export default function ViewTaskList() {
<span className="text-sm"> <span className="text-sm">
Deadline:{' '} Deadline:{' '}
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">
{new Date(task.deadline).toLocaleDateString(undefined, { {fmtDate(task.deadline)}
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span> </span>
</span> </span>
</div> </div>
@@ -69,7 +69,7 @@ export default function EditTask() {
requirements: form.requirements, requirements: form.requirements,
}); });
if (updated) navigate(`/admin/taskList/${taskListId}/tasks/${taskId}`); if (updated) navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/view`);
}; };
if (!form) return ( if (!form) return (
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useAdminTask } from '@/contexts/AdminTaskContext'; import { useAdminTask } from '@/contexts/AdminTaskContext';
import { useDateFormat } from '@/hooks/useDateFormat';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -128,6 +129,7 @@ export default function ViewTask() {
const navigate = useNavigate(); const navigate = useNavigate();
const { taskListId, taskId } = useParams(); const { taskListId, taskId } = useParams();
const { fetchTask } = useAdminTask(); const { fetchTask } = useAdminTask();
const { fmtDate } = useDateFormat();
const [task, setTask] = useState(null); const [task, setTask] = useState(null);
@@ -200,11 +202,7 @@ export default function ViewTask() {
<span className="text-sm"> <span className="text-sm">
Deadline:{' '} Deadline:{' '}
<span className="text-foreground font-medium"> <span className="text-foreground font-medium">
{new Date(task.deadline).toLocaleDateString(undefined, { {fmtDate(task.deadline)}
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span> </span>
</span> </span>
</div> </div>
+72 -149
View File
@@ -1,38 +1,30 @@
import { useEffect, useState } from "react"; import { useEffect, useState, useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, House, ChevronsUpDown, Check, X, BookOpen } from "lucide-react"; import { ArrowLeft, House } from "lucide-react";
import { useTiers } from "@/contexts/AdminTiersContext"; import { useTiers } from "@/contexts/AdminTiersContext";
import { useCourses } from "@/contexts/AdminCoursesContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Command, CommandEmpty, CommandGroup,
CommandInput, CommandItem, CommandList,
} from "@/components/ui/command";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
import api from "@/utils/api.util";
// ─── Schema ─────────────────────────────────────────────────────────────────── // ─── Schema ───────────────────────────────────────────────────────────────────
const schema = z.object({ const schema = z.object({
tier: z.enum(["premium", "exclusive"]), tier_category_id: z.string().min(1, "Tier category is required."),
label: z.string().min(1, "Label is required."), label: z.string().min(1, "Label is required."),
duration_days: z.coerce.number().min(1, "Duration must be at least 1 day."), duration_days: z.coerce.number().min(1, "Duration must be at least 1 day."),
price: z.coerce.number().min(0.01, "Price must be greater than 0."), price: z.coerce.number().min(0.01, "Price must be greater than 0."),
currency: z.string().length(3, "Must be a 3-letter currency code.").default("USD"), currency: z.string().length(3, "Must be a 3-letter currency code.").default("USD"),
}); });
// ─── Helpers ──────────────────────────────────────────────────────────────────
function FieldError({ message }) { function FieldError({ message }) {
if (!message) return null; if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>; return <p className="text-xs text-destructive mt-1">{message}</p>;
@@ -41,146 +33,59 @@ function FieldError({ message }) {
function SectionCard({ title, children }) { function SectionCard({ title, children }) {
return ( return (
<div className="rounded-lg border bg-card p-6 space-y-5"> <div className="rounded-lg border bg-card p-6 space-y-5">
{title && ( {title && <div className="pb-1 border-b"><h2 className="text-sm font-semibold">{title}</h2></div>}
<div className="pb-1 border-b">
<h2 className="text-sm font-semibold">{title}</h2>
</div>
)}
{children} {children}
</div> </div>
); );
} }
// ─── Course Multi-Select ──────────────────────────────────────────────────────
function CourseMultiSelect({ courses, selected, onChange }) {
const [open, setOpen] = useState(false);
const selectedSet = new Set(selected.map(String));
const selectedList = courses.filter((c) => selectedSet.has(String(c.course_id)));
const toggle = (id) => {
const sid = String(id);
onChange(
selectedSet.has(sid)
? selected.filter((x) => String(x) !== sid)
: [...selected, sid]
);
};
const remove = (id) => onChange(selected.filter((x) => String(x) !== String(id)));
return (
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
>
{selected.length === 0
? <span className="text-muted-foreground">Select courses…</span>
: <span>{selected.length} course{selected.length !== 1 ? "s" : ""} selected</span>
}
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search courses…" />
<CommandList>
<CommandEmpty>
<div className="flex flex-col items-center gap-2 py-4">
<BookOpen className="size-6 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No courses found.</p>
</div>
</CommandEmpty>
<CommandGroup>
{courses.map((course) => {
const checked = selectedSet.has(String(course.course_id));
return (
<CommandItem
key={course.course_id}
value={`${course.title} ${course.course_code ?? ""}`}
onSelect={() => toggle(String(course.course_id))}
className="gap-2"
>
<div className={cn(
"flex size-4 items-center justify-center rounded-sm border border-primary shrink-0",
checked ? "bg-primary text-primary-foreground" : "opacity-50"
)}>
{checked && <Check className="size-3" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{course.title}</p>
{course.level && (
<p className="text-xs text-muted-foreground capitalize">{course.level}</p>
)}
</div>
{course.course_code && (
<span className="text-xs text-muted-foreground font-mono shrink-0">{course.course_code}</span>
)}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Selected chips */}
{selectedList.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedList.map((course) => (
<Badge key={course.course_id} variant="secondary" className="gap-1 pr-1">
<span className="text-xs max-w-[160px] truncate">{course.title}</span>
<button
type="button"
onClick={() => remove(course.course_id)}
className="ml-0.5 rounded-full hover:bg-muted-foreground/20 p-0.5"
>
<X className="size-2.5" />
</button>
</Badge>
))}
</div>
)}
</div>
);
}
// ─── Page ───────────────────────────────────────────────────────────────────── // ─── Page ─────────────────────────────────────────────────────────────────────
export default function AddPlan() { export default function AddPlan() {
const navigate = useNavigate(); const navigate = useNavigate();
const { createPlan, syncPlanCourses, loading } = useTiers(); const { createPlan, loading } = useTiers();
const { fetchCourses, courses } = useCourses();
const [selectedCourseIds, setSelectedCourseIds] = useState([]); const [categories, setCategories] = useState([]);
const [catLoading, setCatLoading] = useState(true);
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
useEffect(() => { useEffect(() => {
fetchCourses({ limit: 200 }); api.get("/admin/tiers/categories")
.then(({ data }) => setCategories((data.data ?? []).filter((c) => !c.is_default && c.is_active)))
.catch(() => {})
.finally(() => setCatLoading(false));
}, []); }, []);
const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({ const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { tier: "premium", label: "", duration_days: 30, price: "", currency: "USD" }, defaultValues: { tier_category_id: "", label: "", duration_days: 30, price: "", currency: "USD" },
}); });
const selectedCategoryId = watch("tier_category_id");
// Derive the subscription slug from the chosen category
const categorySlug = useMemo(() => {
if (!selectedCategoryId) return null;
return categories.find((c) => String(c.tier_category_id) === selectedCategoryId)?.slug ?? null;
}, [selectedCategoryId, categories]);
// Reset picker when category changes
useEffect(() => {
setSelectedCourseIds(new Set());
}, [categorySlug]);
const onSubmit = async (values) => { const onSubmit = async (values) => {
const result = await createPlan(values); const result = await createPlan(values);
if (!result) return; if (!result) return;
const planId = String(result.plan_id); // Sync selected courses
if (selectedCourseIds.size > 0) {
if (selectedCourseIds.length > 0) { await api.post(`/admin/tiers/plans/${result.plan_id}/courses`, {
await syncPlanCourses(planId, selectedCourseIds); course_ids: [...selectedCourseIds].map(Number),
}).catch(() => {});
} }
navigate("/admin/tiers/plans"); navigate(`/admin/tiers/plans`);
}; };
return ( return (
@@ -203,24 +108,44 @@ export default function AddPlan() {
</Button> </Button>
<div> <div>
<h1 className="text-xl font-semibold">Add Plan</h1> <h1 className="text-xl font-semibold">Add Plan</h1>
<p className="text-sm text-muted-foreground">Create a new premium or exclusive plan.</p> <p className="text-sm text-muted-foreground">Create a new paid tier plan.</p>
</div> </div>
</div> </div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{/* ── Plan Details ── */}
<SectionCard title="Plan Details"> <SectionCard title="Plan Details">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Tier <span className="text-destructive">*</span></Label> <Label>Tier Category <span className="text-destructive">*</span></Label>
<Select value={watch("tier")} onValueChange={(v) => setValue("tier", v, { shouldDirty: true })}> {catLoading ? (
<SelectTrigger><SelectValue placeholder="Select tier" /></SelectTrigger> <div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Spinner className="h-4 w-4" /> Loading categories…
</div>
) : categories.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No tier categories found.{" "}
<a href="/admin/tiers/categories/add" className="underline text-primary">Add one first.</a>
</p>
) : (
<Select
value={selectedCategoryId}
onValueChange={(v) => setValue("tier_category_id", v, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select tier category" />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="premium">Premium</SelectItem> {categories.map((c) => (
<SelectItem value="exclusive">Exclusive</SelectItem> <SelectItem key={c.tier_category_id} value={String(c.tier_category_id)}>
{c.name}
<span className="ml-2 text-xs text-muted-foreground">({c.slug})</span>
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<FieldError message={errors.tier?.message} /> )}
<FieldError message={errors.tier_category_id?.message} />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -249,24 +174,22 @@ export default function AddPlan() {
</div> </div>
</SectionCard> </SectionCard>
{/* ── Courses ── */} {categorySlug && (
<SectionCard title="Courses"> <SectionCard title="Assigned Courses">
<p className="text-xs text-muted-foreground -mt-2"> <p className="text-xs text-muted-foreground -mt-1">
Assign courses included in this plan. You can also manage this later from the plan's detail page. Select which <span className="font-medium capitalize">{categorySlug}</span> courses are included in this plan.
</p> </p>
<CourseMultiSelect <CoursePicker
courses={courses} subscription={categorySlug}
selected={selectedCourseIds} selectedIds={selectedCourseIds}
onChange={setSelectedCourseIds} onChange={setSelectedCourseIds}
/> />
</SectionCard> </SectionCard>
)}
{/* ── Actions ── */}
<div className="flex justify-end gap-3 pt-1"> <div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}> <Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
Cancel <Button type="submit" disabled={loading || catLoading || !selectedCategoryId}>
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />} {loading && <Spinner className="h-4 w-4 mr-2" />}
Create Plan Create Plan
</Button> </Button>
+36 -123
View File
@@ -3,9 +3,8 @@ import { useNavigate, useParams } from "react-router-dom";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, House, ChevronsUpDown, Check, X, BookOpen } from "lucide-react"; import { ArrowLeft, House } from "lucide-react";
import { useTiers } from "@/contexts/AdminTiersContext"; import { useTiers } from "@/contexts/AdminTiersContext";
import { useCourses } from "@/contexts/AdminCoursesContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -13,14 +12,9 @@ import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Command, CommandEmpty, CommandGroup,
CommandInput, CommandItem, CommandList,
} from "@/components/ui/command";
import { cn } from "@/lib/utils";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
import api from "@/utils/api.util";
const schema = z.object({ const schema = z.object({
label: z.string().min(1, "Label is required."), label: z.string().min(1, "Label is required."),
@@ -44,110 +38,14 @@ function SectionCard({ title, children }) {
); );
} }
function CourseMultiSelect({ courses, selected, onChange }) {
const [open, setOpen] = useState(false);
const selectedSet = new Set(selected.map(String));
const selectedList = courses.filter((c) => selectedSet.has(String(c.course_id)));
const toggle = (id) => {
const sid = String(id);
onChange(
selectedSet.has(sid)
? selected.filter((x) => String(x) !== sid)
: [...selected, sid]
);
};
const remove = (id) => onChange(selected.filter((x) => String(x) !== String(id)));
return (
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
>
{selected.length === 0
? <span className="text-muted-foreground">Select courses…</span>
: <span>{selected.length} course{selected.length !== 1 ? "s" : ""} selected</span>
}
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search courses…" />
<CommandList>
<CommandEmpty>
<div className="flex flex-col items-center gap-2 py-4">
<BookOpen className="size-6 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No courses found.</p>
</div>
</CommandEmpty>
<CommandGroup>
{courses.map((course) => {
const checked = selectedSet.has(String(course.course_id));
return (
<CommandItem
key={course.course_id}
value={`${course.title} ${course.course_code ?? ""}`}
onSelect={() => toggle(String(course.course_id))}
className="gap-2"
>
<div className={cn(
"flex size-4 items-center justify-center rounded-sm border border-primary shrink-0",
checked ? "bg-primary text-primary-foreground" : "opacity-50"
)}>
{checked && <Check className="size-3" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{course.title}</p>
{course.level && (
<p className="text-xs text-muted-foreground capitalize">{course.level}</p>
)}
</div>
{course.course_code && (
<span className="text-xs text-muted-foreground font-mono shrink-0">{course.course_code}</span>
)}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedList.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedList.map((course) => (
<Badge key={course.course_id} variant="secondary" className="gap-1 pr-1">
<span className="text-xs max-w-[160px] truncate">{course.title}</span>
<button
type="button"
onClick={() => remove(course.course_id)}
className="ml-0.5 rounded-full hover:bg-muted-foreground/20 p-0.5"
>
<X className="size-2.5" />
</button>
</Badge>
))}
</div>
)}
</div>
);
}
export default function EditPlan() { export default function EditPlan() {
const navigate = useNavigate(); const navigate = useNavigate();
const { planId } = useParams(); const { planId } = useParams();
const { fetchPlan, plan, updatePlan, fetchPlanCourses, planCourses, syncPlanCourses, loading } = useTiers(); const { fetchPlan, plan, updatePlan, loading } = useTiers();
const { courses: allCourses, fetchCourses } = useCourses();
const [selectedCourseIds, setSelectedCourseIds] = useState([]); const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
const [coursesLoaded, setCoursesLoaded] = useState(false);
const { register, handleSubmit, setValue, watch, reset, formState: { errors } } = useForm({ const { register, handleSubmit, setValue, watch, reset, formState: { errors } } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
@@ -155,28 +53,41 @@ export default function EditPlan() {
useEffect(() => { useEffect(() => {
fetchPlan(planId); fetchPlan(planId);
fetchPlanCourses(planId);
fetchCourses({ page: 1, limit: 1000 });
}, [planId]); }, [planId]);
useEffect(() => { useEffect(() => {
setSelectedCourseIds(planCourses.map(c => String(c.course_id))); if (plan) {
}, [planCourses]); reset({
useEffect(() => {
if (plan) reset({
label: plan.label, label: plan.label,
duration_days: plan.duration_days, duration_days: plan.duration_days,
price: plan.price, price: plan.price,
currency: plan.currency, currency: plan.currency,
is_active: plan.is_active, is_active: plan.is_active,
}); });
}
}, [plan]); }, [plan]);
// Load existing assigned courses once the plan is known
useEffect(() => {
if (!planId || coursesLoaded) return;
api.get(`/admin/tiers/plans/${planId}/courses`)
.then(({ data }) => {
const ids = (data.data ?? []).map((c) => String(c.course_id));
setSelectedCourseIds(new Set(ids));
setCoursesLoaded(true);
})
.catch(() => setCoursesLoaded(true));
}, [planId]);
const onSubmit = async (values) => { const onSubmit = async (values) => {
const result = await updatePlan(planId, values); const result = await updatePlan(planId, values);
await syncPlanCourses(planId, selectedCourseIds);
if (!result) return; if (!result) return;
// Always sync (empty array clears all assignments)
await api.post(`/admin/tiers/plans/${planId}/courses`, {
course_ids: [...selectedCourseIds].map(Number),
}).catch(() => {});
navigate("/admin/tiers/plans"); navigate("/admin/tiers/plans");
}; };
@@ -249,16 +160,18 @@ export default function EditPlan() {
</div> </div>
</SectionCard> </SectionCard>
<SectionCard title="Courses"> {plan?.tier && (
<p className="text-xs text-muted-foreground -mt-2"> <SectionCard title="Assigned Courses">
Assign courses included in this plan. Changes are saved when you click Save Changes. <p className="text-xs text-muted-foreground -mt-1">
Select which <span className="font-medium capitalize">{plan.tier}</span> courses are included in this plan.
</p> </p>
<CourseMultiSelect <CoursePicker
courses={allCourses} subscription={plan.tier}
selected={selectedCourseIds} selectedIds={selectedCourseIds}
onChange={setSelectedCourseIds} onChange={setSelectedCourseIds}
/> />
</SectionCard> </SectionCard>
)}
<div className="flex justify-end gap-3 pt-1"> <div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button> <Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
@@ -0,0 +1,353 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft, House, ShieldCheck, ImagePlus, X, Check,
Shield, Star, Trophy, Medal, Award, BadgeCheck, Gem,
Crown, Zap, Flame, Sparkles, Rocket, Target, Hexagon, Layers, CircleDot,
} from "lucide-react";
import * as LucideIcons from "lucide-react";
import { TIER_COLOR_OPTIONS, getTierColor } from "@/utils/tierColors";
import { Badge } from "@/components/ui/badge";
const BADGE_ICON_OPTIONS = [
{ name: "ShieldCheck", icon: ShieldCheck },
{ name: "Shield", icon: Shield },
{ name: "BadgeCheck", icon: BadgeCheck },
{ name: "Star", icon: Star },
{ name: "Crown", icon: Crown },
{ name: "Gem", icon: Gem },
{ name: "Trophy", icon: Trophy },
{ name: "Medal", icon: Medal },
{ name: "Award", icon: Award },
{ name: "Sparkles", icon: Sparkles },
{ name: "Flame", icon: Flame },
{ name: "Zap", icon: Zap },
{ name: "Rocket", icon: Rocket },
{ name: "Target", icon: Target },
{ name: "Hexagon", icon: Hexagon },
{ name: "Layers", icon: Layers },
{ name: "CircleDot", icon: CircleDot },
];
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { AssetsProvider } from "@/contexts/AdminAssetsContext";
import {
AdminTierCategoriesProvider,
useAdminTierCategories,
} from "@/contexts/AdminTierCategoriesContext";
function SectionCard({ title, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
{title && <p className="text-sm font-semibold border-b pb-3">{title}</p>}
{children}
</div>
);
}
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
function BadgePicker({ currentAsset, selectedAsset, onSelect, onClear }) {
const [open, setOpen] = useState(false);
const display = selectedAsset ?? currentAsset;
return (
<div className="space-y-2">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-lg border bg-muted flex items-center justify-center overflow-hidden shrink-0">
{display?.file_url ? (
<img src={display.file_url} alt={display.display_name} className="w-12 h-12 object-contain" />
) : (
<ShieldCheck className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div className="flex flex-col gap-1.5">
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
<ImagePlus className="h-3.5 w-3.5 mr-1.5" />
{display ? "Change image" : "Pick from assets"}
</Button>
{display && (
<Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={onClear}>
<X className="h-3.5 w-3.5 mr-1.5" />
Remove
</Button>
)}
</div>
</div>
{display && <p className="text-xs text-muted-foreground truncate max-w-xs">{display.display_name}</p>}
<AssetPickerSheet open={open} onOpenChange={setOpen} fileType="image" onSelect={onSelect} />
</div>
);
}
function EditTierCategoryInner({ isAdd }) {
const navigate = useNavigate();
const { id } = useParams();
const { category, loading, fetchCategory, createCategory, updateCategory } = useAdminTierCategories();
const [slug, setSlug] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [rank, setRank] = useState(1);
const [color, setColor] = useState("purple");
const [badgeIcon, setBadgeIcon] = useState(null);
const [badgeLabel, setBadgeLabel] = useState("");
const [isActive, setIsActive] = useState(true);
const [selectedAsset, setSelectedAsset] = useState(null);
const [clearBadge, setClearBadge] = useState(false);
const [errors, setErrors] = useState({});
useEffect(() => {
if (!isAdd && id) fetchCategory(id);
}, [id, isAdd]);
useEffect(() => {
if (category && !isAdd) {
setSlug(category.slug ?? "");
setName(category.name ?? "");
setDescription(category.description ?? "");
setRank(category.rank ?? 0);
setColor(category.color ?? (category.is_default ? "green" : "purple"));
setBadgeIcon(category.badge_icon ?? null);
setBadgeLabel(category.badge_label ?? "");
setIsActive(category.is_active ?? true);
setSelectedAsset(null);
setClearBadge(false);
}
}, [category, isAdd]);
const validate = () => {
const e = {};
if (!name.trim()) e.name = "Name is required.";
if (isAdd && !slug.trim()) e.slug = "Slug is required.";
if (isAdd && !/^[a-z0-9_-]+$/.test(slug)) e.slug = "Slug must be lowercase letters, numbers, hyphens or underscores.";
setErrors(e);
return !Object.keys(e).length;
};
const handleSave = async () => {
if (!validate()) return;
const payload = {
name: name.trim(),
description: description.trim() || null,
rank: Number(rank),
color,
badge_icon: badgeIcon || null,
badge_label: badgeLabel.trim() || null,
is_active: isActive,
};
if (selectedAsset) payload.badge_asset_id = selectedAsset.asset_id;
else if (clearBadge) payload.badge_asset_id = null;
if (isAdd) {
payload.slug = slug.trim();
const result = await createCategory(payload);
if (result) navigate("/admin/tiers/categories");
} else {
const result = await updateCategory(id, payload);
if (result) navigate("/admin/tiers/categories");
}
};
const isLocked = !isAdd && category?.is_default;
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={isAdd ? "Add Tier Category - STARR" : "Edit Tier Category - STARR"} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Tier Categories", to: "/admin/tiers/categories" },
{ label: isAdd ? "Add Category" : (category?.name ?? "Edit") },
]} />
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">{isAdd ? "Add Tier Category" : "Edit Tier Category"}</h1>
<p className="text-sm text-muted-foreground">
{isAdd ? "Define a new tier level for the platform." : "Update this tier category's details and badge."}
</p>
</div>
</div>
<div className="space-y-5">
{/* Details */}
<SectionCard title="Category Details">
{isAdd && (
<div className="space-y-1.5">
<Label htmlFor="slug">Slug <span className="text-destructive">*</span></Label>
<Input
id="slug"
value={slug}
onChange={(e) => setSlug(e.target.value.toLowerCase())}
placeholder="e.g. gold"
/>
<p className="text-xs text-muted-foreground">Lowercase, no spaces. Cannot be changed after creation.</p>
<FieldError message={errors.slug} />
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Gold" />
<FieldError message={errors.name} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="Optional short description." />
</div>
<div className="space-y-1.5">
<Label htmlFor="rank">Rank (ordering)</Label>
<Input id="rank" type="number" min={isLocked ? 0 : 1} value={rank} onChange={(e) => setRank(e.target.value)} className="w-32" />
<p className="text-xs text-muted-foreground">Must be greater than 0. Free is rank 0. Higher rank = higher access tier.</p>
{!isLocked && Number(rank) <= 0 && (
<p className="text-xs text-destructive">Rank must be at least 1 — rank 0 is reserved for the Free (default) tier.</p>
)}
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex flex-wrap gap-2">
{TIER_COLOR_OPTIONS.map((opt) => {
const selected = color === opt.key;
return (
<button
key={opt.key}
type="button"
title={opt.label}
onClick={() => setColor(opt.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border-2 transition-all ${selected ? "border-foreground scale-105" : "border-transparent opacity-70 hover:opacity-100"}`}
style={{ backgroundColor: opt.swatch, color: "#fff" }}
>
{selected && <Check className="size-3" />}
{opt.label}
</button>
);
})}
</div>
<div className="pt-1">
<Badge className={`${getTierColor(color).badge} text-xs`}>
{name || "Preview"}
</Badge>
</div>
</div>
{!isLocked && (
<div className="flex items-center gap-3">
<Switch id="is_active" checked={isActive} onCheckedChange={setIsActive} />
<Label htmlFor="is_active">Active</Label>
</div>
)}
</SectionCard>
{/* Badge */}
<SectionCard title="Badge">
<p className="text-xs text-muted-foreground -mt-1">
Shown on the user's profile when they are in this tier. Upload an image or pick a Lucide icon — image takes priority if both are set.
</p>
{/* Lucide icon picker */}
<div className="space-y-2">
<Label>Icon <span className="text-muted-foreground text-xs">(optional)</span></Label>
<div className="flex flex-wrap gap-2">
{/* None option */}
<button
type="button"
onClick={() => setBadgeIcon(null)}
className={`flex items-center justify-center w-9 h-9 rounded-lg border-2 text-xs text-muted-foreground transition-all ${!badgeIcon ? "border-foreground bg-muted scale-105" : "border-border hover:border-muted-foreground"}`}
title="No icon"
>
<X className="size-3.5" />
</button>
{BADGE_ICON_OPTIONS.map(({ name, icon: Icon }) => {
const selected = badgeIcon === name;
const cls = getTierColor(color).badge;
return (
<button
key={name}
type="button"
title={name}
onClick={() => setBadgeIcon(name)}
className={`flex items-center justify-center w-9 h-9 rounded-lg border-2 transition-all ${selected ? `${cls} border-foreground scale-105` : "border-border hover:border-muted-foreground"}`}
>
<Icon className="size-4" />
</button>
);
})}
</div>
{badgeIcon && (
<p className="text-xs text-muted-foreground">Selected: <span className="font-medium">{badgeIcon}</span></p>
)}
</div>
{/* Image upload */}
<div className="space-y-1.5">
<Label>Image <span className="text-muted-foreground text-xs">(overrides icon)</span></Label>
<BadgePicker
currentAsset={clearBadge ? null : (category?.badgeAsset ?? null)}
selectedAsset={selectedAsset}
onSelect={(a) => { setSelectedAsset(a); setClearBadge(false); }}
onClear={() => { setSelectedAsset(null); setClearBadge(true); }}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="badge_label">Badge Label</Label>
<Input
id="badge_label"
value={badgeLabel}
onChange={(e) => setBadgeLabel(e.target.value)}
placeholder="e.g. Premium Member"
/>
</div>
</SectionCard>
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="button" onClick={handleSave} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
{isAdd ? "Create Category" : "Save Changes"}
</Button>
</div>
</div>
</div>
</div>
</section>
);
}
function Wrapper({ isAdd }) {
return (
<AssetsProvider>
<AdminTierCategoriesProvider>
<EditTierCategoryInner isAdd={isAdd} />
</AdminTierCategoriesProvider>
</AssetsProvider>
);
}
export function AddTierCategory() { return <Wrapper isAdd={true} />; }
export function EditTierCategory() { return <Wrapper isAdd={false} />; }
@@ -0,0 +1,298 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, ShieldCheck, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import { AdminTierPoliciesProvider, useAdminTierPolicies } from "@/contexts/AdminTierPoliciesContext";
import { useTiers } from "@/contexts/AdminTiersContext";
import api from "@/utils/api.util";
// ─── Rule type definitions ────────────────────────────────────────────────────
const RULE_DEFS = {
course_subscription_access: {
label: "Course Subscription Access",
description: "Which course subscription levels this plan can unlock.",
default: { type: "course_subscription_access", levels: ["free"] },
},
required_active_tier: {
label: "Required Active Tier",
description: "User's tier must be at least this rank to access content.",
default: { type: "required_active_tier", tier: "" },
},
group_restriction: {
label: "Group Restriction",
description: "Only users in selected groups can access content.",
default: { type: "group_restriction", group_ids: [] },
},
};
// ─── Rule Editors ─────────────────────────────────────────────────────────────
function CourseAccessEditor({ rule, onChange }) {
const [availableLevels, setAvailableLevels] = useState(["free"]);
useEffect(() => {
api.get("/admin/tiers/categories")
.then(({ data }) => {
setAvailableLevels((data.data ?? []).filter((c) => c.is_active).map((c) => c.slug));
})
.catch(() => {});
}, []);
const toggle = (level) => {
const levels = rule.levels ?? [];
onChange({ ...rule, levels: levels.includes(level) ? levels.filter((l) => l !== level) : [...levels, level] });
};
return (
<div className="flex flex-wrap gap-2 mt-2">
{availableLevels.map((lvl) => (
<button
key={lvl}
type="button"
onClick={() => toggle(lvl)}
className={`px-3 py-1 rounded-full border text-sm capitalize transition-colors ${
(rule.levels ?? []).includes(lvl)
? "bg-primary text-primary-foreground border-primary"
: "bg-card border-border hover:bg-muted"
}`}
>
{lvl}
</button>
))}
</div>
);
}
function RequiredTierEditor({ rule, onChange }) {
const [categories, setCategories] = useState([]);
useEffect(() => {
api.get("/admin/tiers/categories")
.then(({ data }) => setCategories((data.data ?? []).filter((c) => c.is_active && !c.is_default)))
.catch(() => {});
}, []);
return (
<Select value={rule.tier} onValueChange={(v) => onChange({ ...rule, tier: v })}>
<SelectTrigger className="w-48 mt-2">
<SelectValue placeholder="Select tier" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
);
}
function GroupRestrictionEditor({ rule, onChange }) {
const [groups, setGroups] = useState([]);
useEffect(() => {
api.get("/admin/groups").then(({ data }) => setGroups(data.data?.data ?? [])).catch(() => {});
}, []);
const toggleGroup = (gid) => {
const ids = rule.group_ids ?? [];
onChange({ ...rule, group_ids: ids.includes(gid) ? ids.filter((id) => id !== gid) : [...ids, gid] });
};
return (
<div className="mt-2 space-y-1 max-h-48 overflow-y-auto">
{groups.length === 0 && <p className="text-xs text-muted-foreground">No groups found.</p>}
{groups.map((g) => {
const selected = (rule.group_ids ?? []).includes(Number(g.group_id));
return (
<button
key={g.group_id}
type="button"
onClick={() => toggleGroup(Number(g.group_id))}
className={`w-full text-left px-3 py-1.5 rounded border text-sm transition-colors ${
selected ? "bg-primary text-primary-foreground border-primary" : "bg-card border-border hover:bg-muted"
}`}
>
{g.name}
{g.group_code && <span className="ml-2 text-xs opacity-60">{g.group_code}</span>}
</button>
);
})}
</div>
);
}
function RuleCard({ rule, index, onChange, onRemove }) {
const def = RULE_DEFS[rule.type];
return (
<div className="rounded-lg border bg-card p-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-medium">{def?.label ?? rule.type}</p>
<p className="text-xs text-muted-foreground">{def?.description}</p>
</div>
<Button type="button" variant="ghost" size="icon" className="shrink-0" onClick={onRemove}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
{rule.type === "course_subscription_access" && <CourseAccessEditor rule={rule} onChange={(r) => onChange(index, r)} />}
{rule.type === "required_active_tier" && <RequiredTierEditor rule={rule} onChange={(r) => onChange(index, r)} />}
{rule.type === "group_restriction" && <GroupRestrictionEditor rule={rule} onChange={(r) => onChange(index, r)} />}
</div>
);
}
function SectionCard({ icon: Icon, title, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<h2 className="text-sm font-semibold">{title}</h2>
</div>
<Separator />
{children}
</div>
);
}
// ─── Inner page ───────────────────────────────────────────────────────────────
function PlanPolicyInner() {
const navigate = useNavigate();
const { planId } = useParams();
const { fetchPlan, plan, loading: planLoading } = useTiers();
const { fetchPlanPolicy, savePlanPolicy, policy, loading } = useAdminTierPolicies();
const [rules, setRules] = useState([]);
useEffect(() => {
fetchPlan(planId);
fetchPlanPolicy(planId);
}, [planId]);
useEffect(() => {
if (policy) setRules(policy.access_rules ?? []);
}, [policy]);
const handleRuleChange = (index, updated) =>
setRules((prev) => prev.map((r, i) => (i === index ? updated : r)));
const removeRule = (index) =>
setRules((prev) => prev.filter((_, i) => i !== index));
const addRule = (type) => {
const def = RULE_DEFS[type];
if (!def) return;
setRules((prev) => [...prev, { ...def.default }]);
};
const usedTypes = new Set(rules.map((r) => r.type));
const handleSave = async () => {
await savePlanPolicy(planId, { access_rules: rules });
};
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={plan ? `Policy — ${plan.label}` : "Plan Policy"} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Plans", to: "/admin/tiers/plans" },
{ label: plan?.label ?? `Plan #${planId}`, to: `/admin/tiers/plans/${planId}/view` },
{ label: "Policy" },
]} />
</div>
<div className="flex items-start justify-between gap-3 mb-6">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Access Policy</h1>
<p className="text-sm text-muted-foreground">
{planLoading ? <Skeleton className="h-4 w-40 inline-block" /> : (plan?.label ?? `Plan #${planId}`)}
</p>
</div>
</div>
<Button onClick={handleSave} disabled={loading} size="sm">
{loading ? "Saving…" : "Save Policy"}
</Button>
</div>
<div className="space-y-5">
<SectionCard icon={ShieldCheck} title="Access Rules">
{rules.length === 0 && (
<p className="text-sm text-muted-foreground">
No rules defined. Content access falls back to tier rank comparison.
</p>
)}
<div className="space-y-3">
{rules.map((rule, i) => (
<RuleCard
key={`${rule.type}-${i}`}
rule={rule}
index={i}
onChange={handleRuleChange}
onRemove={() => removeRule(i)}
/>
))}
</div>
<div className="pt-2">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">Add Rule</p>
<div className="flex flex-wrap gap-2">
{Object.entries(RULE_DEFS).map(([type, def]) => (
<Button
key={type}
type="button"
variant="outline"
size="sm"
disabled={usedTypes.has(type)}
onClick={() => addRule(type)}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
{def.label}
</Button>
))}
</div>
</div>
</SectionCard>
{plan && (
<div className="flex items-center gap-2 px-1">
<Badge variant="outline" className="capitalize">{plan.tier}</Badge>
<span className="text-sm text-muted-foreground">{plan.label}</span>
<span className="text-sm text-muted-foreground">·</span>
<span className="text-sm text-muted-foreground">{plan.duration_days} days</span>
</div>
)}
</div>
</div>
</div>
</section>
);
}
export default function PlanPolicy() {
return (
<AdminTierPoliciesProvider>
<PlanPolicyInner />
</AdminTierPoliciesProvider>
);
}
@@ -0,0 +1,222 @@
import { useEffect, useState } from "react";
import { House, ShieldCheck, ImagePlus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import { AdminTierPoliciesProvider, useAdminTierPolicies } from "@/contexts/AdminTierPoliciesContext";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { AssetsProvider } from "@/contexts/AdminAssetsContext";
// ─── Badge editor card for a single system badge ──────────────────────────────
function SystemBadgeCard({ badge: initialBadge }) {
const { saveSystemBadge, loading } = useAdminTierPolicies();
const [badge, setBadge] = useState(initialBadge);
// selectedAsset: newly chosen from picker (not yet saved)
const [selectedAsset, setSelectedAsset] = useState(null);
const [clearAsset, setClearAsset] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
useEffect(() => {
setBadge(initialBadge);
setSelectedAsset(null);
setClearAsset(false);
}, [initialBadge]);
const currentAsset = clearAsset ? null : (badge.asset ?? null);
const displayAsset = selectedAsset ?? currentAsset;
const handleSelect = (asset) => {
setSelectedAsset(asset);
setClearAsset(false);
};
const handleClear = () => {
setSelectedAsset(null);
setClearAsset(true);
};
const handleSave = async () => {
const payload = {
label: badge.label ?? "",
description: badge.description ?? "",
information: badge.information ?? "",
active_from: badge.active_from ?? null,
active_until: badge.active_until ?? null,
};
if (selectedAsset) {
payload.asset_id = selectedAsset.asset_id;
} else if (clearAsset) {
payload.asset_id = null;
}
const saved = await saveSystemBadge(badge.key, payload);
if (saved) {
setBadge(saved);
setSelectedAsset(null);
setClearAsset(false);
}
};
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold capitalize">{badge.label || badge.key}</h2>
<span className="ml-auto text-xs text-muted-foreground font-mono">{badge.key}</span>
</div>
<Separator />
{/* ── Image picker ── */}
<div className="space-y-2">
<div className="flex items-center gap-4">
{displayAsset ? (
<div className="w-16 h-16 rounded-lg border bg-muted flex items-center justify-center overflow-hidden">
<img src={displayAsset.file_url} alt={displayAsset.display_name} className="w-12 h-12 object-contain" />
</div>
) : (
<div className="w-16 h-16 rounded-lg border bg-muted flex items-center justify-center text-muted-foreground">
<ShieldCheck className="h-6 w-6" />
</div>
)}
<div className="flex flex-col gap-1.5">
<Button type="button" variant="outline" size="sm" onClick={() => setPickerOpen(true)}>
<ImagePlus className="h-3.5 w-3.5 mr-1.5" />
{displayAsset ? "Change image" : "Pick from assets"}
</Button>
{displayAsset && (
<Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={handleClear}>
<X className="h-3.5 w-3.5 mr-1.5" />
Remove
</Button>
)}
</div>
</div>
{displayAsset && (
<p className="text-xs text-muted-foreground truncate max-w-xs">{displayAsset.display_name}</p>
)}
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={handleSelect}
/>
</div>
{/* ── Label / Description / Information ── */}
<div className="grid grid-cols-1 gap-3">
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wide block mb-1">Label</label>
<Input value={badge.label ?? ""} onChange={(e) => setBadge((p) => ({ ...p, label: e.target.value }))} />
</div>
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wide block mb-1">Description</label>
<Input value={badge.description ?? ""} onChange={(e) => setBadge((p) => ({ ...p, description: e.target.value }))} />
</div>
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wide block mb-1">Information</label>
<Textarea value={badge.information ?? ""} onChange={(e) => setBadge((p) => ({ ...p, information: e.target.value }))} rows={2} />
</div>
</div>
{/* ── Active window ── */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wide block mb-1">Active From</label>
<Input
type="date"
value={badge.active_from ?? ""}
onChange={(e) => setBadge((p) => ({ ...p, active_from: e.target.value || null }))}
/>
</div>
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wide block mb-1">Active Until</label>
<Input
type="date"
value={badge.active_until ?? ""}
onChange={(e) => setBadge((p) => ({ ...p, active_until: e.target.value || null }))}
/>
</div>
</div>
<div className="flex justify-end">
<Button size="sm" onClick={handleSave} disabled={loading}>
{loading ? "Saving…" : "Save Badge"}
</Button>
</div>
</div>
);
}
// ─── Always show early_access even before DB row exists ───────────────────────
const DEFAULT_SYSTEM_BADGES = [
{ key: "early_access", label: "Early Access", description: "", information: "", asset: null, active_from: null, active_until: null },
];
// ─── Inner page ───────────────────────────────────────────────────────────────
function SystemBadgesInner() {
const { fetchSystemBadges, systemBadges, loading } = useAdminTierPolicies();
useEffect(() => { fetchSystemBadges(); }, []);
const merged = DEFAULT_SYSTEM_BADGES.map((def) => {
const fetched = systemBadges.find((b) => b.key === def.key);
return fetched ?? def;
});
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Plans", to: "/admin/tiers/plans" },
{ label: "System Badges" },
];
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="System Badges - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={breadcrumbItems} />
</div>
<div className="mb-6">
<h1 className="text-xl font-semibold">System Badges</h1>
<p className="text-sm text-muted-foreground">Special badges awarded outside of subscription plans.</p>
</div>
{loading && systemBadges.length === 0 ? (
<div className="space-y-4">
{[1, 2].map((i) => <Skeleton key={i} className="h-64 w-full rounded-lg" />)}
</div>
) : (
<div className="space-y-5">
{merged.map((badge) => (
<SystemBadgeCard key={badge.key} badge={badge} />
))}
</div>
)}
</div>
</div>
</section>
);
}
export default function SystemBadges() {
return (
<AssetsProvider>
<AdminTierPoliciesProvider>
<SystemBadgesInner />
</AdminTierPoliciesProvider>
</AssetsProvider>
);
}
@@ -0,0 +1,176 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, Plus, Pencil, Trash2, ShieldCheck, Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { Separator } from "@/components/ui/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { PageMeta } from "@/contexts/MetadataContext";
import {
AdminTierCategoriesProvider,
useAdminTierCategories,
} from "@/contexts/AdminTierCategoriesContext";
function CategoryCard({ cat, onEdit, onDelete }) {
const badge = cat.badgeAsset;
return (
<div className="rounded-lg border bg-card p-5 flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-lg border bg-muted flex items-center justify-center shrink-0 overflow-hidden">
{badge?.file_url ? (
<img src={badge.file_url} alt={badge.display_name} className="w-10 h-10 object-contain" />
) : (
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold">{cat.name}</p>
<code className="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{cat.slug}</code>
<Badge variant="outline" className="text-[10px]">rank {cat.rank}</Badge>
{!cat.is_active && <Badge variant="secondary">Inactive</Badge>}
{cat.is_default && (
<Badge variant="secondary" className="gap-1">
<Lock className="h-2.5 w-2.5" /> Default
</Badge>
)}
</div>
{cat.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{cat.description}</p>
)}
{cat.badge_label && (
<p className="text-xs text-muted-foreground mt-0.5">
Badge label: <span className="font-medium text-foreground">{cat.badge_label}</span>
</p>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(cat)}>
<Pencil className="h-4 w-4" />
</Button>
{!cat.is_default && (
<Button type="button" variant="ghost" size="icon" onClick={() => onDelete(cat)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</div>
);
}
function TierCategoriesInner() {
const navigate = useNavigate();
const { categories, loading, fetchCategories, deleteCategory } = useAdminTierCategories();
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
useEffect(() => { fetchCategories(); }, []);
const confirmDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
await deleteCategory(deleteTarget.tier_category_id);
setDeleting(false);
setDeleteTarget(null);
};
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Tier Categories - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Tiers", to: "/admin/tiers/plans" },
{ label: "Tier Categories" },
]} />
</div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-semibold">Tier Categories</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Define the tier levels available on the platform. Plans are built under each category.
</p>
</div>
<Button size="sm" onClick={() => navigate("/admin/tiers/categories/add")}>
<Plus className="h-4 w-4 mr-2" />
Add Category
</Button>
</div>
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-xs text-muted-foreground">
The <strong>Free</strong> category is the platform default and cannot be deleted or deactivated.
All new users start here automatically. You can still configure its badge.
</p>
</div>
<Separator className="mb-5" />
{loading && !categories.length ? (
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
) : !categories.length ? (
<p className="text-sm text-muted-foreground text-center py-12">No tier categories found.</p>
) : (
<div className="space-y-3">
{categories.map((cat) => (
<CategoryCard
key={cat.tier_category_id}
cat={cat}
onEdit={(c) => navigate(`/admin/tiers/categories/${c.tier_category_id}/edit`)}
onDelete={(c) => setDeleteTarget(c)}
/>
))}
</div>
)}
</div>
</div>
{/* Delete confirmation dialog */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Tier Category</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{deleteTarget?.name}</span>?
This action cannot be undone. Any plans linked to this category must be reassigned first.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleting}>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleting}>
{deleting && <Spinner className="h-4 w-4 mr-2" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
export default function TierCategories() {
return (
<AdminTierCategoriesProvider>
<TierCategoriesInner />
</AdminTierCategoriesProvider>
);
}
+40 -13
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState, useMemo } from "react";
import api from "@/utils/api.util";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, House, ShieldPlus, ShieldOff, BadgeCheck } from "lucide-react"; import { ArrowLeft, House, ShieldPlus, ShieldOff, BadgeCheck } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@@ -19,9 +21,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useTiers } from "@/contexts/AdminTiersContext"; import { useTiers } from "@/contexts/AdminTiersContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
const TIER_BADGE = { free: "secondary", premium: "default", exclusive: "destructive" };
const STATUS_BADGE = { active: "default", expired: "secondary", revoked: "outline" }; const STATUS_BADGE = { active: "default", expired: "secondary", revoked: "outline" };
function InfoRow({ label, children }) { function InfoRow({ label, children }) {
@@ -50,20 +52,46 @@ export default function UserTierList() {
const { userId } = useParams(); const { userId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { userTiers, plans, loading, fetchUserTiers, fetchPlans, grantTier, revokeTier } = useTiers(); const { userTiers, plans, loading, fetchUserTiers, fetchPlans, grantTier, revokeTier } = useTiers();
const { fmtDate, fmtDateTime } = useDateFormat();
const [grantOpen, setGrantOpen] = useState(false); const [grantOpen, setGrantOpen] = useState(false);
const [revokeTarget, setRevokeTarget] = useState(null); const [revokeTarget, setRevokeTarget] = useState(null);
const [grantForm, setGrantForm] = useState({ tier: "premium", plan_id: "", notes: "" }); const [grantForm, setGrantForm] = useState({ tier: "", plan_id: "", notes: "" });
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [tierCategories, setTierCategories] = useState([]);
const tierMap = useMemo(() => {
const m = {};
tierCategories.forEach((c) => { m[c.slug] = c; });
return m;
}, [tierCategories]);
const grantableCategories = useMemo(
() => tierCategories.filter((c) => !c.is_default && c.is_active),
[tierCategories]
);
useEffect(() => { useEffect(() => {
fetchUserTiers(userId); fetchUserTiers(userId);
fetchPlans(); fetchPlans();
api.get("/admin/tiers/categories")
.then(({ data }) => {
const all = data.data ?? [];
setTierCategories(all);
const grantable = all.filter((c) => !c.is_default && c.is_active);
if (grantable.length > 0) setGrantForm((p) => ({ ...p, tier: grantable[0].slug }));
})
.catch(() => {});
}, [userId]); }, [userId]);
const activePlans = plans.filter((p) => p.is_active); const activePlans = plans.filter((p) => p.is_active);
const filteredPlans = activePlans.filter((p) => p.tier === grantForm.tier); const filteredPlans = activePlans.filter((p) => p.tier === grantForm.tier);
const tierBadge = (slug) => {
const { cls, label } = resolveTierBadge(slug, tierMap);
return <Badge className={`${cls} capitalize`}>{label}</Badge>;
};
const handleGrant = async () => { const handleGrant = async () => {
if (!grantForm.plan_id) return; if (!grantForm.plan_id) return;
setSubmitting(true); setSubmitting(true);
@@ -118,12 +146,10 @@ export default function UserTierList() {
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide">Current Tier</p> <p className="text-xs text-muted-foreground uppercase tracking-wide">Current Tier</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant={TIER_BADGE[activeTier.tier] ?? "outline"} className="capitalize text-sm px-3 py-0.5"> <span className="text-sm px-3 py-0.5">{tierBadge(activeTier.tier)}</span>
{activeTier.tier}
</Badge>
{activeTier.expires_at && ( {activeTier.expires_at && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Expires {new Date(activeTier.expires_at).toLocaleDateString()} Expires {fmtDate(activeTier.expires_at)}
</span> </span>
)} )}
</div> </div>
@@ -152,17 +178,17 @@ export default function UserTierList() {
<div key={t.tier_id} className="rounded-lg border p-4 space-y-3"> <div key={t.tier_id} className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant={TIER_BADGE[t.tier] ?? "outline"} className="capitalize">{t.tier}</Badge> {tierBadge(t.tier)}
<Badge variant={STATUS_BADGE[t.status] ?? "outline"} className="capitalize">{t.status}</Badge> <Badge variant={STATUS_BADGE[t.status] ?? "outline"} className="capitalize">{t.status}</Badge>
</div> </div>
<span className="text-xs text-muted-foreground">#{t.tier_id}</span> <span className="text-xs text-muted-foreground">#{t.tier_id}</span>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<InfoRow label="Starts At">{t.starts_at ? new Date(t.starts_at).toLocaleString() : "—"}</InfoRow> <InfoRow label="Starts At">{t.starts_at ? fmtDateTime(t.starts_at) : "—"}</InfoRow>
<InfoRow label="Expires At">{t.expires_at ? new Date(t.expires_at).toLocaleString() : "Never"}</InfoRow> <InfoRow label="Expires At">{t.expires_at ? fmtDateTime(t.expires_at) : "Never"}</InfoRow>
<InfoRow label="Granted By">{t.grantedByUser?.email ?? (t.granted_by ? `#${t.granted_by}` : "Self-serve")}</InfoRow> <InfoRow label="Granted By">{t.grantedByUser?.email ?? (t.granted_by ? `#${t.granted_by}` : "Self-serve")}</InfoRow>
{t.revoked_at && ( {t.revoked_at && (
<InfoRow label="Revoked At">{new Date(t.revoked_at).toLocaleString()}</InfoRow> <InfoRow label="Revoked At">{fmtDateTime(t.revoked_at)}</InfoRow>
)} )}
</div> </div>
{t.notes && <p className="text-xs text-muted-foreground italic">{t.notes}</p>} {t.notes && <p className="text-xs text-muted-foreground italic">{t.notes}</p>}
@@ -191,8 +217,9 @@ export default function UserTierList() {
> >
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="premium">Premium</SelectItem> {grantableCategories.map((c) => (
<SelectItem value="exclusive">Exclusive</SelectItem> <SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
+16 -9
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useState, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, CreditCard, BadgeCheck, User } from "lucide-react"; import { ArrowLeft, House, CreditCard, BadgeCheck, User } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -7,7 +7,10 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useTiers } from "@/contexts/AdminTiersContext"; import { useTiers } from "@/contexts/AdminTiersContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import api from "@/utils/api.util";
import { resolveTierBadge } from "@/utils/tierBadge.util";
const STATUS_BADGE = { const STATUS_BADGE = {
pending: "secondary", pending: "secondary",
@@ -17,7 +20,6 @@ const STATUS_BADGE = {
expired: "outline", expired: "outline",
refunded: "outline", refunded: "outline",
}; };
const TIER_BADGE = { free: "secondary", premium: "default", exclusive: "destructive" };
// ─── Provider label map (extend as you add more providers) ─────────────────── // ─── Provider label map (extend as you add more providers) ───────────────────
const PROVIDER_LABELS = { const PROVIDER_LABELS = {
@@ -102,7 +104,7 @@ function ProviderReference({ payment }) {
{/* Cancelled info — shown for any provider */} {/* Cancelled info — shown for any provider */}
{payload.cancelled_at && ( {payload.cancelled_at && (
<InfoRow label="Cancelled At"> <InfoRow label="Cancelled At">
{new Date(payload.cancelled_at).toLocaleString()} {fmtDateTime(payload.cancelled_at)}
</InfoRow> </InfoRow>
)} )}
@@ -117,8 +119,15 @@ export default function ViewPayment() {
const navigate = useNavigate(); const navigate = useNavigate();
const { paymentId } = useParams(); const { paymentId } = useParams();
const { fetchPayment, payment, loading } = useTiers(); const { fetchPayment, payment, loading } = useTiers();
const { fmtDateTime } = useDateFormat();
useEffect(() => { fetchPayment(paymentId); }, [fetchPayment, paymentId]); const [tierCategories, setTierCategories] = useState([]);
const tierMap = useMemo(() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])), [tierCategories]);
useEffect(() => {
fetchPayment(paymentId);
api.get("/admin/tiers/categories").then(({ data }) => setTierCategories(data.data ?? [])).catch(() => {});
}, [paymentId]);
return ( return (
<section className="bg-muted/60 min-h-full"> <section className="bg-muted/60 min-h-full">
@@ -174,10 +183,10 @@ export default function ViewPayment() {
</InfoRow> </InfoRow>
)} )}
<InfoRow label="Paid At"> <InfoRow label="Paid At">
{payment.paid_at ? new Date(payment.paid_at).toLocaleString() : "—"} {payment.paid_at ? fmtDateTime(payment.paid_at) : "—"}
</InfoRow> </InfoRow>
<InfoRow label="Created At"> <InfoRow label="Created At">
{payment.createdAt ? new Date(payment.createdAt).toLocaleString() : "—"} {payment.createdAt ? fmtDateTime(payment.createdAt) : "—"}
</InfoRow> </InfoRow>
</div> </div>
</SectionCard> </SectionCard>
@@ -196,9 +205,7 @@ export default function ViewPayment() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<InfoRow label="Label">{payment.plan.label}</InfoRow> <InfoRow label="Label">{payment.plan.label}</InfoRow>
<InfoRow label="Tier"> <InfoRow label="Tier">
<Badge variant={TIER_BADGE[payment.plan.tier] ?? "outline"} className="capitalize mt-0.5"> {(() => { const { cls, label } = resolveTierBadge(payment.plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
{payment.plan.tier}
</Badge>
</InfoRow> </InfoRow>
<InfoRow label="Duration">{payment.plan.duration_days} days</InfoRow> <InfoRow label="Duration">{payment.plan.duration_days} days</InfoRow>
</div> </div>
+25 -33
View File
@@ -1,15 +1,17 @@
import { useEffect, useState } from "react"; import { useEffect, useState, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Pencil, Tag, BadgeCheck, BookOpen } from "lucide-react"; import { ArrowLeft, House, Pencil, Tag, BadgeCheck, ShieldCheck } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useTiers } from "@/contexts/AdminTiersContext"; import { useTiers } from "@/contexts/AdminTiersContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import api from "@/utils/api.util";
import { resolveTierBadge } from "@/utils/tierBadge.util";
const TIER_BADGE = { premium: "default", exclusive: "destructive" };
const STATUS_BADGE = { true: "default", false: "secondary" }; const STATUS_BADGE = { true: "default", false: "secondary" };
function InfoRow({ label, children }) { function InfoRow({ label, children }) {
@@ -48,11 +50,15 @@ function LoadingSkeleton() {
export default function ViewPlan() { export default function ViewPlan() {
const navigate = useNavigate(); const navigate = useNavigate();
const { planId } = useParams(); const { planId } = useParams();
const { fetchPlan, fetchPlanCourses, planCourses, plan, loading } = useTiers(); const { fetchPlan, plan, loading } = useTiers();
const { fmtDateTime } = useDateFormat();
const [tierCategories, setTierCategories] = useState([]);
const tierMap = useMemo(() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])), [tierCategories]);
useEffect(() => { useEffect(() => {
fetchPlan(planId); fetchPlan(planId);
fetchPlanCourses(planId); api.get("/admin/tiers/categories").then(({ data }) => setTierCategories(data.data ?? [])).catch(() => {});
}, [planId]); }, [planId]);
return ( return (
@@ -79,6 +85,16 @@ export default function ViewPlan() {
<p className="text-sm text-muted-foreground">View plan information.</p> <p className="text-sm text-muted-foreground">View plan information.</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/tiers/plans/${planId}/policy`)}
disabled={loading}
>
<ShieldCheck className="h-4 w-4 mr-2" />
Policy
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -89,6 +105,7 @@ export default function ViewPlan() {
Edit Edit
</Button> </Button>
</div> </div>
</div>
{loading && !plan ? ( {loading && !plan ? (
<LoadingSkeleton /> <LoadingSkeleton />
@@ -101,9 +118,7 @@ export default function ViewPlan() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<InfoRow label="Label">{plan.label}</InfoRow> <InfoRow label="Label">{plan.label}</InfoRow>
<InfoRow label="Tier"> <InfoRow label="Tier">
<Badge variant={TIER_BADGE[plan.tier] ?? "outline"} className="capitalize mt-0.5"> {(() => { const { cls, label } = resolveTierBadge(plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
{plan.tier}
</Badge>
</InfoRow> </InfoRow>
<InfoRow label="Duration">{plan.duration_days} days</InfoRow> <InfoRow label="Duration">{plan.duration_days} days</InfoRow>
<InfoRow label="Price"> <InfoRow label="Price">
@@ -121,37 +136,14 @@ export default function ViewPlan() {
<SectionCard icon={BadgeCheck} title="Audit"> <SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<InfoRow label="Created At"> <InfoRow label="Created At">
{plan.createdAt ? new Date(plan.createdAt).toLocaleString() : "—"} {plan.createdAt ? fmtDateTime(plan.createdAt) : "—"}
</InfoRow> </InfoRow>
<InfoRow label="Updated At"> <InfoRow label="Updated At">
{plan.updatedAt ? new Date(plan.updatedAt).toLocaleString() : "—"} {plan.updatedAt ? fmtDateTime(plan.updatedAt) : "—"}
</InfoRow> </InfoRow>
</div> </div>
</SectionCard> </SectionCard>
<SectionCard icon={BookOpen} title="Assigned Courses">
{planCourses.length === 0 ? (
<p className="text-sm text-muted-foreground">No courses assigned to this plan.</p>
) : (
<div className="space-y-2">
{planCourses.map((course) => (
<div key={course.course_id} className="flex items-center justify-between rounded-lg border px-4 py-2.5">
<div className="flex items-center gap-3">
<BookOpen className="h-4 w-4 text-muted-foreground shrink-0" />
<div>
<p className="text-sm font-medium">{course.title}</p>
{course.course_code && (
<p className="text-xs text-muted-foreground">{course.course_code}</p>
)}
</div>
</div>
<Badge variant="outline" className="capitalize text-xs">{course.level ?? "—"}</Badge>
</div>
))}
</div>
)}
</SectionCard>
</div> </div>
)} )}
</div> </div>
+200 -14
View File
@@ -2,7 +2,7 @@
import { useRef, useMemo, useState, useEffect, useCallback } from "react"; import { useRef, useMemo, useState, useEffect, useCallback } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { House, Users, QrCode, Download, Copy, Check, Link } from "lucide-react"; import { House, Users, QrCode, Download, Copy, Check, Link, UserCheck, Search, X } from "lucide-react";
import { QRCodeCanvas } from "qrcode.react"; import { QRCodeCanvas } from "qrcode.react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -16,9 +16,21 @@ import {
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { useUserGroups } from "@/contexts/AdminUserGroupContext"; import { useUserGroups } from "@/contexts/AdminUserGroupContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { buildDataColumns, columnPinning } from "../../config/user_groups/view/columns.config"; import { buildDataColumns, columnPinning } from "../../config/user_groups/view/columns.config";
import { buildToolbarActions } from "../../config/user_groups/view/toolbar.config"; import { buildToolbarActions } from "../../config/user_groups/view/toolbar.config";
@@ -118,6 +130,142 @@ function InviteLinkDialog({ open, onOpenChange, group }) {
); );
} }
// ─── Assign Group Dialog ──────────────────────────────────────────────────────
function AssignGroupDialog({ open, onOpenChange, userCount, groups, loading, onAssign }) {
const [selectedGroupId, setSelectedGroupId] = useState(null);
const [search, setSearch] = useState('');
useEffect(() => {
if (open) {
setSelectedGroupId(null);
setSearch('');
}
}, [open]);
const realGroups = groups.filter((g) => (g.group_code ?? '') !== 'NOGRP' && g.is_active);
const filtered = realGroups.filter((g) => {
if (!search.trim()) return true;
const q = search.toLowerCase();
return g.name.toLowerCase().includes(q) || (g.group_code ?? '').toLowerCase().includes(q);
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserCheck className="size-4" /> Assign to Group
</DialogTitle>
<DialogDescription>
Select a group to assign{" "}
{userCount === 1 ? "this user" : `${userCount} users`} to.
They will be added to the selected group.
</DialogDescription>
</DialogHeader>
<Command shouldFilter={false} className="rounded-lg border shadow-sm">
{/* ── Search input ── */}
<div className="flex items-center gap-2 border-b px-3 py-2">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search groups..."
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground py-0.5"
/>
{search && (
<button
onClick={() => setSearch('')}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* ── Group list ── */}
<CommandList>
<ScrollArea className="h-64">
{filtered.length === 0 ? (
<CommandEmpty className="py-8 text-sm text-center text-muted-foreground">
{search ? `No results for "${search}".` : 'No groups available.'}
</CommandEmpty>
) : (
<CommandGroup>
{filtered.map((g) => {
const isSelected = selectedGroupId === g.group_id;
return (
<CommandItem
key={g.group_id}
value={String(g.group_id)}
onSelect={() => setSelectedGroupId(isSelected ? null : g.group_id)}
className={cn(
"flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors",
"aria-selected:bg-transparent data-selected:bg-transparent",
isSelected
? "bg-primary/[0.06] hover:bg-primary/[0.10]"
: "hover:bg-muted/50"
)}
>
{/* Embossed checkbox */}
<span className={cn(
"h-4 w-4 shrink-0 rounded border-2 flex items-center justify-center transition-all",
isSelected
? "bg-primary border-primary shadow-none ring-2 ring-primary/25 ring-offset-1"
: [
"bg-background border-border",
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.10),inset_0_1px_2px_rgba(0,0,0,0.06)]",
"hover:border-primary/60",
]
)}>
{isSelected && (
<Check className="h-2.5 w-2.5 text-primary-foreground stroke-[3.5]" />
)}
</span>
{/* Name */}
<span className={cn(
"flex-1 text-sm truncate",
isSelected ? "font-medium text-foreground" : "text-foreground/90"
)}>
{g.name}
</span>
{/* Group code pill */}
<span className={cn(
"font-mono text-[11px] px-1.5 py-0.5 rounded shrink-0",
isSelected
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground"
)}>
{g.group_code}
</span>
</CommandItem>
);
})}
</CommandGroup>
)}
</ScrollArea>
</CommandList>
</Command>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button
disabled={!selectedGroupId || loading}
onClick={() => onAssign(selectedGroupId)}
>
Assign
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── Page ───────────────────────────────────────────────────────────────────── // ─── Page ─────────────────────────────────────────────────────────────────────
export default function ViewGroup() { export default function ViewGroup() {
const { groupId } = useParams(); const { groupId } = useParams();
@@ -135,13 +283,19 @@ export default function ViewGroup() {
const [archiveIds, setArchiveIds] = useState(null); const [archiveIds, setArchiveIds] = useState(null);
const [memberAttrs, setMemberAttrs] = useState([]); const [memberAttrs, setMemberAttrs] = useState([]);
const [assignOpen, setAssignOpen] = useState(false);
const [assignTarget, setAssignTarget] = useState(null); // single row
const [assignIds, setAssignIds] = useState(null); // bulk ids[]
const { const {
group, group,
groups,
members, members,
usersNotIn, usersNotIn,
pagination, pagination,
setPagination, setPagination,
loading, loading,
fetchGroups,
fetchGroup, fetchGroup,
fetchGroupFieldValues, fetchGroupFieldValues,
fetchUsersNotInGroup, fetchUsersNotInGroup,
@@ -149,6 +303,8 @@ export default function ViewGroup() {
removeUsersFromGroup, removeUsersFromGroup,
} = useUserGroups(); } = useUserGroups();
const isNoGroup = group?.group_code === 'NOGRP';
useEffect(() => { useEffect(() => {
if (!groupId) return; if (!groupId) return;
fetchGroup(groupId).then((res) => { fetchGroup(groupId).then((res) => {
@@ -170,6 +326,8 @@ export default function ViewGroup() {
const rowActions = buildRowActions({ const rowActions = buildRowActions({
onRemove: (row) => setArchiveTarget(row), onRemove: (row) => setArchiveTarget(row),
onAssign: (row) => openAssignDialog(row),
isNoGroup,
}); });
const toolbarActions = buildToolbarActions({ const toolbarActions = buildToolbarActions({
@@ -186,11 +344,13 @@ export default function ViewGroup() {
exportConfig, exportConfig,
onRemoveMember: (row) => setArchiveTarget(row), onRemoveMember: (row) => setArchiveTarget(row),
onRemoveMembers: (ids) => setArchiveIds(ids), onRemoveMembers: (ids) => setArchiveIds(ids),
onAssignMembers: (ids) => openAssignDialog(ids),
isNoGroup,
}); });
const columns = useMemo( const columns = useMemo(
() => buildDataColumns(memberAttrs, rowActions), () => buildDataColumns(memberAttrs, rowActions),
[memberAttrs], [memberAttrs, isNoGroup],
); );
const handleRemoveSuccess = () => { const handleRemoveSuccess = () => {
@@ -200,23 +360,38 @@ export default function ViewGroup() {
fetchGroup(groupId, { page: 1, limit: pagination.limit }); fetchGroup(groupId, { page: 1, limit: pagination.limit });
}; };
const openAssignDialog = (rowOrIds) => {
fetchGroups({ limit: 100 });
if (Array.isArray(rowOrIds)) {
setAssignIds(rowOrIds);
setAssignTarget(null);
} else {
setAssignTarget(rowOrIds);
setAssignIds(null);
}
setAssignOpen(true);
};
const handleAssign = async (targetGroupId) => {
const ids = assignIds ?? [assignTarget?.user_id];
await addUsersToGroup(targetGroupId, ids);
setAssignOpen(false);
setAssignTarget(null);
setAssignIds(null);
tableRefsRef.current.resetSelection?.();
fetchGroup(groupId, { page: 1, limit: pagination.limit });
};
const breadcrumbItems = [ const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" }, { label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "User Groups", to: "/admin/groups" }, { label: "User Groups", to: "/admin/groups" },
{ label: group?.name ?? "View Group" }, { label: group?.name ?? "View Group" },
]; ];
const formattedCreated = group?.createdAt const { fmtDate } = useDateFormat();
? new Date(group.createdAt).toLocaleDateString("en-PH", {
year: "numeric", month: "long", day: "numeric",
})
: "—";
const formattedUpdated = group?.updatedAt const formattedCreated = group?.createdAt ? fmtDate(group.createdAt) : "—";
? new Date(group.updatedAt).toLocaleDateString("en-PH", { const formattedUpdated = group?.updatedAt ? fmtDate(group.updatedAt) : "—";
year: "numeric", month: "long", day: "numeric",
})
: "—";
const handleFetch = useCallback( const handleFetch = useCallback(
(params) => fetchGroup(groupId, params), (params) => fetchGroup(groupId, params),
@@ -256,8 +431,8 @@ export default function ViewGroup() {
</p> </p>
</div> </div>
{/* ── Generate invite link button ── */} {/* ── Generate invite link button — hidden for NOGRP (system default group) ── */}
{group?.group_code && ( {group?.group_code && group.group_code !== 'NOGRP' && (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -330,6 +505,16 @@ export default function ViewGroup() {
group={group} group={group}
/> />
{/* ── Assign to group dialog (NOGRP members only) ───────────────────── */}
<AssignGroupDialog
open={assignOpen}
onOpenChange={setAssignOpen}
userCount={assignIds?.length ?? (assignTarget ? 1 : 0)}
groups={groups}
loading={loading}
onAssign={handleAssign}
/>
{/* ── Add member ───────────────────────────────────────────────────── */} {/* ── Add member ───────────────────────────────────────────────────── */}
<AddSheet <AddSheet
open={addMemberOpen} open={addMemberOpen}
@@ -341,6 +526,7 @@ export default function ViewGroup() {
onFetch={() => fetchUsersNotInGroup(groupId)} onFetch={() => fetchUsersNotInGroup(groupId)}
idKey="user_id" idKey="user_id"
labelKey="full_name" labelKey="full_name"
warningKey="current_group"
onSubmit={async (user_ids) => { onSubmit={async (user_ids) => {
await addUsersToGroup(groupId, user_ids); await addUsersToGroup(groupId, user_ids);
fetchGroup(groupId, { page: 1, limit: pagination.limit }); fetchGroup(groupId, { page: 1, limit: pagination.limit });
+158 -12
View File
@@ -1,18 +1,23 @@
// ─── pages/users/ViewUser.jsx ───────────────────────────────────────────────── // ─── pages/users/ViewUser.jsx ─────────────────────────────────────────────────
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useUsers } from "@/contexts/AdminUserContext"; import { useUsers } from "@/contexts/AdminUserContext";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { ArrowLeft, Trophy, Award, BadgeCheck, Activity, ChevronLeft, ChevronRight } from "lucide-react"; import { House, Trophy, Award, BadgeCheck, Activity, ChevronLeft, ChevronRight, ShieldBan, ShieldCheck } from "lucide-react";
import { ROLE_CONFIG } from "@/data/profile.data"; import { ROLE_CONFIG } from "@/data/profile.data";
import { BADGE_STYLES } from "@/utils/table.util"; import { BADGE_STYLES } from "@/utils/table.util";
import { getActionBadge } from "@/data/activity.data"; import { getActionBadge } from "@/data/activity.data";
import { timeAgo } from "@/utils/timestamp.util"; import { timeAgo } from "@/utils/timestamp.util";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { BanUserDialog } from "@/components/generic/Dialogs/BanUserDialog";
import { UnbanDialog } from "@/components/generic/Dialogs/UnbanDialog";
import { useDateFormat } from "@/hooks/useDateFormat";
// ─── Helper ─────────────────────────────────────────────────────────────────── // ─── Helper ───────────────────────────────────────────────────────────────────
const StatusBadge = ({ value }) => ( const StatusBadge = ({ value }) => (
@@ -26,18 +31,24 @@ const ACHIEVEMENT_ICON = { badge: BadgeCheck, milestone: Trophy };
export default function ViewUser() { export default function ViewUser() {
const { userId } = useParams(); const { userId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { fmtDate, fmtDateTime } = useDateFormat();
const { const {
user, fetchUser, loading, user, fetchUser, loading,
achievements, achievementsLoading, fetchUserAchievements, achievements, achievementsLoading, fetchUserAchievements,
activity, activityPagination, activityLoading, fetchUserActivity, activity, activityPagination, activityLoading, fetchUserActivity,
bans, bansLoading, fetchUserBans,
banUser, unbanUser,
} = useUsers(); } = useUsers();
const [activityPage, setActivityPage] = useState(1); const [activityPage, setActivityPage] = useState(1);
const [banDialogOpen, setBanDialogOpen] = useState(false);
const [unbanDialogOpen, setUnbanDialogOpen] = useState(false);
useEffect(() => { useEffect(() => {
fetchUser(userId); fetchUser(userId);
fetchUserAchievements(userId); fetchUserAchievements(userId);
fetchUserActivity(userId, { page: 1, limit: 10 }); fetchUserActivity(userId, { page: 1, limit: 10 });
fetchUserBans(userId);
}, [userId]); }, [userId]);
const loadActivityPage = (p) => { const loadActivityPage = (p) => {
@@ -64,19 +75,56 @@ export default function ViewUser() {
return ( return (
<div className="lg:container lg:mx-auto px-4 py-6 flex flex-col gap-6"> <div className="lg:container lg:mx-auto px-4 py-6 flex flex-col gap-6">
{/* ─── Breadcrumb ──────────────────────────────────────────────────── */}
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Users", to: "/admin/users" },
{ label: name.full_name ?? user.email ?? "View User" },
]} />
{/* ─── Header ──────────────────────────────────────────────────────── */} {/* ─── Header ──────────────────────────────────────────────────────── */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/users`)}> <Avatar className="size-11 shrink-0">
<ArrowLeft className="size-4" /> <AvatarImage src={user.personal_info?.avatar?.url ?? undefined} alt={name.full_name ?? user.email} />
</Button> <AvatarFallback className="text-sm font-semibold">
{(name.full_name ?? user.email ?? "?")
.split(" ").map((n) => n[0]).slice(0, 2).join("").toUpperCase()}
</AvatarFallback>
</Avatar>
<div> <div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
{name.full_name ?? "—"} {name.full_name ?? "—"}
</h1> </h1>
{user.is_banned && (
<Badge variant="outline" className="text-xs text-destructive border-destructive/50 bg-destructive/5">
<ShieldBan className="size-3 mr-1" /> Banned
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm">{user.email}</p> <p className="text-muted-foreground text-sm">{user.email}</p>
</div> </div>
</div> </div>
<div className="flex gap-2">
{user.is_banned ? (
<Button
size="sm"
className="bg-emerald-600 text-white hover:bg-emerald-700"
onClick={() => setUnbanDialogOpen(true)}
>
<ShieldCheck className="size-4 mr-1.5" /> Unban User
</Button>
) : user.is_active ? (
<Button
size="sm"
variant="destructive"
onClick={() => setBanDialogOpen(true)}
>
<ShieldBan className="size-4 mr-1.5" /> Ban User
</Button>
) : null}
</div>
</div> </div>
{/* ─── Account Info ────────────────────────────────────────────────── */} {/* ─── Account Info ────────────────────────────────────────────────── */}
@@ -87,12 +135,20 @@ export default function ViewUser() {
<Field label="Status"> <Field label="Status">
<StatusBadge value={user.is_active ? "Active" : "Not Active"} /> <StatusBadge value={user.is_active ? "Active" : "Not Active"} />
</Field> </Field>
<Field label="Ban Status">
<StatusBadge value={user.is_banned ? "Banned" : "Not Banned"} />
</Field>
<Field label="Verified"> <Field label="Verified">
<StatusBadge value={user.is_verified ? "Verified" : "Not Verified"} /> <StatusBadge value={user.is_verified ? "Verified" : "Not Verified"} />
</Field> </Field>
<Field label="Registration Type"> <Field label="Registration Type">
<StatusBadge value={user.reg_type} /> <StatusBadge value={user.reg_type} />
</Field> </Field>
{user.ban_expires_at && (
<Field label="Ban Expires">
{fmtDateTime(user.ban_expires_at)}
</Field>
)}
</Section> </Section>
{/* ─── Personal Info ───────────────────────────────────────────────── */} {/* ─── Personal Info ───────────────────────────────────────────────── */}
@@ -169,7 +225,7 @@ export default function ViewUser() {
<p className="text-sm font-medium truncate">{a.label}</p> <p className="text-sm font-medium truncate">{a.label}</p>
<p className="text-xs text-muted-foreground truncate">{a.description}</p> <p className="text-xs text-muted-foreground truncate">{a.description}</p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{new Date(a.granted_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} {fmtDate(a.granted_at)}
</p> </p>
</div> </div>
{isCert && ( {isCert && (
@@ -244,10 +300,7 @@ export default function ViewUser() {
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="left"> <TooltipContent side="left">
{new Date(row.created_at).toLocaleString("en-US", { {fmtDateTime(row.created_at)}
month: "short", day: "numeric", year: "numeric",
hour: "numeric", minute: "2-digit", second: "2-digit",
})}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
@@ -283,13 +336,106 @@ export default function ViewUser() {
)} )}
</div> </div>
{/* ─── Ban History ─────────────────────────────────────────────────── */}
<div className="bg-card border rounded-lg p-6 flex flex-col gap-4">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<ShieldBan className="size-4" /> Ban History
{bans.length > 0 && (
<Badge variant="secondary" className="ml-auto font-normal">
{bans.length} record{bans.length !== 1 ? "s" : ""}
</Badge>
)}
</h2>
{bansLoading ? (
<div className="space-y-3">
{[...Array(2)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-24 ml-auto" />
</div>
))}
</div>
) : bans.length === 0 ? (
<p className="text-sm text-muted-foreground">No bans on record.</p>
) : (
<div className="flex flex-col divide-y divide-border -mx-6">
{bans.map((ban) => (
<div key={ban.ban_id} className="flex flex-col gap-1 px-6 py-3 hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-2 flex-wrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border shrink-0 ${
ban.is_lifted
? "bg-emerald-50 text-emerald-700 border-emerald-300 dark:bg-emerald-900/20 dark:text-emerald-400"
: "bg-destructive/10 text-destructive border-destructive/30"
}`}>
{ban.is_lifted ? "Lifted" : ban.ban_type === "permanent" ? "Permanent" : "Temporary"}
</span>
<span className="text-sm font-medium truncate flex-1">{ban.reason}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap ml-auto">
{fmtDate(ban.banned_at)}
</span>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-0.5 text-xs text-muted-foreground">
<span>
Banned by{" "}
<span className="text-foreground font-medium">
{ban.banner?.personal_info?.name?.full_name ?? ban.banner?.email ?? "—"}
</span>
</span>
{ban.ban_type === "temporary" && ban.expires_at && (
<span>
Until{" "}
<span className="text-foreground">
{fmtDate(ban.expires_at)}
</span>
</span>
)}
{ban.is_lifted && ban.lifter && (
<span>
Lifted by{" "}
<span className="text-foreground font-medium">
{ban.lifter?.personal_info?.name?.full_name ?? ban.lifter?.email ?? "—"}
</span>
{ban.lift_reason && <> — {ban.lift_reason}</>}
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* ─── Audit ───────────────────────────────────────────────────────── */} {/* ─── Audit ───────────────────────────────────────────────────────── */}
<Section title="Audit Trail"> <Section title="Audit Trail">
<Field label="Created At">{user.createdAt ? new Date(user.createdAt).toLocaleString() : "—"}</Field> <Field label="Created At">{fmtDateTime(user.createdAt)}</Field>
<Field label="Updated At">{user.updatedAt ? new Date(user.updatedAt).toLocaleString() : "—"}</Field> <Field label="Updated At">{fmtDateTime(user.updatedAt)}</Field>
<Field label="Deleted At">{user.deletedAt ? new Date(user.deletedAt).toLocaleString() : "—"}</Field> <Field label="Deleted At">{fmtDateTime(user.deletedAt)}</Field>
</Section> </Section>
{/* ─── Ban / Unban Dialogs ─────────────────────────────────────────── */}
<BanUserDialog
open={banDialogOpen}
onOpenChange={setBanDialogOpen}
entity={user}
entityLabel="User"
getName={(u) => u?.personal_info?.name?.full_name ?? u?.email}
onBan={(payload) => banUser(userId, payload)}
loading={loading}
onSuccess={() => { fetchUser(userId); fetchUserBans(userId); }}
/>
<UnbanDialog
open={unbanDialogOpen}
onOpenChange={setUnbanDialogOpen}
entity={user}
entityLabel="User"
getName={(u) => u?.personal_info?.name?.full_name ?? u?.email}
onUnban={(payload) => unbanUser(userId, payload)}
loading={loading}
onSuccess={() => { fetchUser(userId); fetchUserBans(userId); }}
/>
</div> </div>
); );
} }
+16 -3
View File
@@ -55,7 +55,7 @@ import LessonPageBuilder from '../pages/courses/lessons/LessonPageBuilder'
import ViewLessonPage from '../pages/courses/lessons/ViewLessonPage' import ViewLessonPage from '../pages/courses/lessons/ViewLessonPage'
import CourseAssessment from '../pages/courses/CourseAssessment' import CourseAssessment from '../pages/courses/CourseAssessment'
import ViewAssessment from '../pages/courses/ViewAssessment' import ViewAssessment from '../pages/courses/ViewAssessment'
import UnitQuiz from '../pages/courses/units/UnitQuiz' import ModifyQuiz from '../pages/courses/units/ModifyQuiz'
import ViewUnitQuiz from '../pages/courses/units/ViewUnitQuiz' import ViewUnitQuiz from '../pages/courses/units/ViewUnitQuiz'
// Task List // Task List
@@ -85,9 +85,12 @@ import PlanList from '../pages/tiers/PlanList';
import AddPlan from '../pages/tiers/AddPlan'; import AddPlan from '../pages/tiers/AddPlan';
import ViewPlan from '../pages/tiers/ViewPlan'; import ViewPlan from '../pages/tiers/ViewPlan';
import EditPlan from '../pages/tiers/EditPlan'; import EditPlan from '../pages/tiers/EditPlan';
import SystemBadges from '../pages/tiers/SystemBadges';
import UserTierList from '../pages/tiers/UserTierList'; import UserTierList from '../pages/tiers/UserTierList';
import PaymentList from '../pages/tiers/PaymentList'; import PaymentList from '../pages/tiers/PaymentList';
import ViewPayment from '../pages/tiers/ViewPayment'; import ViewPayment from '../pages/tiers/ViewPayment';
import TierCategories from '../pages/tiers/TierCategories';
import { AddTierCategory, EditTierCategory } from '../pages/tiers/EditTierCategory';
import TaskSubmissions from '../pages/task_list/task/TaskCompletion' import TaskSubmissions from '../pages/task_list/task/TaskCompletion'
import ViewTaskCompletion from '../pages/task_list/task/ViewTaskCompletion' import ViewTaskCompletion from '../pages/task_list/task/ViewTaskCompletion'
@@ -123,6 +126,7 @@ export const AdminRoutes = {
{ path: 'add/staff', element: <AddUser /> }, { path: 'add/staff', element: <AddUser /> },
{ path: 'view/:userId', element: <ViewUser /> }, { path: 'view/:userId', element: <ViewUser /> },
{ path: 'archived', element: <ArchivedUserList /> }, { path: 'archived', element: <ArchivedUserList /> },
{ path: ':userId/activity', element: <UserActivityPage /> },
] ]
}, },
@@ -187,7 +191,7 @@ export const AdminRoutes = {
{ path: 'add', element: <AddUnit /> }, { path: 'add', element: <AddUnit /> },
{ path: ':unitId/view', element: <ViewUnit /> }, { path: ':unitId/view', element: <ViewUnit /> },
{ path: ':unitId/edit', element: <EditUnit /> }, { path: ':unitId/edit', element: <EditUnit /> },
{ path: ":unitId/quiz", element: <UnitQuiz /> }, { path: ":unitId/quiz/edit", element: <ModifyQuiz /> },
{ path: ":unitId/quiz/view", element: <ViewUnitQuiz /> }, { path: ":unitId/quiz/view", element: <ViewUnitQuiz /> },
// Lessons // Lessons
@@ -252,6 +256,16 @@ export const AdminRoutes = {
{ path: ':planId/edit', element: <EditPlan /> }, { path: ':planId/edit', element: <EditPlan /> },
] ]
}, },
{ path: 'system-badges', element: <SystemBadges /> },
{
path: 'categories',
element: <Outlet />,
children: [
{ index: true, element: <TierCategories /> },
{ path: 'add', element: <AddTierCategory /> },
{ path: ':id/edit', element: <EditTierCategory /> },
],
},
{ {
path: 'users/:userId/tiers', path: 'users/:userId/tiers',
element: <UserTierList />, element: <UserTierList />,
@@ -283,7 +297,6 @@ export const AdminRoutes = {
// Activity Feed // Activity Feed
{ path: 'activity', element: <ActivityFeed /> }, { path: 'activity', element: <ActivityFeed /> },
{ path: 'users/:userId/activity', element: <UserActivityPage /> },
// Add here // Add here
] ]
+11
View File
@@ -71,6 +71,17 @@ export function LoginForm({ className, ...props }) {
return return
} }
if (result.errors?.banned) {
navigate('/suspended', {
state: {
reason: result.errors.reason ?? null,
ban_type: result.errors.ban_type ?? null,
ban_expires_at: result.errors.ban_expires_at ?? null,
},
})
return
}
setErrorMessage(result.message || 'Invalid credentials.') setErrorMessage(result.message || 'Invalid credentials.')
setErrorDialogOpen(true) setErrorDialogOpen(true)
} }
+72 -11
View File
@@ -18,29 +18,90 @@
* Date Created: Jun. 21, 2026 * Date Created: Jun. 21, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
import { useSearchParams, Link } from 'react-router-dom' import { useSearchParams, Link } from 'react-router-dom'
import { LoaderCircle } from 'lucide-react' import { LoaderCircle, ShieldBan, UserX, AlertTriangle, RefreshCw, Clock } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useDateFormat } from '@/hooks/useDateFormat'
const ERROR_MESSAGES = { const ERROR_MAP = {
access_denied: 'You cancelled the Google sign-in.', access_denied: { icon: UserX, message: 'You cancelled the Google sign-in.' },
account_deactivated: 'Your account has been deactivated. Please contact support.', account_deactivated: { icon: UserX, message: 'Your account has been deactivated. Please contact support.' },
session_expired: 'The sign-in session expired. Please try again.', session_expired: { icon: Clock, message: 'The sign-in session expired. Please try again.' },
state_mismatch: 'Security check failed. Please try signing in again.', state_mismatch: { icon: AlertTriangle, message: 'Security check failed. Please try signing in again.' },
auth_failed: 'Google sign-in failed. Please try again.', auth_failed: { icon: RefreshCw, message: 'Google sign-in failed. Please try again.' },
} }
export default function OAuthCallback() { export default function OAuthCallback() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const { fmtDateTime } = useDateFormat()
const error = searchParams.get('error') const error = searchParams.get('error')
if (error === 'account_banned') {
const reason = searchParams.get('reason')
const banType = searchParams.get('ban_type')
const expiresAt = searchParams.get('expires_at')
const expiryText = expiresAt ? fmtDateTime(expiresAt) : null
return (
<div className="min-h-svh flex items-center justify-center bg-muted/40 px-4">
<div className="flex flex-col items-center text-center gap-6 max-w-md w-full">
<div className="flex items-center justify-center w-20 h-20 rounded-full bg-destructive/10">
<ShieldBan className="size-10 text-destructive" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">Account Suspended</h1>
<p className="text-sm text-muted-foreground text-balance">
Your account has been suspended and you cannot access the platform at this time.
</p>
</div>
{(reason || banType) && (
<div className="w-full rounded-lg border bg-card p-4 text-left flex flex-col gap-3">
{reason && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium mb-0.5">Reason</p>
<p className="text-sm">{reason}</p>
</div>
)}
{banType === 'temporary' && expiryText && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium mb-0.5">Suspended Until</p>
<p className="text-sm">{expiryText}</p>
</div>
)}
{banType === 'permanent' && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium mb-0.5">Duration</p>
<p className="text-sm">Permanent</p>
</div>
)}
</div>
)}
<p className="text-sm text-muted-foreground">
If you believe this is a mistake, please reach out to your administrator for assistance.
</p>
<Button asChild variant="outline" size="sm">
<Link to="/login">Back to Login</Link>
</Button>
</div>
</div>
)
}
if (error) { if (error) {
const { icon: Icon, message } = ERROR_MAP[error] ?? { icon: AlertTriangle, message: 'An unexpected error occurred. Please try again.' }
return ( return (
<div className="flex min-h-svh items-center justify-center p-6"> <div className="flex min-h-svh items-center justify-center p-6">
<div className="flex flex-col items-center gap-4 text-center max-w-sm"> <div className="flex flex-col items-center gap-4 text-center max-w-sm">
<p className="text-sm text-destructive font-medium"> <div className="flex items-center justify-center w-12 h-12 rounded-full bg-muted">
{ERROR_MESSAGES[error] ?? 'An unexpected error occurred. Please try again.'} <Icon className="size-5 text-muted-foreground" />
</p> </div>
<Button asChild variant="outline"> <p className="text-sm text-destructive font-medium">{message}</p>
<Button asChild variant="outline" size="sm">
<Link to="/login">Back to Login</Link> <Link to="/login">Back to Login</Link>
</Button> </Button>
</div> </div>
+2
View File
@@ -6,6 +6,7 @@ import LandingPage from '@/modules/public/pages/LandingPage'
import Login from '../pages/Login' import Login from '../pages/Login'
import Register from '../pages/Register' import Register from '../pages/Register'
import OAuthCallback from '../pages/OAuthCallback' import OAuthCallback from '../pages/OAuthCallback'
import Suspended from '@/modules/public/pages/Suspended'
export const AuthRoutes = { export const AuthRoutes = {
@@ -19,6 +20,7 @@ export const AuthRoutes = {
{ path: "login", element: <Login />}, { path: "login", element: <Login />},
{ path: "signup", element: <Register />}, { path: "signup", element: <Register />},
{ path: "auth/callback/google", element: <OAuthCallback /> }, { path: "auth/callback/google", element: <OAuthCallback /> },
{ path: "suspended", element: <Suspended /> },
] ]
}, },
], ],
@@ -1,4 +1,4 @@
import { Trophy } from "lucide-react"; import { Trophy, Clock } from "lucide-react";
/** /**
* Props: * Props:
@@ -25,6 +25,15 @@ const CourseCompleteBlock = ({ course }) => {
You've passed all required units and the final assessment for this course. You've passed all required units and the final assessment for this course.
</p> </p>
</div> </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>
</div> </div>
); );
@@ -10,15 +10,10 @@ import {
Paperclip, Paperclip,
Plus, Plus,
X, X,
AlertTriangle,
Database,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
// ── Constants ─────────────────────────────────────────────────────────────────
const DEFAULT_MAX_BYTES = 500 * 1024 * 1024; // 500 MB
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
function formatBytes(b) { function formatBytes(b) {
if (b >= 1024 * 1024 * 1024) return (b / 1024 / 1024 / 1024).toFixed(1) + " GB"; 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 ──────────────────────────────────────────────────────────────── // ── FileUpload ────────────────────────────────────────────────────────────────
/** /**
* Standalone file upload UI — no modal, no footer buttons. * Standalone file upload UI — no modal, no footer buttons.
* Compose inside <ResponsiveModal> or any container. * Compose inside <ResponsiveModal> or any container.
* *
* Props: * Props:
* maxBytes {number} – total size cap (default: 500 MB)
* accept {string} – native <input accept> string (fallback if * accept {string} – native <input accept> string (fallback if
* allowedFileTypes not provided) * allowedFileTypes not provided)
* hint {string} – dropzone helper text (fallback if * hint {string} – dropzone helper text (fallback if
@@ -135,12 +96,11 @@ const StorageBar = ({ usedBytes, maxBytes }) => {
* maxFileCount {number} – max number of files allowed. Displayed in * maxFileCount {number} – max number of files allowed. Displayed in
* the hint and enforced client-side on file add. * the hint and enforced client-side on file add.
* onChange {function} – fires on every file list change: * onChange {function} – fires on every file list change:
* ({ files, isUploading, isOverLimit }) => void * ({ files, isUploading }) => void
* onUploadDone {function} – fires when all uploads finish: * onUploadDone {function} – fires when all uploads finish:
* ({ files }) => void * ({ files }) => void
*/ */
const FileUpload = ({ const FileUpload = ({
maxBytes = DEFAULT_MAX_BYTES,
accept, accept,
hint = "PDF, DOCX, MP4, PNG, JPG", hint = "PDF, DOCX, MP4, PNG, JPG",
allowedFileTypes, allowedFileTypes,
@@ -152,8 +112,6 @@ const FileUpload = ({
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef(null); 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"); const isUploading = files.some((f) => f.status === "uploading");
// ── Derived accept string ─────────────────────────────────────────────────── // ── Derived accept string ───────────────────────────────────────────────────
@@ -177,8 +135,7 @@ const FileUpload = ({
// Notify parent with full state // Notify parent with full state
const notify = (next) => { const notify = (next) => {
const uploading = next.some((f) => f.status === "uploading"); 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 });
onChange?.({ files: next, isUploading: uploading, isOverLimit: overLimit });
}; };
// ── simulate upload progress ────────────────────────────────────────────── // ── simulate upload progress ──────────────────────────────────────────────
@@ -204,7 +161,7 @@ const FileUpload = ({
}); });
}; };
setTimeout(tick, 300); setTimeout(tick, 300);
}, [onChange, onUploadDone, maxBytes]); }, [onChange, onUploadDone]);
// ── add files ───────────────────────────────────────────────────────────── // ── add files ─────────────────────────────────────────────────────────────
const addFiles = useCallback((rawFiles) => { const addFiles = useCallback((rawFiles) => {
@@ -277,7 +234,7 @@ const FileUpload = ({
return next; return next;
}); });
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
}, [simulateUpload, onChange, maxBytes, allowedFileTypes, maxFileCount]); }, [simulateUpload, onChange, allowedFileTypes, maxFileCount]);
// ── remove ──────────────────────────────────────────────────────────────── // ── remove ────────────────────────────────────────────────────────────────
const removeFile = (id) => { 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")} /> <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 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 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> </div>
)} )}
@@ -348,18 +305,6 @@ const FileUpload = ({
)} )}
<p className="text-xs text-muted-foreground mt-2">{derivedHint}</p> <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> </div>
)} )}
@@ -376,4 +321,4 @@ const FileUpload = ({
}; };
export default 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 { SendHorizonal } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import api from "@/utils/api.util"; 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' }; const LVL_LABEL = { beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced' };
function TierBadge({ tier, locked = false }) { function TierBadge({ tier, locked = false }) {
if (tier === 'premium') const { tierMap } = useClientTiers();
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>; const { rank, label, cls } = resolveTierBadge(tier ?? 'free', tierMap);
if (tier === 'exclusive') if (rank === 0 && !locked) return null;
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 (
if (!locked) <Badge className={`gap-1 ${cls} w-fit shrink-0`}>
return <Badge className="gap-1 bg-green-500 text-white border-0 w-fit shrink-0"><Tag className="size-3" /> Free</Badge>; {rank > 0 ? <Lock className="size-3" /> : <Tag className="size-3" />}
return null; {label}
</Badge>
);
} }
const ReadCourse = ({ title = "Read Course", courses = [] }) => { const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId, taskId }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
const [details, setDetails] = useState({}); const [details, setDetails] = useState({});
@@ -173,7 +177,21 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
</div> </div>
<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} {course.title}
</h1> </h1>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1"> <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 variant="outline" onClick={() => setSelected(null)}>Cancel</Button>
<Button <Button
onClick={() => { 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} disabled={done || !info?.course_id}
> >
@@ -2,17 +2,22 @@ import { useNavigate } from "react-router-dom";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress"; 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 { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { resolveTierBadge } from "@/utils/tierBadge.util";
function TierBadge({ tier }) { function TierBadge({ tier }) {
if (tier === 'premium') const { tierMap } = useClientTiers();
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>; const { rank, label, cls } = resolveTierBadge(tier ?? 'free', tierMap);
if (tier === 'exclusive') return (
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>; <Badge className={`gap-1 ${cls} w-fit shrink-0`}>
return null; {rank > 0 ? <Lock className="size-3" /> : <Tag className="size-3" />}
{label}
</Badge>
);
} }
const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId, taskId }) => { 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"> <div className="flex items-center gap-1.5">
<FileText className="size-3.5 text-muted-foreground shrink-0" /> <FileText className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground font-medium">Lesson</span> <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> </div>
{/* Course breadcrumb */} {/* Course breadcrumb */}
@@ -166,7 +177,7 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
)} )}
<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 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} {lesson.title}
</h1> </h1>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1"> <p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1">
@@ -1,19 +1,24 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge"; 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 { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import ResponsiveModal from "@/components/generic/ResponsiveModal"; import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { resolveTierBadge } from "@/utils/tierBadge.util";
function TierBadge({ tier }) { function TierBadge({ tier }) {
if (tier === 'premium') const { tierMap } = useClientTiers();
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>; const { rank, label, cls } = resolveTierBadge(tier ?? 'free', tierMap);
if (tier === 'exclusive') return (
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>; <Badge className={`gap-1 ${cls} w-fit shrink-0`}>
return null; {rank > 0 ? <Lock className="size-3" /> : <Tag className="size-3" />}
{label}
</Badge>
);
} }
const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskId }) => { 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"> <div className="flex items-center gap-1.5">
<Layers className="size-3.5 text-muted-foreground shrink-0" /> <Layers className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground font-medium">Unit</span> <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> </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> <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} {unit.title}
</h1> </h1>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1"> <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 { Badge } from "@/components/ui/badge";
import { import {
Card, Card,
@@ -22,8 +22,9 @@ const normalizeUrl = (url) => {
// ── Meta fetcher ────────────────────────────────────────────────────────────── // ── Meta fetcher ──────────────────────────────────────────────────────────────
const fetchLinkMeta = async (url) => { const fetchLinkMeta = async (url) => {
const normalized = normalizeUrl(url);
try { 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(); const json = await res.json();
if (json.status === "success") { if (json.status === "success") {
return { return {
@@ -36,11 +37,17 @@ const fetchLinkMeta = async (url) => {
return { title: null, description: null, image: null }; return { title: null, description: null, image: null };
}; };
const getDomain = (url) => {
try { return new URL(normalizeUrl(url)).hostname.replace(/^www\./, ''); }
catch { return url; }
};
// ── LinkCard ────────────────────────────────────────────────────────────────── // ── 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 [meta, setMeta] = useState({ title: null, description: null, image: null });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [viewModalOpen, setViewModalOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (!link.url) return; if (!link.url) return;
@@ -49,8 +56,10 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [link.url]); }, [link.url]);
const displayImage = meta.image ?? `https://avatar.vercel.sh/${encodeURIComponent(link.url)}`; const domain = getDomain(link.url);
const displayTitle = meta.title ?? link.label; 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 displayDescription = meta.description ?? link.url;
const handleTurnIn = async () => { const handleTurnIn = async () => {
@@ -58,20 +67,33 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
setModalOpen(false); setModalOpen(false);
}; };
const handleUnsubmit = async () => {
await onUnvisit(link.requirement_id);
setViewModalOpen(false);
};
return ( return (
<> <>
<Card className="relative w-72 shrink-0 pt-0"> <Card className="relative w-72 shrink-0 pt-0">
{loading ? ( {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 <img
src={displayImage} src={displayImage}
alt={displayTitle} alt={displayTitle}
className="relative z-20 h-40 w-full object-cover rounded-t-lg" className="h-40 w-full object-cover rounded-t-lg"
onError={(e) => { onError={(e) => { e.currentTarget.style.display = 'none'; }}
e.currentTarget.src = `https://avatar.vercel.sh/${encodeURIComponent(link.url)}`;
}}
/> />
) : (
<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> <CardHeader>
<CardTitle className="line-clamp-1"> <CardTitle className="line-clamp-1">
@@ -89,9 +111,8 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
</CardHeader> </CardHeader>
<CardFooter> <CardFooter>
{visited ? ( {visited ? (
<Button className="w-full" variant="secondary" disabled> <Button variant="secondary" className="w-full" onClick={() => setViewModalOpen(true)}>
<CheckCheck className="size-4" /> <CheckCheck /> Visited
Visited
</Button> </Button>
) : ( ) : (
<Button className="w-full" onClick={() => setModalOpen(true)}> <Button className="w-full" onClick={() => setModalOpen(true)}>
@@ -101,16 +122,15 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
</CardFooter> </CardFooter>
</Card> </Card>
{/* Turn-in modal — first visit */}
<ResponsiveModal <ResponsiveModal
open={modalOpen} open={modalOpen}
onOpenChange={setModalOpen} onOpenChange={setModalOpen}
title={`Visit Link: ${displayTitle}`} 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={ footer={
<> <>
<Button variant="outline" onClick={() => setModalOpen(false)}> <Button variant="outline" onClick={() => setModalOpen(false)}>Cancel</Button>
Cancel
</Button>
<Button onClick={handleTurnIn} disabled={submitting}> <Button onClick={handleTurnIn} disabled={submitting}>
<SendHorizonal /> {submitting ? "Submitting…" : "Turn In"} <SendHorizonal /> {submitting ? "Submitting…" : "Turn In"}
</Button> </Button>
@@ -121,12 +141,52 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
<p className="text-xs text-muted-foreground break-all">{link.url}</p> <p className="text-xs text-muted-foreground break-all">{link.url}</p>
<Button asChild variant="outline" className="w-full"> <Button asChild variant="outline" className="w-full">
<a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer"> <a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-4" /> <ExternalLink className="size-4" /> Open Link
Open Link
</a> </a>
</Button> </Button>
</div> </div>
</ResponsiveModal> </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 * onVisit {function} – async (requirement_id) => void
* called when user confirms "Turn In" * called when user confirms "Turn In"
*/ */
const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit }) => { const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit, onUnvisit }) => {
const [submittingId, setSubmittingId] = useState(null); const [submittingId, setSubmittingId] = useState(null);
const [unsubmittingId, setUnsubmittingId] = useState(null);
const handleTurnIn = async (requirementId) => { const handleTurnIn = async (requirementId) => {
setSubmittingId(requirementId); setSubmittingId(requirementId);
try { try { await onVisit?.(requirementId); }
await onVisit?.(requirementId); finally { setSubmittingId(null); }
} finally { };
setSubmittingId(null);
} const handleUnvisit = async (requirementId) => {
setUnsubmittingId(requirementId);
try { await onUnvisit?.(requirementId); }
finally { setUnsubmittingId(null); }
}; };
return ( return (
@@ -167,7 +231,9 @@ const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit
link={link} link={link}
visited={!!visitedMap[link.requirement_id]} visited={!!visitedMap[link.requirement_id]}
onTurnIn={handleTurnIn} onTurnIn={handleTurnIn}
onUnvisit={handleUnvisit}
submitting={submittingId === link.requirement_id} submitting={submittingId === link.requirement_id}
unsubmitting={unsubmittingId === link.requirement_id}
/> />
))} ))}
</div> </div>
+16 -5
View File
@@ -28,7 +28,7 @@ import { useProfile } from "@/contexts/ProfileProvider"
import { useClientTiers } from "@/contexts/ClientTiersProvider" import { useClientTiers } from "@/contexts/ClientTiersProvider"
import { useGroup } from "@/contexts/ClientGroupContext" import { useGroup } from "@/contexts/ClientGroupContext"
import { useEffect, useState } from "react" 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 { Badge } from "@/components/ui/badge"
import ClientNotificationBell from "@/components/generic/ClientNotificationBell" import ClientNotificationBell from "@/components/generic/ClientNotificationBell"
import { QRCodeCanvas } from "qrcode.react" import { QRCodeCanvas } from "qrcode.react"
@@ -141,7 +141,7 @@ function ClientNav() {
// Background fetches only — nav rendering never waits on these // Background fetches only — nav rendering never waits on these
const { achievements, getAchievements } = useProfile() const { achievements, getAchievements } = useProfile()
const { myTier, getMyTier } = useClientTiers() const { myTier, getMyTier, getTierCategories } = useClientTiers()
const [referOpen, setReferOpen] = useState(false) const [referOpen, setReferOpen] = useState(false)
@@ -149,6 +149,7 @@ function ClientNav() {
if (!user) return; if (!user) return;
if (achievements.length === 0) getAchievements(); if (achievements.length === 0) getAchievements();
if (!myTier) getMyTier(); if (!myTier) getMyTier();
getTierCategories();
}, [user]); }, [user]);
// ── Derive directly from auth user — same pattern as admin UserMenu ────── // ── 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 fullName = given && last ? `${given} ${last}` : (user?.email ?? "")
const avatarUrl = user?.personal_info?.avatar?.url ?? user?.personal_info?.avatar ?? "" const avatarUrl = user?.personal_info?.avatar?.url ?? user?.personal_info?.avatar ?? ""
const email = user?.email ?? "" 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 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 const initials = given && last
? (given[0] + last[0]).toUpperCase() ? (given[0] + last[0]).toUpperCase()
: getInitials(fullName) : getInitials(fullName)
@@ -201,9 +210,11 @@ function ClientNav() {
</svg> </svg>
</div> </div>
<div id="name" className="lg:-ml-1 font-medium text-sm flex items-center gap-2"> <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"> {tierBadge && (
{role.label} <Badge className={`xs:hidden md:block ${tierBadge.className}`}>
{tierBadge.label}
</Badge> </Badge>
)}
</div> </div>
</div> </div>
+85 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; 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 { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -9,9 +9,20 @@ import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch"; 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 { useAuth } from "@/contexts/AuthContext";
import { useProfile } from "@/contexts/ProfileProvider"; import { useProfile } from "@/contexts/ProfileProvider";
import { useClientTiers } from "@/contexts/ClientTiersProvider"; import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -132,6 +143,7 @@ const TIER_COLORS = {
function SubscriptionSection() { function SubscriptionSection() {
const navigate = useNavigate(); const navigate = useNavigate();
const { myTier, tierLoading, getMyTier, payments, paymentsLoading, getMyPayments } = useClientTiers(); const { myTier, tierLoading, getMyTier, payments, paymentsLoading, getMyPayments } = useClientTiers();
const { fmtDate, fmtNumber } = useDateFormat();
useEffect(() => { useEffect(() => {
getMyTier(); getMyTier();
@@ -139,9 +151,7 @@ function SubscriptionSection() {
}, []); }, []);
const tier = myTier?.tier ?? "free"; const tier = myTier?.tier ?? "free";
const expiresAt = myTier?.expires_at const expiresAt = myTier?.expires_at ? fmtDate(myTier.expires_at) : null;
? new Date(myTier.expires_at).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })
: null;
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@@ -192,11 +202,11 @@ function SubscriptionSection() {
{payments.map((p) => ( {payments.map((p) => (
<tr key={p.payment_id} className="hover:bg-muted/30 transition-colors"> <tr key={p.payment_id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 text-muted-foreground"> <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>
<td className="px-4 py-3 capitalize">{p.plan?.tier ?? "—"}</td> <td className="px-4 py-3 capitalize">{p.plan?.tier ?? "—"}</td>
<td className="px-4 py-3"> <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>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Badge variant={p.status === "completed" ? "outline" : ""} className="capitalize text-xs"> <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 ───────────────────────────────────────────────────────────────────── // ─── Page ─────────────────────────────────────────────────────────────────────
export default function AccountSettings() { 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."> <Section icon={Mail} title="Newsletter" description="Choose what emails you want to receive from us.">
<NewsletterSection /> <NewsletterSection />
</Section> </Section>
<Section icon={Trash2} title="Danger Zone" description="Irreversible account actions.">
<DeleteAccountSection logout={logout} />
</Section>
</div> </div>
</div> </div>
); );
+7 -10
View File
@@ -18,11 +18,7 @@ import {
House, Loader2, ShieldCheck, Tag, Zap, LockIcon, House, Loader2, ShieldCheck, Tag, Zap, LockIcon,
} from "lucide-react"; } from "lucide-react";
function formatPrice(price = 0, currency = "USD") { import { useDateFormat } from "@/hooks/useDateFormat";
return new Intl.NumberFormat("en-US", {
style: "currency", currency, minimumFractionDigits: 2,
}).format(Number(price) || 0);
}
function formatDuration(days) { function formatDuration(days) {
if (!days) return "Lifetime"; if (!days) return "Lifetime";
@@ -66,6 +62,7 @@ const CheckoutSkeleton = () => (
const Checkout = () => { const Checkout = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { fmtCurrency } = useDateFormat();
const planId = searchParams.get("plan_id"); const planId = searchParams.get("plan_id");
const returnToken = searchParams.get("token"); const returnToken = searchParams.get("token");
@@ -237,7 +234,7 @@ const Checkout = () => {
</p> </p>
</div> </div>
<p className="text-2xl font-bold text-primary"> <p className="text-2xl font-bold text-primary">
{formatPrice(plan.price, plan.currency)} {fmtCurrency(plan.price, plan.currency)}
</p> </p>
</div> </div>
</div> </div>
@@ -312,12 +309,12 @@ const Checkout = () => {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between gap-4"> <div className="flex justify-between gap-4">
<span className="text-muted-foreground">Plan Price</span> <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> </div>
{isPromoApplied && ( {isPromoApplied && (
<div className="flex justify-between gap-4 text-green-600"> <div className="flex justify-between gap-4 text-green-600">
<span>Promo Discount (PHIL10)</span> <span>Promo Discount (PHIL10)</span>
<span>-{formatPrice(discount, plan.currency)}</span> <span>-{fmtCurrency(discount, plan.currency)}</span>
</div> </div>
)} )}
</div> </div>
@@ -348,7 +345,7 @@ const Checkout = () => {
<div className="flex justify-between items-center text-lg font-semibold"> <div className="flex justify-between items-center text-lg font-semibold">
<span>Total</span> <span>Total</span>
<span>{formatPrice(total, plan.currency)}</span> <span>{fmtCurrency(total, plan.currency)}</span>
</div> </div>
{isCurrent ? ( {isCurrent ? (
@@ -366,7 +363,7 @@ const Checkout = () => {
? <Loader2 className="size-4 animate-spin" /> ? <Loader2 className="size-4 animate-spin" />
: <ShieldCheck className="size-4" /> : <ShieldCheck className="size-4" />
} }
Pay {formatPrice(total, plan.currency)} with PayPal Pay {fmtCurrency(total, plan.currency)} with PayPal
</Button> </Button>
)} )}
+5 -9
View File
@@ -9,12 +9,7 @@ import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useClientCourses } from "@/contexts/ClientCoursesContext"; import { useClientCourses } from "@/contexts/ClientCoursesContext";
import { useDateFormat } from "@/hooks/useDateFormat";
function formatPrice(price = 0, currency = "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency", currency, minimumFractionDigits: 2,
}).format(Number(price) || 0);
}
function formatAccess(days) { function formatAccess(days) {
if (!days) return "Lifetime access"; if (!days) return "Lifetime access";
@@ -38,6 +33,7 @@ const PageSkeleton = () => (
export default function CourseCheckout() { export default function CourseCheckout() {
const navigate = useNavigate(); const navigate = useNavigate();
const { id: courseId } = useParams(); const { id: courseId } = useParams();
const { fmtCurrency } = useDateFormat();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const returnToken = searchParams.get("token"); const returnToken = searchParams.get("token");
@@ -181,14 +177,14 @@ export default function CourseCheckout() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-muted-foreground">Course Price</span> <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> </div>
<Separator /> <Separator />
<div className="flex justify-between items-center text-lg font-semibold"> <div className="flex justify-between items-center text-lg font-semibold">
<span>Total</span> <span>Total</span>
<span>{formatPrice(product.price, product.currency)}</span> <span>{fmtCurrency(product.price, product.currency)}</span>
</div> </div>
{course?.has_purchased ? ( {course?.has_purchased ? (
@@ -206,7 +202,7 @@ export default function CourseCheckout() {
? <Loader2 className="size-4 animate-spin" /> ? <Loader2 className="size-4 animate-spin" />
: <ShieldCheck className="size-4" /> : <ShieldCheck className="size-4" />
} }
Pay {formatPrice(product.price, product.currency)} with PayPal Pay {fmtCurrency(product.price, product.currency)} with PayPal
</Button> </Button>
)} )}
+20 -15
View File
@@ -1,5 +1,7 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import api from "@/utils/api.util";
import { import {
House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon, House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon,
SendHorizonal, CheckCheck, CheckCircle2, Clock, SendHorizonal, CheckCheck, CheckCircle2, Clock,
@@ -21,6 +23,7 @@ import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgressContext"; import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgressContext";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import { toast } from "sonner"; import { toast } from "sonner";
import { resolveTierBadge } from "@/utils/tierBadge.util";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -155,11 +158,8 @@ const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle,
// ─── Certificate Card ───────────────────────────────────────────────────────── // ─── 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 CertCard = ({ courseTitle, courseLevel, pendingCert, certificate, delay, nodeRef }) => {
const { fmtDate } = useDateFormat();
const isIssued = !!certificate; const isIssued = !!certificate;
const isPending = !isIssued && !!pendingCert; const isPending = !isIssued && !!pendingCert;
@@ -382,6 +382,17 @@ const CourseDetails = () => {
const { myTier, getMyTier } = useClientTiers(); const { myTier, getMyTier } = useClientTiers();
const { fetchCourseProgress, isCompleted, resetProgress } = useCourseReadingProgress(); 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; const hasCompleted = !!course?.is_completed;
useEffect(() => { useEffect(() => {
@@ -458,17 +469,11 @@ const CourseDetails = () => {
<div className="flex lg:flex-row items-start justify-between w-full"> <div className="flex lg:flex-row items-start justify-between w-full">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{course?.plan_tier && course.plan_tier !== "free" ? ( {(() => {
<Badge className={ const slug = course?.plan_tier ?? course?.subscription ?? "free";
course.plan_tier === "premium" const { label, cls } = resolveTierBadge(slug, tierMap);
? "bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white" return <Badge className={cls}>{label}</Badge>;
: "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>
)}
</div> </div>
<h1 className="font-bold text-4xl">{course?.title ?? "Course Title"}</h1> <h1 className="font-bold text-4xl">{course?.title ?? "Course Title"}</h1>
<p className="max-w-2xl lg:text-lg">{course?.description ?? ""}</p> <p className="max-w-2xl lg:text-lg">{course?.description ?? ""}</p>
+59 -98
View File
@@ -11,10 +11,12 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import ResponsiveModal from "@/components/generic/ResponsiveModal"; import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { useClientCourses } from "@/contexts/ClientCoursesContext"; import { useClientCourses } from "@/contexts/ClientCoursesContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Fragment } from "react"; import { Fragment } from "react";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util";
import { resolveTierBadge } from "@/utils/tierBadge.util";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -29,21 +31,13 @@ function formatDuration(seconds = 0) {
const ITEMS_PER_PAGE = 10; 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 ────────────────────────────────────────────────────────────── // ─── Course Card ──────────────────────────────────────────────────────────────
const CourseCard = ({ course, onViewDetails }) => { const CourseCard = ({ course, tierMap, onViewDetails }) => {
const type = course.plan_tier ?? "free"; const slug = course.subscription ?? "free";
const locked = course.is_locked; const locked = course.is_locked;
const duration = formatDuration(course.duration_seconds); const duration = formatDuration(course.duration_seconds);
const { rank, label, cls } = resolveTierBadge(slug, tierMap);
return ( return (
<div <div
@@ -57,31 +51,10 @@ const CourseCard = ({ course, onViewDetails }) => {
onClick={() => onViewDetails(course)} onClick={() => onViewDetails(course)}
> >
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{type === "free" && ( <Badge className={cls}>
<Badge className="bg-green-500 text-white"> {locked ? <LockIcon className="size-3" /> : <Tag className="size-3" />}
<Tag /> Free {label}
</Badge> </Badge>
)}
{type === "premium" && !locked && (
<Badge className="bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white">
<Tag /> Premium
</Badge>
)}
{type === "premium" && locked && (
<Badge className="bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white">
<LockIcon /> Premium
</Badge>
)}
{type === "exclusive" && !locked && (
<Badge className="bg-gradient-to-r from-rose-500 to-red-600 text-white">
<LockIcon /> Exclusive
</Badge>
)}
{type === "exclusive" && locked && (
<Badge className="bg-gradient-to-r from-rose-500 to-red-600 text-white">
<LockIcon /> Exclusive
</Badge>
)}
{course.level && ( {course.level && (
<Badge variant="outline">{course.level.charAt(0).toUpperCase() + course.level.slice(1)}</Badge> <Badge variant="outline">{course.level.charAt(0).toUpperCase() + course.level.slice(1)}</Badge>
)} )}
@@ -167,12 +140,7 @@ const Pagination = ({ currentPage, totalPages, totalItems, itemsPerPage, onPageC
page === "..." ? ( page === "..." ? (
<span key={`ellipsis-${i}`} className="w-7 h-7 flex items-center justify-center text-xs text-muted-foreground">···</span> <span key={`ellipsis-${i}`} className="w-7 h-7 flex items-center justify-center text-xs text-muted-foreground">···</span>
) : ( ) : (
<Button <Button key={page} size="sm" variant={currentPage === page ? "default" : "outline"} onClick={() => onPageChange(page)}>
key={page}
size="sm"
variant={currentPage === page ? "default" : "outline"}
onClick={() => onPageChange(page)}
>
{page} {page}
</Button> </Button>
) )
@@ -190,8 +158,9 @@ const Pagination = ({ currentPage, totalPages, totalItems, itemsPerPage, onPageC
const CoursesList = () => { const CoursesList = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { courses, coursesLoading, getCourses } = useClientCourses(); const { courses, coursesLoading, getCourses } = useClientCourses();
const { myTier, getMyTier } = useClientTiers(); const { fmtCurrency } = useDateFormat();
const [tierCategories, setTierCategories] = useState([]);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [levelFilter, setLevelFilter] = useState("All"); const [levelFilter, setLevelFilter] = useState("All");
@@ -200,29 +169,34 @@ const CoursesList = () => {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [selectedCourse, setSelectedCourse] = useState(null); const [selectedCourse, setSelectedCourse] = useState(null);
// Collect unique categories from loaded courses useEffect(() => {
getCourses();
api.get("/client/tiers/categories")
.then(({ data }) => setTierCategories(data.data ?? []))
.catch(() => {});
}, []);
// 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 allCategories = useMemo(() => {
const map = new Map(); const map = new Map();
courses.forEach((c) => (c.categories ?? []).forEach((cat) => map.set(cat.id, cat))); courses.forEach((c) => (c.categories ?? []).forEach((cat) => map.set(cat.id, cat)));
return [...map.values()]; return [...map.values()];
}, [courses]); }, [courses]);
const userTier = myTier?.tier ?? "free";
useEffect(() => {
getCourses();
if (!myTier) getMyTier();
}, []);
// ── Filter + sort ─────────────────────────────────────────────────────────
const filtered = useMemo(() => const filtered = useMemo(() =>
courses courses
.filter((c) => { .filter((c) => {
const matchSearch = c.title.toLowerCase().includes(search.toLowerCase()) || const matchSearch = c.title.toLowerCase().includes(search.toLowerCase()) ||
(c.description ?? "").toLowerCase().includes(search.toLowerCase()); (c.description ?? "").toLowerCase().includes(search.toLowerCase());
const matchLevel = levelFilter === "All" || (c.level ?? "").toLowerCase() === levelFilter.toLowerCase(); const matchLevel = levelFilter === "All" || (c.level ?? "").toLowerCase() === levelFilter.toLowerCase();
const matchSub = subFilter === "All" || c.subscription === subFilter.toLowerCase(); const matchSub = subFilter === "All" || c.subscription === subFilter;
const matchCategory = categoryFilter === "All" || (c.categories ?? []).some((cat) => String(cat.id) === categoryFilter); const matchCategory = categoryFilter === "All" || (c.categories ?? []).some((cat) => String(cat.id) === categoryFilter);
return matchSearch && matchLevel && matchSub && matchCategory; return matchSearch && matchLevel && matchSub && matchCategory;
}) })
@@ -231,17 +205,10 @@ const CoursesList = () => {
); );
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE)); const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
const paginated = filtered.slice( const paginated = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
(currentPage - 1) * ITEMS_PER_PAGE,
currentPage * ITEMS_PER_PAGE
);
// ── Card click ────────────────────────────────────────────────────────────
const handleViewDetails = (course) => { const handleViewDetails = (course) => {
// Re-check access using live tier — ignore is_locked from backend if tier has changed if (course.is_locked) {
const accessible = canAccess(userTier, course.plan_tier);
if (!accessible) {
setSelectedCourse(course); setSelectedCourse(course);
setModalOpen(true); setModalOpen(true);
} else { } else {
@@ -254,6 +221,9 @@ const CoursesList = () => {
{ label: "Courses" }, { label: "Courses" },
]; ];
// Upsell modal tier panel
const upsellTier = selectedCourse ? tierMap[selectedCourse.subscription] : null;
return ( return (
<div> <div>
<PageMeta title="Courses - STARR" description="Browse your available training courses." /> <PageMeta title="Courses - STARR" description="Browse your available training courses." />
@@ -282,21 +252,25 @@ const CoursesList = () => {
<SelectItem value="Advanced">Advanced</SelectItem> <SelectItem value="Advanced">Advanced</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={subFilter} onValueChange={(v) => { setSubFilter(v); setCurrentPage(1); }}> <Select value={subFilter} onValueChange={(v) => { setSubFilter(v); setCurrentPage(1); }}>
<SelectTrigger className="w-full lg:w-48 bg-card"> <SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Subscription" /> <SelectValue placeholder="Subscription" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="All">All</SelectItem> <SelectItem value="All">All</SelectItem>
<SelectItem value="Free">Free</SelectItem> {tierCategories.map((cat) => (
<SelectItem value="Premium">Premium</SelectItem> <SelectItem key={cat.slug} value={cat.slug}>
{cat.name}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
</div> </div>
{/* Category chips */} {/* Product category chips */}
{allCategories.length > 0 && ( {allCategories.length > 0 && (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<button <button
@@ -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"> <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) => ( {paginated.map((course) => (
<CourseCard key={course.course_id} course={course} onViewDetails={handleViewDetails} /> <CourseCard
key={course.course_id}
course={course}
tierMap={tierMap}
onViewDetails={handleViewDetails}
/>
))} ))}
</div> </div>
)} )}
@@ -349,7 +328,7 @@ const CoursesList = () => {
</div> </div>
</div> </div>
{/* Upsell Modal — only for locked courses */} {/* Upsell Modal */}
<ResponsiveModal <ResponsiveModal
open={modalOpen} open={modalOpen}
onOpenChange={setModalOpen} onOpenChange={setModalOpen}
@@ -364,7 +343,7 @@ const CoursesList = () => {
onClick={() => { setModalOpen(false); navigate(`/course/${selectedCourse.course_id}/checkout`); }} onClick={() => { setModalOpen(false); navigate(`/course/${selectedCourse.course_id}/checkout`); }}
> >
<ShoppingCart className="size-4" /> <ShoppingCart className="size-4" />
Buy {new Intl.NumberFormat("en-US", { style: "currency", currency: selectedCourse.product.currency ?? "USD" }).format(selectedCourse.product.price ?? 0)} Buy {fmtCurrency(selectedCourse.product.price ?? 0, selectedCourse.product.currency ?? "USD")}
</Button> </Button>
)} )}
<Button onClick={() => { setModalOpen(false); navigate("/plans"); }}> <Button onClick={() => { setModalOpen(false); navigate("/plans"); }}>
@@ -374,43 +353,25 @@ const CoursesList = () => {
} }
> >
<div className="space-y-6 py-2"> <div className="space-y-6 py-2">
{(selectedCourse?.plan_tier ?? "free") === "premium" && ( {upsellTier && !upsellTier.is_default && (() => {
<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"> 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"> <div className="flex items-center gap-3 mb-3">
<Badge className="bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white"> <Badge className={cls}>
<Tag className="size-4" /> Premium <LockIcon className="size-3" /> {upsellTier.name}
</Badge> </Badge>
</div> </div>
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4"> <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 /> Access to {upsellTier.name} content</li>
<li className="flex items-center gap-2"><Check /> Downloadable resources</li> <li className="flex items-center gap-2"><Check /> Certificates & achievements</li>
<li className="flex items-center gap-2"><Check /> Certificate of completion</li>
</ul> </ul>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Upgrade to a Premium plan to unlock this course and all other premium content. Upgrade to a <span className="font-medium">{upsellTier.name}</span> plan to unlock this course.
</p> </p>
</div> </div>
)} );
{(selectedCourse?.plan_tier ?? "free") === "exclusive" && ( })()}
<div className="p-5 bg-gradient-to-r from-rose-50 to-red-50 dark:from-rose-950/30 dark:to-red-950/30 rounded-2xl border border-rose-200 dark:border-rose-800">
<div className="flex items-center gap-3 mb-3">
<Badge className="bg-gradient-to-r from-rose-500 to-red-600 text-white">
<LockIcon className="size-4" /> Exclusive
</Badge>
</div>
<div className="bg-card border rounded-xl p-4 mb-4">
<p className="text-sm font-medium flex items-center gap-2 text-rose-600">
<LockIcon className="size-4" /> This is an exclusive course
</p>
<p className="text-xs text-muted-foreground mt-1">
Only available to members with exclusive access
</p>
</div>
<p className="text-sm text-muted-foreground">
Exclusive courses are granted through special programs, partnerships, or limited enrollment offerings.
</p>
</div>
)}
</div> </div>
</ResponsiveModal> </ResponsiveModal>
</div> </div>
+119 -138
View File
@@ -1,21 +1,25 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
TableOfContents, Users, Timer, Users, Timer,
Tag, LockIcon, Check, Tag, LockIcon, Check,
} from "lucide-react"; } from "lucide-react";
import { ThemeSwitcher } from "../components/ThemeSwitcher"; import { ThemeSwitcher } from "../components/ThemeSwitcher";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; 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 { useNavigate, useLocation } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import ResponsiveModal from "@/components/generic/ResponsiveModal"; import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { useClientCourses } from "@/contexts/ClientCoursesContext"; import { useClientCourses } from "@/contexts/ClientCoursesContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider"; import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useGroup } from "@/contexts/ClientGroupContext"; import { useGroup } from "@/contexts/ClientGroupContext";
import { useTask } from "@/contexts/ClientTaskContext";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext"; import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Hero, HeroSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Hero"; import { Hero, HeroSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Hero";
import { Popup } from "@/components/generic/Blocks/Client/Advertisements/Popup"; import { Popup } from "@/components/generic/Blocks/Client/Advertisements/Popup";
@@ -32,20 +36,21 @@ function formatDuration(seconds = 0) {
return `${m}m`; return `${m}m`;
} }
// Mirrors the same access logic in CoursesList.jsx // Rank-based access: user's rank must be >= course's required rank.
function canAccess(userTier, planTier) { function canAccess(userTier, planTier, tierMap) {
if (!planTier || planTier === "free") return true; const courseRank = tierMap[planTier]?.rank ?? (planTier && planTier !== "free" ? Infinity : 0);
if (planTier === "premium") return userTier === "premium" || userTier === "exclusive"; const userRank = tierMap[userTier]?.rank ?? 0;
if (planTier === "exclusive") return userTier === "exclusive"; return userRank >= courseRank;
return false;
} }
// ── Course Card ────────────────────────────────────────────────────────────── // ── Course Card ──────────────────────────────────────────────────────────────
const CourseCard = ({ course, onViewDetails }) => { const CourseCard = ({ course, onViewDetails }) => {
const type = course.plan_tier ?? "free"; const { tierMap } = useClientTiers();
const slug = course.subscription ?? "free";
const locked = course.is_locked; const locked = course.is_locked;
const duration = formatDuration(course.duration_seconds); const duration = formatDuration(course.duration_seconds);
const { rank, label, cls } = resolveTierBadge(slug, tierMap);
return ( return (
<div <div
@@ -59,21 +64,10 @@ const CourseCard = ({ course, onViewDetails }) => {
onClick={() => onViewDetails(course)} onClick={() => onViewDetails(course)}
> >
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{type === "free" && ( <Badge className={cls}>
<Badge className="bg-green-500 text-white"> {rank > 0 || locked ? <LockIcon className="size-3" /> : <Tag className="size-3" />}
<Tag /> Free {label}
</Badge> </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>
)}
{course.level && ( {course.level && (
<Badge variant="outline"> <Badge variant="outline">
{course.level.charAt(0).toUpperCase() + course.level.slice(1)} {course.level.charAt(0).toUpperCase() + course.level.slice(1)}
@@ -127,20 +121,88 @@ const CourseCardSkeleton = () => (
</div> </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 ───────────────────────────────────────────────────────── // ─── Client Dashboard ─────────────────────────────────────────────────────────
const Client = () => { const Client = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { state: navState } = useLocation(); const { state: navState } = useLocation();
const { courses, coursesLoading, getCourses } = useClientCourses(); const { courses, coursesLoading, getCourses } = useClientCourses();
const { myTier, getMyTier } = useClientTiers(); const { myTier, getMyTier, tierMap } = useClientTiers();
const { groups, fetchGroups, loading: groupLoading } = useGroup(); const { groups, fetchGroups, loading: groupLoading } = useGroup();
const { fetchTaskLists } = useTask();
const { advertisements, loading: adLoading, getActiveAdvertisement, trackClick } = useClientAdvertisements(); const { advertisements, loading: adLoading, getActiveAdvertisement, trackClick } = useClientAdvertisements();
const [completedCount, setCompletedCount] = useState(0);
const [dueSoonCount, setDueSoonCount] = useState(0);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [selectedCourse, setSelectedCourse] = useState(null); const [selectedCourse, setSelectedCourse] = useState(null);
@@ -192,16 +254,14 @@ const Client = () => {
// Show only first 3 // Show only first 3
const featuredCourses = courses.slice(0, 3); const featuredCourses = courses.slice(0, 3);
const myGroup = groups?.[0] ?? null;
const breadcrumbItems = [ const breadcrumbItems = [
{ label: "My Group", icon: <Users className="size-4" />, to: `` }, { label: "My Groups", icon: <Users className="size-4" /> },
{ label: "Statistics" },
]; ];
// ── Card click — mirrors CoursesList.jsx logic ──────────────────────────── // ── Card click — mirrors CoursesList.jsx logic ────────────────────────────
const handleViewDetails = (course) => { const handleViewDetails = (course) => {
const accessible = canAccess(userTier, course.plan_tier); const accessible = canAccess(userTier, course.subscription, tierMap);
if (!accessible) { if (!accessible) {
setSelectedCourse(course); setSelectedCourse(course);
setModalOpen(true); 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 ( return (
<div> <div>
<div className="my-20"> <div className="my-20">
@@ -260,53 +283,27 @@ const Client = () => {
<Hero ad={heroAd} onCtaClick={handleAdCtaClick} /> <Hero ad={heroAd} onCtaClick={handleAdCtaClick} />
)} )}
{/* ── Group affiliated ── */} {/* ── My Groups ── */}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<AppBreadcrumb items={breadcrumbItems} /> <AppBreadcrumb items={breadcrumbItems} />
<div className="flex flex-col gap-4"> {!groupLoading && groups.length > 0 && (
<div className="w-full flex items-center justify-between"> <span className="text-sm text-muted-foreground">
{groupLoading ? ( {groups.length} group{groups.length !== 1 ? 's' : ''}
<Skeleton className="h-7 w-40" /> </span>
) : (
<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>
<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 ? ( {groupLoading ? (
<Skeleton className="h-8 w-10" /> <GroupsTableSkeleton />
) : groups.length === 0 ? (
<p className="text-sm text-muted-foreground">You are not assigned to any group.</p>
) : ( ) : (
<h1 className="font-medium text-2xl">{dueSoonCount}</h1> <GroupsTable
groups={groups}
onView={(g) => navigate(`/group/${g.group_id}`)}
/>
)} )}
</div> </div>
</div>
</div>
</div>
{/* ── Featured Courses (first 3) ── */} {/* ── Featured Courses (first 3) ── */}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -363,43 +360,27 @@ const Client = () => {
} }
> >
<div className="space-y-6 py-2"> <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"> 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"> <div className="flex items-center gap-3 mb-3">
<Badge className="bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white"> <Badge className={cls}>
<Tag className="size-4" /> Premium <LockIcon className="size-3" /> {label}
</Badge> </Badge>
</div> </div>
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4"> <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 /> Access to {label} content</li>
<li className="flex items-center gap-2"><Check /> Downloadable resources</li> <li className="flex items-center gap-2"><Check /> Certificates & achievements</li>
<li className="flex items-center gap-2"><Check /> Certificate of completion</li>
</ul> </ul>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Upgrade to a Premium plan to unlock this course and all other premium content. Upgrade to a <span className="font-medium">{label}</span> plan to unlock this course.
</p> </p>
</div> </div>
)} );
{(selectedCourse?.plan_tier ?? "free") === "exclusive" && ( })()}
<div className="p-5 bg-gradient-to-r from-rose-50 to-red-50 dark:from-rose-950/30 dark:to-red-950/30 rounded-2xl border border-rose-200 dark:border-rose-800">
<div className="flex items-center gap-3 mb-3">
<Badge className="bg-gradient-to-r from-rose-500 to-red-600 text-white">
<LockIcon className="size-4" /> Exclusive
</Badge>
</div>
<div className="bg-card border rounded-xl p-4 mb-4">
<p className="text-sm font-medium flex items-center gap-2 text-rose-600">
<LockIcon className="size-4" /> This is an exclusive course
</p>
<p className="text-xs text-muted-foreground mt-1">
Only available to members with exclusive access
</p>
</div>
<p className="text-sm text-muted-foreground">
Exclusive courses are granted through special programs, partnerships, or limited enrollment offerings.
</p>
</div>
)}
</div> </div>
</ResponsiveModal> </ResponsiveModal>
</div> </div>
+3 -3
View File
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useProfile } from "@/contexts/ProfileProvider"; import { useProfile } from "@/contexts/ProfileProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
const ACHIEVEMENT_ICONS = { const ACHIEVEMENT_ICONS = {
early_access: Star, early_access: Star,
@@ -26,6 +27,7 @@ const getFallbackIcon = (type) => type === "badge" ? BadgeCheck : Trophy;
export default function MyAchievements() { export default function MyAchievements() {
const navigate = useNavigate(); const navigate = useNavigate();
const { achievements, achievementsLoading, getAchievements } = useProfile(); const { achievements, achievementsLoading, getAchievements } = useProfile();
const { fmtDate } = useDateFormat();
useEffect(() => { getAchievements(); }, []); useEffect(() => { getAchievements(); }, []);
@@ -81,9 +83,7 @@ export default function MyAchievements() {
<p className="text-xs text-muted-foreground mt-0.5">{item.description}</p> <p className="text-xs text-muted-foreground mt-0.5">{item.description}</p>
{item.granted_at && ( {item.granted_at && (
<p className="text-xs text-muted-foreground/70 mt-1"> <p className="text-xs text-muted-foreground/70 mt-1">
{new Date(item.granted_at).toLocaleDateString("en-US", { {fmtDate(item.granted_at)}
month: "long", day: "numeric", year: "numeric",
})}
</p> </p>
)} )}
</div> </div>
+3 -3
View File
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useProfile } from "@/contexts/ProfileProvider"; import { useProfile } from "@/contexts/ProfileProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -25,10 +26,9 @@ const CertBadgeIcon = ({ className }) => (
const CertificateCard = ({ courseTitle, issuedAt, courseUuid }) => { const CertificateCard = ({ courseTitle, issuedAt, courseUuid }) => {
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const { fmtDate } = useDateFormat();
const issuedLabel = issuedAt const issuedLabel = issuedAt ? fmtDate(issuedAt) : "—";
? new Date(issuedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
: "—";
const handleDownload = async () => { const handleDownload = async () => {
setDownloading(true); setDownloading(true);
+63 -29
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
Card, CardContent, CardDescription, Card, CardContent, CardDescription,
@@ -17,15 +17,16 @@ import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { toast } from "sonner"; import { toast } from "sonner";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import { useDateFormat } from "@/hooks/useDateFormat";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function formatPrice(price, currency = "USD") { const REFUND_WINDOW_SECS = 5 * 60;
return new Intl.NumberFormat("en-US", {
style: "currency", function formatCountdown(secs) {
currency: currency, const m = Math.floor(secs / 60);
minimumFractionDigits: 2, const s = secs % 60;
}).format(price); return `${m}:${String(s).padStart(2, "0")}`;
} }
function formatDuration(days) { function formatDuration(days) {
@@ -93,7 +94,8 @@ const PlanSkeleton = () => (
// ─── Plan Card ──────────────────────────────────────────────────────────────── // ─── 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 style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
const Icon = style.icon; const Icon = style.icon;
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active"; const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
@@ -116,7 +118,7 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund }) => {
</div> </div>
<CardDescription> <CardDescription>
<span className="text-3xl font-bold text-foreground"> <span className="text-3xl font-bold text-foreground">
{formatPrice(plan.price, plan.currency)} {fmtCurrency(plan.price, plan.currency)}
</span> </span>
{duration && ( {duration && (
<span className="text-sm text-muted-foreground ml-1">/ {duration}</span> <span className="text-sm text-muted-foreground ml-1">/ {duration}</span>
@@ -170,15 +172,16 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund }) => {
> >
View Details View Details
</Button> </Button>
{isCurrent ? ( {isCurrent && refundSecsLeft > 0 ? (
<Button <Button
className="flex-1" className="flex-1"
variant="destructive" variant="destructive"
onClick={() => onRefund(plan)} onClick={() => onRefund(plan)}
> >
<RotateCcw className="size-4" /> Refund <RotateCcw className="size-4" />
Refund ({formatCountdown(refundSecsLeft)})
</Button> </Button>
) : ( ) : !isCurrent ? (
<Button <Button
className="flex-1" className="flex-1"
variant={style.button} variant={style.button}
@@ -186,7 +189,7 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund }) => {
> >
{plan.tier === "free" ? "Current" : `Get ${style.label}`} {plan.tier === "free" ? "Current" : `Get ${style.label}`}
</Button> </Button>
)} ) : null}
</CardFooter> </CardFooter>
</Card> </Card>
); );
@@ -197,15 +200,34 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund }) => {
export default function PlanList() { export default function PlanList() {
const navigate = useNavigate(); const navigate = useNavigate();
const { plans, plansLoading, myTier, tierLoading, getPlans, getMyTier, resetMyTier } = useClientTiers(); const { plans, plansLoading, myTier, tierLoading, getPlans, getMyTier, resetMyTier } = useClientTiers();
const { fmtCurrency, fmtDate } = useDateFormat();
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
const [refundLoading, setRefundLoading] = useState(false); const [refundLoading, setRefundLoading] = useState(false);
const [refundSecsLeft, setRefundSecsLeft] = useState(0);
const refundTimerRef = useRef(null);
useEffect(() => { useEffect(() => {
getPlans(); getPlans();
getMyTier(); getMyTier();
}, [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 handleViewPlan = (plan) => navigate(`/plans/view/${plan.plan_id}`);
const handleSelectPlan = (plan) => navigate(`/plans/checkout?plan_id=${plan.plan_id}`); const handleSelectPlan = (plan) => navigate(`/plans/checkout?plan_id=${plan.plan_id}`);
@@ -216,7 +238,7 @@ export default function PlanList() {
setRefundLoading(true); setRefundLoading(true);
try { try {
const { data } = await api.post("/client/tiers/checkout/refund"); 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); setRefundPlan(null);
resetMyTier(); resetMyTier();
getMyTier(); getMyTier();
@@ -234,7 +256,7 @@ export default function PlanList() {
<div className="lg:container lg:mx-auto space-y-8 p-6"> <div className="lg:container lg:mx-auto space-y-8 p-6">
{/* Advertisement Banner */} {/* 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"> <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"> <div className="space-y-2">
<Badge> <Badge>
@@ -251,10 +273,10 @@ export default function PlanList() {
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card> */}
{/* Section Header */} {/* Section Header */}
<div className="text-center"> <div className="text-center mt-6">
<h2 className="text-3xl font-bold">Available Plans</h2> <h2 className="text-3xl font-bold">Available Plans</h2>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
Choose a subscription that matches your goals. Choose a subscription that matches your goals.
@@ -279,6 +301,7 @@ export default function PlanList() {
onSelect={handleSelectPlan} onSelect={handleSelectPlan}
onView={handleViewPlan} onView={handleViewPlan}
onRefund={handleRefundClick} onRefund={handleRefundClick}
refundSecsLeft={refundSecsLeft}
/> />
)) ))
)} )}
@@ -305,7 +328,7 @@ export default function PlanList() {
<Button <Button
variant="destructive" variant="destructive"
onClick={handleConfirmRefund} onClick={handleConfirmRefund}
disabled={refundLoading} disabled={refundLoading || refundSecsLeft === 0}
> >
<RotateCcw className="size-4" /> <RotateCcw className="size-4" />
{refundLoading ? "Processing..." : "Confirm Refund"} {refundLoading ? "Processing..." : "Confirm Refund"}
@@ -322,30 +345,41 @@ export default function PlanList() {
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Refund amount</span> <span className="text-muted-foreground">Refund amount</span>
<span className="font-medium"> <span className="font-medium">
{refundPlan ? formatPrice(refundPlan.price, refundPlan.currency) : "—"} {refundPlan ? fmtCurrency(refundPlan.price, refundPlan.currency) : "—"}
</span> </span>
</div> </div>
{myTier?.expires_at && ( {myTier?.expires_at && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Access until</span> <span className="text-muted-foreground">Access until</span>
<span className="font-medium"> <span className="font-medium">
{new Date(myTier.expires_at).toLocaleDateString("en-US", { {fmtDate(myTier.expires_at)}
month: "long", day: "numeric", year: "numeric",
})}
</span> </span>
</div> </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>
</div>
{refundSecsLeft > 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Your refund will be processed through PayPal. You will retain access to your current plan until{" "} Your refund will be processed through PayPal.{" "}
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{myTier?.expires_at Access will be revoked immediately
? new Date(myTier.expires_at).toLocaleDateString("en-US", { </span>{" "}
month: "long", day: "numeric", year: "numeric", and your account will be downgraded to Free.
})
: "the end of the billing period"}
</span>.
</p> </p>
) : (
<p className="text-sm text-destructive">
The 5-minute refund window has expired. Refunds are no longer available for this payment.
</p>
)}
</div> </div>
</ResponsiveModal> </ResponsiveModal>
</div> </div>
+76 -48
View File
@@ -3,6 +3,8 @@ import { useNavigate } from "react-router-dom";
import { import {
Edit, BookOpen, Award, Trophy, Shield, Star, Zap, Target, BadgeCheck, Medal, Flame, LockIcon, Camera, ChevronRight, Download, RefreshCcw Edit, BookOpen, Award, Trophy, Shield, Star, Zap, Target, BadgeCheck, Medal, Flame, LockIcon, Camera, ChevronRight, Download, RefreshCcw
} from "lucide-react"; } from "lucide-react";
import * as LucideIcons from "lucide-react";
import { getTierColor } from "@/utils/tierColors";
import AvatarUploadDialog from "@/components/generic/AvatarUploadDialog"; import AvatarUploadDialog from "@/components/generic/AvatarUploadDialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -16,12 +18,13 @@ import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { useProfile } from "@/contexts/ProfileProvider"; import { useProfile } from "@/contexts/ProfileProvider";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider"; import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { toast } from "sonner"; 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: { free: {
src: "/badges/free-access-badge-leaf-flaticon.svg", src: "/badges/free-access-badge-leaf-flaticon.svg",
label: "Free", 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", src: "/badges/early-access-badge-percent-flaticon.svg",
label: "Early Access", label: "Early Access",
description: "Registered during the Philproperties beta period.", description: "Registered during the Philproperties beta period.",
information: "Exclusive to members who registered before Dec 31, 2026.", 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) ─────────────────────────────────────────── // ─── Achievement icon map (by key) ───────────────────────────────────────────
const ACHIEVEMENT_ICONS = { const ACHIEVEMENT_ICONS = {
@@ -84,10 +130,9 @@ const CertBadgeIcon = ({ className }) => (
const LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid }) => { const LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid }) => {
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const { fmtDate } = useDateFormat();
const issuedLabel = issuedAt const issuedLabel = issuedAt ? fmtDate(issuedAt) : "—";
? new Date(issuedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
: "—";
const handleDownload = async () => { const handleDownload = async () => {
setDownloading(true); setDownloading(true);
@@ -135,6 +180,7 @@ const LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid }) => {
const ProfilePage = () => { const ProfilePage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const { fmtDate } = useDateFormat();
const { const {
profile, profileLoading, getProfile, profile, profileLoading, getProfile,
@@ -145,7 +191,7 @@ const ProfilePage = () => {
const [avatarDialogOpen, setAvatarDialogOpen] = useState(false); const [avatarDialogOpen, setAvatarDialogOpen] = useState(false);
const { myTier, tierLoading, getMyTier } = useClientTiers(); const { myTier, tierLoading, getMyTier, systemBadges, getSystemBadges } = useClientTiers();
const [badgeOpen, setBadgeOpen] = useState(false); const [badgeOpen, setBadgeOpen] = useState(false);
const [selectedBadge, setSelectedBadge] = useState(null); const [selectedBadge, setSelectedBadge] = useState(null);
@@ -159,6 +205,7 @@ const ProfilePage = () => {
getProfile(); getProfile();
getAchievements(); getAchievements();
getMyTier(); getMyTier();
getSystemBadges();
(async () => { (async () => {
setInProgressCoursesLoading(true); setInProgressCoursesLoading(true);
try { try {
@@ -175,7 +222,8 @@ const ProfilePage = () => {
// ── Derived ──────────────────────────────────────────────────────────────── // ── Derived ────────────────────────────────────────────────────────────────
const tier = myTier?.tier ?? user?.tier ?? "free"; 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 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(); 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 // Build badge object with earnedAt from achievements for modal
const getBadgeWithDate = (achievementKey, tierKey) => { const getBadgeWithDate = (achievementKey, tierKey) => {
const achievement = achievements.find((a) => a.key === achievementKey); const achievement = achievements.find((a) => a.key === achievementKey);
const earnedAt = achievement?.granted_at const earnedAt = achievement?.granted_at ? fmtDate(achievement.granted_at) : null;
? new Date(achievement.granted_at).toLocaleDateString("en-US", { return { ...TIER_BADGE_FALLBACKS[tierKey], earnedAt };
month: "long", day: "numeric", year: "numeric",
})
: null;
return { ...TIER_BADGES[tierKey], earnedAt };
}; };
const getEarlyAccessBadge = () => { const getEarlyAccessBadge = () => {
const achievement = achievements.find((a) => a.key === "early_access"); const achievement = achievements.find((a) => a.key === "early_access");
const earnedAt = achievement?.granted_at const earnedAt = achievement?.granted_at ? fmtDate(achievement.granted_at) : null;
? new Date(achievement.granted_at).toLocaleDateString("en-US", { return { ...earlyAccessBadge, earnedAt };
month: "long", day: "numeric", year: "numeric",
})
: null;
return { ...EARLY_ACCESS_BADGE, earnedAt };
}; };
const getActiveTierBadgeWithDate = () => { const getActiveTierBadgeWithDate = () => {
@@ -261,12 +301,12 @@ const ProfilePage = () => {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<img <img
className="size-4.5 cursor-pointer" className="size-4.5 cursor-pointer"
src={EARLY_ACCESS_BADGE.src} src={earlyAccessBadge.src}
alt={EARLY_ACCESS_BADGE.label} alt={earlyAccessBadge.label}
onClick={() => { setSelectedBadge(getEarlyAccessBadge()); setBadgeOpen(true); }} onClick={() => { setSelectedBadge(getEarlyAccessBadge()); setBadgeOpen(true); }}
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent><p>{EARLY_ACCESS_BADGE.label}</p></TooltipContent> <TooltipContent><p>{earlyAccessBadge.label}</p></TooltipContent>
</Tooltip> </Tooltip>
)} )}
@@ -276,7 +316,7 @@ const ProfilePage = () => {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<img <img
className="size-4.5 cursor-pointer" className="size-4.5 cursor-pointer"
src={TIER_BADGES.premium.src} src={TIER_BADGE_FALLBACKS.premium.src}
alt="Premium" alt="Premium"
onClick={() => { setSelectedBadge(getBadgeWithDate("premium_first_time", "premium")); setBadgeOpen(true); }} onClick={() => { setSelectedBadge(getBadgeWithDate("premium_first_time", "premium")); setBadgeOpen(true); }}
/> />
@@ -291,7 +331,7 @@ const ProfilePage = () => {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<img <img
className="size-4.5 cursor-pointer" className="size-4.5 cursor-pointer"
src={TIER_BADGES.exclusive.src} src={TIER_BADGE_FALLBACKS.exclusive.src}
alt="Exclusive" alt="Exclusive"
onClick={() => { setSelectedBadge(getBadgeWithDate("exclusive_first_time", "exclusive")); setBadgeOpen(true); }} onClick={() => { setSelectedBadge(getBadgeWithDate("exclusive_first_time", "exclusive")); setBadgeOpen(true); }}
/> />
@@ -305,12 +345,9 @@ const ProfilePage = () => {
{userRank === 0 && ( {userRank === 0 && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<img <span className="cursor-pointer" onClick={() => { setSelectedBadge(tierBadge); setBadgeOpen(true); }}>
className="size-4.5 cursor-pointer" <TierBadgeDisplay badge={tierBadge} className="size-4.5" />
src={tierBadge.src} </span>
alt={tierBadge.label}
onClick={() => { setSelectedBadge(tierBadge); setBadgeOpen(true); }}
/>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent><p>{tierBadge.label}</p></TooltipContent> <TooltipContent><p>{tierBadge.label}</p></TooltipContent>
</Tooltip> </Tooltip>
@@ -320,12 +357,9 @@ const ProfilePage = () => {
{userRank === 1 && !hasPremiumBadge && ( {userRank === 1 && !hasPremiumBadge && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<img <span className="cursor-pointer" onClick={() => { setSelectedBadge(getActiveTierBadgeWithDate()); setBadgeOpen(true); }}>
className="size-4.5 cursor-pointer" <TierBadgeDisplay badge={tierBadge} className="size-4.5" />
src={tierBadge.src} </span>
alt={tierBadge.label}
onClick={() => { setSelectedBadge(getActiveTierBadgeWithDate()); setBadgeOpen(true); }}
/>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent><p>{tierBadge.label}</p></TooltipContent> <TooltipContent><p>{tierBadge.label}</p></TooltipContent>
</Tooltip> </Tooltip>
@@ -363,8 +397,8 @@ const ProfilePage = () => {
} }
> >
<div className="flex flex-col items-center gap-4 py-2"> <div className="flex flex-col items-center gap-4 py-2">
{selectedBadge?.src && ( {(selectedBadge?.src || selectedBadge?.icon) && (
<img src={selectedBadge.src} alt={selectedBadge.label} className="size-16" /> <TierBadgeDisplay badge={selectedBadge} className="size-16" />
)} )}
<div className="text-center space-y-1"> <div className="text-center space-y-1">
<p className="text-sm font-medium">{selectedBadge?.label}</p> <p className="text-sm font-medium">{selectedBadge?.label}</p>
@@ -530,9 +564,7 @@ const ProfilePage = () => {
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Member since</span> <span className="text-muted-foreground">Member since</span>
<span className="font-medium"> <span className="font-medium">
{profile?.createdAt {profile?.createdAt ? fmtDate(profile.createdAt) : "—"}
? new Date(profile.createdAt).toLocaleDateString("en-US", { month: "long", year: "numeric" })
: "—"}
</span> </span>
</div> </div>
<Separator /> <Separator />
@@ -542,9 +574,7 @@ const ProfilePage = () => {
<Skeleton className="h-5 w-20" /> <Skeleton className="h-5 w-20" />
) : ( ) : (
<span className="font-medium capitalize"> <span className="font-medium capitalize">
{tier}{myTier?.expires_at && ` · until ${new Date(myTier.expires_at).toLocaleDateString("en-US", { {tier}{myTier?.expires_at && ` · until ${fmtDate(myTier.expires_at)}`}
month: "short", day: "numeric", year: "numeric",
})}`}
</span> </span>
)} )}
</div> </div>
@@ -638,9 +668,7 @@ const ProfilePage = () => {
<p className="text-xs text-muted-foreground">{item.description}</p> <p className="text-xs text-muted-foreground">{item.description}</p>
{item.granted_at && ( {item.granted_at && (
<p className="text-xs mt-0.5"> <p className="text-xs mt-0.5">
{new Date(item.granted_at).toLocaleDateString("en-US", { {fmtDate(item.granted_at)}
month: "long", day: "numeric", year: "numeric",
})}
</p> </p>
)} )}
</div> </div>
+308 -35
View File
@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect, useRef } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate, useLocation } from "react-router-dom"; import { useParams, useNavigate, useLocation, useBlocker } from "react-router-dom";
import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle, Zap } from "lucide-react"; import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle, Zap, ListChecks } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group"; import { ButtonGroup } from "@/components/ui/button-group";
import { import {
@@ -20,6 +20,64 @@ import { useClientCourses } from "@/contexts/ClientCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgressContext"; import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgressContext";
import { Skeleton } from "@/components/ui/skeleton"; 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 ─────────────────────────────────────────────────────── // ─── Assessment Gate ───────────────────────────────────────────────────────
@@ -114,7 +172,7 @@ const SidebarContent = ({
units, selectedLessonId, selectedQuizId, onLessonClick, onQuizClick, units, selectedLessonId, selectedQuizId, onLessonClick, onQuizClick,
courseAssessment, selectedAssessment, onAssessmentClick, assessmentLocked, courseAssessment, selectedAssessment, onAssessmentClick, assessmentLocked,
isCompleted, selectedCompletion, onCompletionClick, isCompleted, selectedCompletion, onCompletionClick,
getLessonCompleted, getUnitCompleted, getLessonCompleted, getUnitCompleted, isQuizLocked,
loading, loading,
}) => ( }) => (
<ScrollArea className="h-full p-3 md:p-4"> <ScrollArea className="h-full p-3 md:p-4">
@@ -162,24 +220,31 @@ const SidebarContent = ({
</li> </li>
); );
})} })}
{unit.quiz && ( {unit.quiz && (() => {
const quizLocked = isQuizLocked?.(unit);
return (
<li <li
onClick={() => onQuizClick({ unit, quiz: unit.quiz })} 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 ${ 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 selectedQuizId === unit.quiz.quiz_id
? "bg-muted-foreground/10 text-foreground font-medium" ? "bg-muted-foreground/10 text-foreground font-medium"
: unit.quiz.has_passed : unit.quiz.has_passed
? "text-emerald-600 dark:text-emerald-400" ? "text-emerald-600 dark:text-emerald-400 hover:bg-muted-foreground/10"
: "text-muted-foreground hover:text-foreground" : 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 {unit.quiz.has_passed
? <CheckCircle2 className="size-3.5 text-emerald-500 shrink-0" /> ? <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" /> : <ClipboardList className="size-3.5 shrink-0" />
} }
{unit.quiz.title || "Quiz"} {unit.quiz.title || "Quiz"}
</li> </li>
)} );
})()}
</ul> </ul>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
@@ -234,7 +299,7 @@ const UnitList = () => {
const { const {
course, courseLoading, courseBlocked, getCourse, course, courseLoading, courseBlocked, getCourse,
lesson, lessonLoading, getLesson, resetLesson, lesson, lessonLoading, getLesson, resetLesson,
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz, quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz, saveQuizDraft,
assessment, assessmentLoading, getCourseAssessment, resetAssessment, startCourseAssessment, saveDraft, refreshAssessmentSession, submitCourseAssessment, assessment, assessmentLoading, getCourseAssessment, resetAssessment, startCourseAssessment, saveDraft, refreshAssessmentSession, submitCourseAssessment,
} = useClientCourses(); } = useClientCourses();
@@ -243,12 +308,17 @@ const UnitList = () => {
upsertLessonProgress, upsertLessonProgress,
isCompleted: isProgressCompleted, isCompleted: isProgressCompleted,
isRead: isProgressRead, isRead: isProgressRead,
completedTasks,
clearCompletedTasks,
resetProgress, resetProgress,
} = useCourseReadingProgress(); } = useCourseReadingProgress();
// Tracks which lessons have been marked completed this session to avoid duplicate calls // Tracks which lessons have been marked completed this session to avoid duplicate calls
const completedSessionRef = useRef(new Set()); const completedSessionRef = useRef(new Set());
// ── Task context — populated from navigation state or backend fallback ──
const [taskCtx, setTaskCtx] = useState(null);
// ── Local UI state ────────────────────────────────────────────────────── // ── Local UI state ──────────────────────────────────────────────────────
const [selectedLessonId, setSelectedLessonId] = useState(null); const [selectedLessonId, setSelectedLessonId] = useState(null);
const [selectedQuizId, setSelectedQuizId] = useState(null); const [selectedQuizId, setSelectedQuizId] = useState(null);
@@ -260,6 +330,16 @@ const UnitList = () => {
const resetCompletion = () => setSelectedCompletion(false); 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 ─ // ── Quiz gate — all required unit quizzes must be passed before assessment ─
const requiredQuizzes = (course?.units ?? []) const requiredQuizzes = (course?.units ?? [])
.filter((u) => u.quiz?.is_required) .filter((u) => u.quiz?.is_required)
@@ -273,6 +353,47 @@ const UnitList = () => {
const allRequiredQuizzesPassed = const allRequiredQuizzesPassed =
requiredQuizzes.length === 0 || requiredQuizzes.every((q) => q.has_passed); 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) ────────────── // ── Flatten all content (lessons + quiz + final assessment) ──────────────
const allContent = (course?.units ?? []).flatMap((u) => [ const allContent = (course?.units ?? []).flatMap((u) => [
...(u.lessons ?? []).map((l) => ({ type: "lesson", unit: u, lesson: l })), ...(u.lessons ?? []).map((l) => ({ type: "lesson", unit: u, lesson: l })),
@@ -307,7 +428,24 @@ const UnitList = () => {
if (completedSessionRef.current.has(selectedLessonId)) return; if (completedSessionRef.current.has(selectedLessonId)) return;
if (isProgressCompleted(lesson.uuid)) return; if (isProgressCompleted(lesson.uuid)) return;
completedSessionRef.current.add(selectedLessonId); 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]); }, [scrollProgress]);
// ── Fetch course + progress on mount ───────────────────────────────── // ── Fetch course + progress on mount ─────────────────────────────────
@@ -323,12 +461,53 @@ const UnitList = () => {
}; };
}, [courseId]); }, [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 ──────────────────── // ── Auto-load lesson once course data is available ────────────────────
// If navigated from CourseDetails with a specific lesson, open that one; // If navigated from CourseDetails with a specific lesson, open that one;
// otherwise fall back to the first lesson. // otherwise fall back to the first lesson.
useEffect(() => { useEffect(() => {
if (!course || selectedLessonId || selectedQuizId || selectedAssessment || selectedCompletion) return; 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) { if (seekFirstIncomplete) {
const firstIncompleteUnit = (course.units ?? []).find((u) => u.quiz && !u.quiz.has_passed); const firstIncompleteUnit = (course.units ?? []).find((u) => u.quiz && !u.quiz.has_passed);
@@ -391,12 +570,26 @@ const UnitList = () => {
{ label: selectedCompletion ? "Course Complete" : selectedAssessment ? "Course Assessment" : (currentUnit?.title ?? "Select a lesson") }, { 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 ─────────────────────────────────────────────────────── // ── Lesson click ───────────────────────────────────────────────────────
const handleLessonClick = useCallback(async ({ unit, lesson: lessonStub }) => { const handleLessonClick = useCallback(async ({ unit, lesson: lessonStub }) => {
if (lessonStub.lesson_id === selectedLessonId) { if (lessonStub.lesson_id === selectedLessonId) { setSidebarOpen(false); return; }
setSidebarOpen(false);
return; const doNav = async () => {
}
setSelectedLessonId(lessonStub.lesson_id); setSelectedLessonId(lessonStub.lesson_id);
setSelectedQuizId(null); setSelectedQuizId(null);
setSelectedAssessment(false); setSelectedAssessment(false);
@@ -406,18 +599,20 @@ const UnitList = () => {
setSelectedUnitId(unit.unit_id); setSelectedUnitId(unit.unit_id);
setSidebarOpen(false); setSidebarOpen(false);
await getLesson(courseId, unit.unit_id, lessonStub.lesson_id); await getLesson(courseId, unit.unit_id, lessonStub.lesson_id);
// Fire in_progress only if not already started or completed
if (!isProgressRead(lessonStub.uuid)) { if (!isProgressRead(lessonStub.uuid)) {
upsertLessonProgress(courseId, unit.unit_id, lessonStub.lesson_id, lessonStub.uuid, 'in_progress'); 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]); }, [selectedLessonId, courseId, getLesson, resetQuiz, resetAssessment, isProgressRead, upsertLessonProgress]);
// ── Quiz click ───────────────────────────────────────────────────────── // ── Quiz click ─────────────────────────────────────────────────────────
const handleQuizClick = useCallback(async ({ unit, quiz: quizStub }) => { const handleQuizClick = useCallback(async ({ unit, quiz: quizStub }) => {
if (quizStub.quiz_id === selectedQuizId) { if (quizStub.quiz_id === selectedQuizId) { setSidebarOpen(false); return; }
setSidebarOpen(false);
return; const doNav = async () => {
}
setSelectedQuizId(quizStub.quiz_id); setSelectedQuizId(quizStub.quiz_id);
setSelectedLessonId(null); setSelectedLessonId(null);
setSelectedAssessment(false); setSelectedAssessment(false);
@@ -426,15 +621,20 @@ const UnitList = () => {
resetAssessment(); resetAssessment();
setSelectedUnitId(unit.unit_id); setSelectedUnitId(unit.unit_id);
setSidebarOpen(false); setSidebarOpen(false);
if (!lockedQuizUnitIds.has(unit.unit_id)) {
await getUnitQuiz(courseId, unit.unit_id); await getUnitQuiz(courseId, unit.unit_id);
}, [selectedQuizId, courseId, getUnitQuiz, resetLesson, resetAssessment]); }
};
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
await doNav();
}, [selectedQuizId, courseId, getUnitQuiz, resetLesson, resetAssessment, lockedQuizUnitIds]);
// ── Course assessment click ───────────────────────────────────────────── // ── Course assessment click ─────────────────────────────────────────────
const handleAssessmentClick = useCallback(async () => { const handleAssessmentClick = useCallback(async () => {
if (selectedAssessment) { if (selectedAssessment) { setSidebarOpen(false); return; }
setSidebarOpen(false);
return; const doNav = async () => {
}
setSelectedAssessment(true); setSelectedAssessment(true);
setSelectedLessonId(null); setSelectedLessonId(null);
setSelectedQuizId(null); setSelectedQuizId(null);
@@ -442,17 +642,18 @@ const UnitList = () => {
resetLesson(); resetLesson();
resetQuiz(); resetQuiz();
setSidebarOpen(false); setSidebarOpen(false);
if (allRequiredQuizzesPassed) { if (allRequiredQuizzesPassed) await getCourseAssessment(courseId);
await getCourseAssessment(courseId); };
}
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
await doNav();
}, [selectedAssessment, courseId, getCourseAssessment, resetLesson, resetQuiz, allRequiredQuizzesPassed]); }, [selectedAssessment, courseId, getCourseAssessment, resetLesson, resetQuiz, allRequiredQuizzesPassed]);
// ── Course complete click ─────────────────────────────────────────────── // ── Course complete click ───────────────────────────────────────────────
const handleCompletionClick = useCallback(() => { const handleCompletionClick = useCallback(() => {
if (selectedCompletion) { if (selectedCompletion) { setSidebarOpen(false); return; }
setSidebarOpen(false);
return; const doNav = () => {
}
setSelectedLessonId(null); setSelectedLessonId(null);
setSelectedQuizId(null); setSelectedQuizId(null);
setSelectedAssessment(false); setSelectedAssessment(false);
@@ -461,8 +662,22 @@ const UnitList = () => {
resetAssessment(); resetAssessment();
setSelectedCompletion(true); setSelectedCompletion(true);
setSidebarOpen(false); setSidebarOpen(false);
};
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
doNav();
}, [selectedCompletion, resetLesson, resetQuiz, resetAssessment]); }, [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) ───────────── // ── Next content item (lesson, quiz, or final assessment) ─────────────
const getNextContent = useCallback(() => { const getNextContent = useCallback(() => {
const idx = allContent.findIndex((item) => const idx = allContent.findIndex((item) =>
@@ -509,6 +724,52 @@ const UnitList = () => {
return ( return (
<> <>
<PageMeta title={pageTitle} /> <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) ── */} {/* ── Up next floating button (lesson only — quizzes/assessments have their own bottom controls) ── */}
{scrollProgress >= 100 && nextContent && !selectedQuizId && !selectedAssessment && ( {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"> <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} onCompletionClick={handleCompletionClick}
getLessonCompleted={(l) => isProgressCompleted(l.uuid)} getLessonCompleted={(l) => isProgressCompleted(l.uuid)}
getUnitCompleted={(u) => isProgressCompleted(u.uuid)} getUnitCompleted={(u) => isProgressCompleted(u.uuid)}
isQuizLocked={(u) => lockedQuizUnitIds.has(u.unit_id)}
loading={courseLoading} loading={courseLoading}
/> />
</SheetContent> </SheetContent>
@@ -599,7 +861,7 @@ const UnitList = () => {
{/* ── Desktop sidebar ── */} {/* ── Desktop sidebar ── */}
{desktopSidebarOpen && ( {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 <SidebarContent
units={units} units={units}
selectedLessonId={selectedLessonId} selectedLessonId={selectedLessonId}
@@ -621,7 +883,7 @@ const UnitList = () => {
)} )}
{/* ── Main content ── */} {/* ── 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"> <div className="relative w-full h-full">
{selectedCompletion ? ( {selectedCompletion ? (
<CourseCompleteBlock course={course} /> <CourseCompleteBlock course={course} />
@@ -638,7 +900,7 @@ const UnitList = () => {
loading={assessmentLoading} loading={assessmentLoading}
label="Assessment" label="Assessment"
onStart={() => startCourseAssessment(courseId, assessment.assessment_id)} onStart={() => startCourseAssessment(courseId, assessment.assessment_id)}
onDraft={(answers) => saveDraft(courseId, assessment.assessment_id, answers)} onDraft={handleAssessmentDraft}
onRefreshSession={() => refreshAssessmentSession(courseId, assessment.assessment_id)} onRefreshSession={() => refreshAssessmentSession(courseId, assessment.assessment_id)}
onSubmit={async (answers, sessionId) => { onSubmit={async (answers, sessionId) => {
const result = await submitCourseAssessment(courseId, assessment.assessment_id, answers, sessionId); const result = await submitCourseAssessment(courseId, assessment.assessment_id, answers, sessionId);
@@ -646,19 +908,30 @@ const UnitList = () => {
return result; return result;
}} }}
onRetake={() => getCourseAssessment(courseId)} onRetake={() => getCourseAssessment(courseId)}
onActiveChange={setQuizActive}
/> />
) )
) : selectedQuizId ? ( ) : selectedQuizId ? (
lockedQuizUnitIds.has(selectedUnitId) ? (
<QuizPrerequisiteGate
previousQuizzes={previousRequiredQuizzes}
units={units}
onQuizClick={handleQuizClick}
/>
) : (
<QuizBlock <QuizBlock
quiz={quiz} quiz={quiz}
loading={quizLoading} loading={quizLoading}
onDraft={handleQuizDraft}
onSubmit={async (answers) => { onSubmit={async (answers) => {
const result = await submitUnitQuiz(courseId, selectedUnitId, selectedQuizId, answers); const result = await submitUnitQuiz(courseId, selectedUnitId, selectedQuizId, answers);
await getCourse(courseId); await getCourse(courseId);
return result; return result;
}} }}
onRetake={() => getUnitQuiz(courseId, selectedUnitId)} onRetake={() => getUnitQuiz(courseId, selectedUnitId)}
onActiveChange={setQuizActive}
/> />
)
) : ( ) : (
<LessonBlock <LessonBlock
lesson={lesson ? { ...lesson, blocks: lesson.page?.blocks ?? [] } : null} lesson={lesson ? { ...lesson, blocks: lesson.page?.blocks ?? [] } : null}
+4 -10
View File
@@ -12,18 +12,11 @@ import {
Tag, LockIcon, Zap, CalendarDays, Tag, LockIcon, Zap, CalendarDays,
} from "lucide-react"; } from "lucide-react";
import { useClientTiers } from "@/contexts/ClientTiersProvider"; import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function formatPrice(price, currency = "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency,
minimumFractionDigits: 2,
}).format(price);
}
function formatDuration(days) { function formatDuration(days) {
if (!days) return null; if (!days) return null;
if (days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""}`; if (days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""}`;
@@ -82,6 +75,7 @@ const ViewPlan = () => {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { myTier, getMyTier, plans, plansLoading, getPlans } = useClientTiers(); const { myTier, getMyTier, plans, plansLoading, getPlans } = useClientTiers();
const { fmtCurrency } = useDateFormat();
useEffect(() => { useEffect(() => {
getMyTier(); getMyTier();
@@ -129,7 +123,7 @@ const ViewPlan = () => {
<h2 className="text-2xl font-bold">{plan.label}</h2> <h2 className="text-2xl font-bold">{plan.label}</h2>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-3xl font-bold"> <span className="text-3xl font-bold">
{formatPrice(plan.price, plan.currency)} {fmtCurrency(plan.price, plan.currency)}
</span> </span>
{duration && ( {duration && (
<span className="text-sm text-muted-foreground flex items-center gap-1"> <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}`)} 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> </Button>
</div> </div>
)} )}
+409 -239
View File
@@ -1,25 +1,26 @@
/*********************************************************************************************************************************************************************** /*
* File Name : ViewRequirement.jsx * 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 * Route: /group/:groupId/view/:taskListId/task/:taskId/requirement
***********************************************************************************************************************************************************************/ *
import { useState, useCallback, useEffect, useRef } from 'react'; * 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 { useParams, useNavigate, useLocation } from 'react-router-dom';
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb'; import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
import { import {
House, TableOfContents, CheckCheck, Circle, House, TableOfContents, CheckCheck, Circle,
BookOpen, Layers, FileText, ArrowLeft, ChevronDown, ChevronRight, Layers, ArrowLeft, Lock, Zap, RefreshCw, Tag,
Lock, Zap, RefreshCw, ClipboardList, GraduationCap, CheckCircle2,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import { ScrollArea } from '@/components/ui/scroll-area';
Accordion, AccordionContent, AccordionItem, AccordionTrigger,
} from '@/components/ui/accordion';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -29,170 +30,266 @@ import { useTaskProgress } from '@/contexts/ClientTaskProgressContext';
import LessonBlock from '@/modules/client/components/LessonBlock'; import LessonBlock from '@/modules/client/components/LessonBlock';
import api from '@/utils/api.util'; import api from '@/utils/api.util';
import { useClientTiers } from '@/contexts/ClientTiersProvider';
import { resolveTierBadge } from '@/utils/tierBadge.util';
// ─── Type config ────────────────────────────────────────────────────────────── // ─── Constants ────────────────────────────────────────────────────────────────
const TYPE_ICON = { read_course: BookOpen, read_unit: Layers, read_lesson: FileText }; const TYPE_LABEL = {
const TYPE_LABEL = { read_course: 'Courses', read_unit: 'Units', read_lesson: 'Lessons' }; 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 ────────────────────────────────────────────────────────────────── // ─── Sidebar ──────────────────────────────────────────────────────────────────
//
// selection = { reqId, lessonUuid? }
// • read_course / read_lesson: lessonUuid is undefined
// • read_unit: lessonUuid identifies which sub-lesson is open
//
const SidebarContent = ({ const SidebarContent = ({
requirements, requirements,
selection, selection,
onSelectReq, // (req) → select a read_course / read_lesson requirement onSelectReq,
onSelectLesson, // (req, lesson) → select a lesson within a read_unit onSelectLesson,
isCompleted, isCompleted,
unitLessonsMap, // { [reqId]: { meta, lessons } } unitLessonsMap,
unitLoadingMap, // { [reqId]: boolean } unitLoadingMap,
courseUnitsMap,
courseLoadingMap,
referenceMetaMap,
onNavigateToCourse,
}) => { }) => {
const groups = ['read_course', 'read_unit', 'read_lesson'] const groups = ['read_course', 'read_unit', 'read_lesson']
.map((type) => ({ type, items: requirements.filter((r) => r.type === type) })) .map((type) => ({ type, items: requirements.filter((r) => r.type === type) }))
.filter((g) => g.items.length > 0); .filter((g) => g.items.length > 0);
return ( return (
<ScrollArea className="h-full p-3 md:p-4"> <ScrollArea className="h-full">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-2 px-2"> <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 Requirements
</p> </p>
<Accordion {groups.length === 1 && (
type="multiple" <span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/60 bg-muted px-2 py-0.5 rounded-full shrink-0">
defaultValue={groups.map((g) => g.type)} {TYPE_LABEL[groups[0].type]}
className="space-y-1" </span>
> )}
{groups.map(({ type, items }) => ( </div>
<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;
if (req.type === 'read_unit') { {groups.map(({ type, items }) => (
// ── Unit: show lessons as sub-items ────────────── <div key={type} className="space-y-1">
const entry = unitLessonsMap[req.requirement_id]; {/* Section heading — hidden when only one type is visible (entry-scoped) */}
const loading = unitLoadingMap[req.requirement_id]; {groups.length > 1 && (
const lessons = entry?.lessons ?? []; <p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground px-1 mb-2">
{TYPE_LABEL[type]}
</p>
)}
{items.map((req) => {
const isSelected = selection?.reqId === req.requirement_id;
const done = isCompleted(req.requirement_id, req.reference_id); const done = isCompleted(req.requirement_id, req.reference_id);
const meta = referenceMetaMap[req.requirement_id];
return ( return (
<li key={req.requirement_id}> <div key={req.requirement_id}>
{/* Unit header row (non-clickable — navigates via lessons) */} {/* Requirement button */}
<div className="flex items-center gap-2 pl-4 pr-3 py-1.5 text-sm rounded-md text-muted-foreground"> <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 {done
? <CheckCheck className="size-3.5 text-green-500 shrink-0" /> ? <CheckCheck className="size-3.5 text-green-500 shrink-0" />
: <Circle className="size-3.5 shrink-0 opacity-40" /> : <Circle className="size-3.5 shrink-0 opacity-40" />
} }
<Icon className="size-3.5 shrink-0 opacity-60" /> <span className="truncate flex-1 font-medium min-w-0">
<span className="truncate font-medium text-foreground"> {req.reference_label ?? req.type}
{req.reference_label ?? 'Unit'}
</span> </span>
</div> <TierBadge tier={meta?.subscription} />
</button>
{/* Lessons sub-list */} {/* 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];
return (
<div className="pl-5 mt-1 space-y-0.5 border-l ml-4 mb-2">
{loading && ( {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-32" />
<Skeleton className="h-3 w-24" /> <Skeleton className="h-3 w-24" />
</div> </div>
)} )}
{lessons.map((lesson, i) => { {lessons.map((lesson, i) => {
const lessonActive = const active = selection?.lessonUuid === lesson.uuid;
isActive && selection?.lessonUuid === lesson.uuid;
return ( return (
<li <button
key={lesson.uuid} key={lesson.uuid}
onClick={() => onSelectLesson(req, lesson)} 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 ${ className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors ${
lessonActive active
? 'bg-muted-foreground/15 font-medium text-foreground' ? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`} }`}
> >
<span className="text-xs tabular-nums w-4 shrink-0 opacity-50"> <span className="text-xs tabular-nums w-4 shrink-0 opacity-40">{i + 1}</span>
{i + 1}
</span>
<span className="truncate">{lesson.title}</span> <span className="truncate">{lesson.title}</span>
</li> </button>
); );
})} })}
</li> </div>
); );
} })()}
// ── read_course / read_lesson ───────────────────────── {isSelected && type === 'read_course' && (() => {
const done = isCompleted(req.requirement_id, req.reference_id); 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 ( return (
<li <div className="pl-5 mt-1 space-y-3 border-l ml-4 mb-2">
key={req.requirement_id} {loading && (
onClick={() => onSelectReq(req)} <div className="py-1 space-y-1.5">
className={`flex items-center gap-2 pl-6 pr-3 py-1.5 text-sm rounded-md cursor-pointer transition-colors ${ <Skeleton className="h-3 w-32" />
isActive <Skeleton className="h-3 w-24" />
? 'bg-muted-foreground/10 text-foreground font-medium' </div>
: 'text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground' )}
{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'
}`} }`}
> >
{done <span className="text-xs tabular-nums w-4 shrink-0 opacity-40">{lIdx + 1}</span>
? <CheckCheck className="size-3.5 text-green-500 shrink-0" /> <span className="truncate">{lesson.title}</span>
: <Circle className="size-3.5 shrink-0 opacity-40" /> </button>
}
<Icon className="size-3.5 shrink-0 opacity-60" />
<span className="truncate">{req.reference_label ?? req.type}</span>
</li>
); );
})} })}
</ul> {unit.quiz && (
</AccordionContent> <button
</AccordionItem> 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>
))} ))}
</Accordion> {assessment && (
<ScrollBar orientation="vertical" /> <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>
))}
</div>
</ScrollArea> </ScrollArea>
); );
}; };
// ─── Content: course view ───────────────────────────────────────────────────── // ─── Course overview (before first lesson selected) ───────────────────────────
const CourseView = ({ req }) => { const CourseOverview = ({ meta }) => {
const [info, setInfo] = useState(null); const { tierMap } = useClientTiers();
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 />;
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{info?.subscription && <Badge variant="secondary" className="text-xs capitalize">{info.subscription}</Badge>} {meta?.subscription && (() => {
{info?.level && <Badge variant="outline" className="text-xs capitalize">{info.level}</Badge>} 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> </div>
<h1 className="text-2xl font-bold mt-1">{req.reference_label ?? 'Course'}</h1> <h1 className="text-2xl font-bold">{meta?.title ?? 'Course'}</h1>
</div> {meta?.description && <p className="text-sm text-muted-foreground leading-relaxed">{meta.description}</p>}
{info?.description && ( <p className="text-sm text-blue-500 dark:text-blue-400 mt-2">Select a lesson from the sidebar to begin reading.</p>
<p className="text-sm text-muted-foreground leading-relaxed">{info.description}</p>
)}
</div> </div>
); );
}; };
// ─── Content: standalone lesson view (for read_lesson requirements) ─────────── // ─── On-demand lesson fetch for read_course ───────────────────────────────────
const LessonView = ({ req }) => { 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 [lesson, setLesson] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [locked, setLocked] = useState(false); const [locked, setLocked] = useState(false);
@@ -202,11 +299,16 @@ const LessonView = ({ req }) => {
api.get(`/client/courses/lesson/uuid/${req.reference_id}`) api.get(`/client/courses/lesson/uuid/${req.reference_id}`)
.then((r) => { .then((r) => {
const d = r.data?.data; const d = r.data?.data;
if (d) setLesson({ ...d, blocks: d.blocks ?? [] }); if (d) {
}) setLesson({ ...d, blocks: d.blocks ?? [] });
.catch((err) => { onMeta?.(req.requirement_id, {
if (err?.response?.status === 403) setLocked(true); 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)); .finally(() => setLoading(false));
}, [req.reference_id]); }, [req.reference_id]);
@@ -215,7 +317,7 @@ const LessonView = ({ req }) => {
return <LessonBlock lesson={lesson} loading={false} />; return <LessonBlock lesson={lesson} loading={false} />;
}; };
// ─── Loading skeleton ───────────────────────────────────────────────────────── // ─── Skeletons / locked ───────────────────────────────────────────────────────
const ContentSkeleton = () => ( const ContentSkeleton = () => (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Skeleton className="h-5 w-32" /> <Skeleton className="h-5 w-32" />
@@ -225,7 +327,6 @@ const ContentSkeleton = () => (
</div> </div>
); );
// ─── Locked content placeholder ───────────────────────────────────────────────
const LockedContent = () => { const LockedContent = () => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
@@ -236,16 +337,14 @@ const LockedContent = () => {
<div className="space-y-2 max-w-sm"> <div className="space-y-2 max-w-sm">
<h2 className="text-lg font-semibold">Premium / Exclusive Content</h2> <h2 className="text-lg font-semibold">Premium / Exclusive Content</h2>
<p className="text-sm text-muted-foreground leading-relaxed"> <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> </p>
</div> </div>
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<Button onClick={() => navigate('/plans')} className="gap-1.5"> <Button onClick={() => navigate('/plans')} className="gap-1.5">
<Zap className="size-4" /> View Available Plans <Zap className="size-4" /> View Available Plans
</Button> </Button>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
Already subscribed? Your plan may not cover this tier.
</p>
</div> </div>
</div> </div>
); );
@@ -258,14 +357,14 @@ const ViewRequirement = () => {
const location = useLocation(); const location = useLocation();
const { task, taskList, loading, fetchTask, fetchTaskList } = useTask(); const { task, taskList, loading, fetchTask, fetchTaskList } = useTask();
const { const { fetchProgress, isCompleted, updateLessonProgress, loading: progressLoading } = useTaskProgress();
fetchProgress, isCompleted, updateLessonProgress, loading: progressLoading,
} = useTaskProgress();
// selection = { reqId, lessonUuid? }
const [selection, setSelection] = useState(null); const [selection, setSelection] = useState(null);
const [unitLessonsMap, setUnitLessonsMap] = useState({}); const [unitLessonsMap, setUnitLessonsMap] = useState({});
const [unitLoadingMap, setUnitLoadingMap] = useState({}); const [unitLoadingMap, setUnitLoadingMap] = useState({});
const [courseUnitsMap, setCourseUnitsMap] = useState({});
const [courseLoadingMap, setCourseLoadingMap] = useState({});
const [referenceMetaMap, setReferenceMetaMap] = useState({});
const [lockedReqs, setLockedReqs] = useState(new Set()); const [lockedReqs, setLockedReqs] = useState(new Set());
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [desktopOpen, setDesktopOpen] = useState(true); const [desktopOpen, setDesktopOpen] = useState(true);
@@ -280,12 +379,25 @@ const ViewRequirement = () => {
fetchProgress(groupId, taskListId, taskId); fetchProgress(groupId, taskListId, taskId);
}, [groupId, taskListId, taskId]); }, [groupId, taskListId, taskId]);
// ── Filtered requirements ───────────────────────────────────────────────── // ── read_* requirements only ─────────────────────────────────────────────
const requirements = (task?.requirements ?? []).filter((r) => const requirements = (task?.requirements ?? []).filter((r) =>
['read_course', 'read_unit', 'read_lesson'].includes(r.type) ['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(() => { useEffect(() => {
requirements.forEach((req) => { requirements.forEach((req) => {
if (req.type !== 'read_unit') return; if (req.type !== 'read_unit') return;
@@ -294,91 +406,166 @@ const ViewRequirement = () => {
api.get(`/client/courses/unit/uuid/${req.reference_id}/lessons`) api.get(`/client/courses/unit/uuid/${req.reference_id}/lessons`)
.then((r) => { .then((r) => {
const data = r.data?.data ?? null; const data = r.data?.data ?? null;
const lessons = data?.lessons ?? []; setUnitLessonsMap((p) => ({ ...p, [req.requirement_id]: { meta: data, lessons: data?.lessons ?? [] } }));
setUnitLessonsMap((p) => ({ ...p, [req.requirement_id]: { meta: data, lessons } }));
}) })
.catch((err) => { .catch((err) => {
if (err?.response?.status === 403) { if (err?.response?.status === 403)
setLockedReqs((prev) => new Set(prev).add(req.requirement_id)); setLockedReqs((p) => new Set(p).add(req.requirement_id));
}
}) })
.finally(() => setUnitLoadingMap((p) => ({ ...p, [req.requirement_id]: false }))); .finally(() => setUnitLoadingMap((p) => ({ ...p, [req.requirement_id]: false })));
}); });
}, [requirements.length]); }, [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 ─────────────────────────── // ── Auto-select from router state or first item ───────────────────────────
useEffect(() => { useEffect(() => {
if (initialised.current || !requirements.length) return; if (initialised.current || !requirements.length) return;
const state = location.state ?? {}; const state = location.state ?? {};
const unitId = state.unit?.id;
const lesId = state.lesson?.id;
if (unitId) { const trySelect = (key, type) => {
const req = requirements.find((r) => r.requirement_id === unitId); if (!state[key]?.id) return false;
if (req) { const req = requirements.find((r) => r.requirement_id === state[key].id);
// Select unit; lesson will be auto-picked once lessons are fetched if (!req) return false;
setSelection({ reqId: req.requirement_id, lessonUuid: null }); const needsLesson = type === 'read_unit' || type === 'read_course';
initialised.current = true; setSelection({ reqId: req.requirement_id, ...(needsLesson ? { lessonUuid: null } : {}) });
return; return true;
} };
}
if (lesId) { if (!trySelect('course', 'read_course') && !trySelect('unit', 'read_unit') && !trySelect('lesson', 'read_lesson')) {
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]; const first = requirements[0];
if (first.type === 'read_unit') { const needsLesson = first.type === 'read_unit' || first.type === 'read_course';
setSelection({ reqId: first.requirement_id, lessonUuid: null }); setSelection({ reqId: first.requirement_id, ...(needsLesson ? { lessonUuid: null } : {}) });
} else {
setSelection({ reqId: first.requirement_id });
} }
initialised.current = true; initialised.current = true;
}, [requirements, location.state]); }, [requirements, location.state]);
// ── Auto-pick first lesson once unit lessons are loaded ─────────────────── // ── Auto-pick first lesson once unit/course lessons load ─────────────────
useEffect(() => { useEffect(() => {
if (!selection) return; if (!selection || selection.lessonUuid !== null) return;
const req = requirements.find((r) => r.requirement_id === selection.reqId); const req = requirements.find((r) => r.requirement_id === selection.reqId);
if (req?.type !== 'read_unit') return; if (req?.type === 'read_unit') {
if (selection.lessonUuid !== null) return; // already have one (null = "not picked yet") const lessons = unitLessonsMap[req.requirement_id]?.lessons ?? [];
const lessons = unitLessonsMap[selection.reqId]?.lessons ?? []; if (lessons.length) setSelection((p) => ({ ...p, lessonUuid: lessons[0].uuid }));
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 selectedReq = requirements.find((r) => r.requirement_id === selection?.reqId) ?? null;
const selectedLesson = (() => { const selectedLesson = (() => {
if (!selectedReq || selectedReq.type !== 'read_unit') return 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 lessons = unitLessonsMap[selectedReq.requirement_id]?.lessons ?? [];
return lessons.find((l) => l.uuid === selection?.lessonUuid) ?? null; 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 ─────────────────────────────────────────────────────── // ── Scroll tracking ───────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
window.scrollTo(0, 0); window.scrollTo(0, 0);
// Re-evaluate immediately — short content may already be at 100%
const h = document.documentElement.scrollHeight - window.innerHeight; const h = document.documentElement.scrollHeight - window.innerHeight;
setScrollPct(h <= 40 ? 100 : 0); setScrollPct(h <= 40 ? 100 : 0);
}, [selection]); }, [selection]);
useEffect(() => { useEffect(() => {
const onScroll = () => { const onScroll = () => {
const scrollH = document.documentElement.scrollHeight; const h = document.documentElement.scrollHeight - window.innerHeight;
const viewH = window.innerHeight; const y = window.scrollY;
const scrollY = window.scrollY; if (h <= 0 || h - y <= 40) { setScrollPct(100); return; }
const h = scrollH - viewH; setScrollPct(Math.min(99, Math.round((y / h) * 100)));
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)));
}; };
window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll); return () => window.removeEventListener('scroll', onScroll);
@@ -396,9 +583,10 @@ const ViewRequirement = () => {
{ label: 'Requirements' }, { label: 'Requirements' },
]; ];
// ── Selection handlers ──────────────────────────────────────────────────── // ── Handlers ─────────────────────────────────────────────────────────────
const handleSelectReq = useCallback((req) => { 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); setSidebarOpen(false);
}, []); }, []);
@@ -407,7 +595,6 @@ const ViewRequirement = () => {
setSidebarOpen(false); setSidebarOpen(false);
}, []); }, []);
// ── Mark done (marks the requirement, not individual lessons) ─────────────
const handleMarkDone = useCallback(async () => { const handleMarkDone = useCallback(async () => {
if (!selectedReq) return; if (!selectedReq) return;
const completed = !isCompleted(selectedReq.requirement_id, selectedReq.reference_id); const completed = !isCompleted(selectedReq.requirement_id, selectedReq.reference_id);
@@ -418,47 +605,43 @@ const ViewRequirement = () => {
}); });
}, [selectedReq, groupId, taskListId, taskId, isCompleted, updateLessonProgress]); }, [selectedReq, groupId, taskListId, taskId, isCompleted, updateLessonProgress]);
const selectedDone = selectedReq const selectedDone = selectedReq ? isCompleted(selectedReq.requirement_id, selectedReq.reference_id) : false;
? isCompleted(selectedReq.requirement_id, selectedReq.reference_id) const isLastContent = !nextLesson;
: 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 canMarkDone = scrollPct >= 100 && isLastContent; const canMarkDone = scrollPct >= 100 && isLastContent;
// ── Auto turn-in: fires once per requirement when user reaches the end ──────── // ── Auto turn-in ──────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!canMarkDone || selectedDone || progressLoading || !selectedReq) return; if (!canMarkDone || selectedDone || progressLoading || !selectedReq) return;
if (lockedReqs.has(selectedReq.requirement_id)) 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 ?? ''); const key = selectedReq.requirement_id + (selection?.lessonUuid ?? '');
if (lastAutoMarkRef.current === key) return; if (lastAutoMarkRef.current === key) return;
lastAutoMarkRef.current = key; lastAutoMarkRef.current = key;
handleMarkDone(); handleMarkDone();
}, [canMarkDone, selectedDone]); // eslint-disable-line react-hooks/exhaustive-deps }, [canMarkDone, selectedDone]); // eslint-disable-line react-hooks/exhaustive-deps
const handleNavigateToCourse = useCallback((courseId, opts) => {
navigate(`/course/${courseId}/unit`, { state: opts });
}, [navigate]);
const sidebarProps = { const sidebarProps = {
requirements, requirements: sidebarRequirements,
selection, selection,
onSelectReq: handleSelectReq, onSelectReq: handleSelectReq,
onSelectLesson: handleSelectLesson, onSelectLesson: handleSelectLesson,
isCompleted, isCompleted,
unitLessonsMap, unitLessonsMap,
unitLoadingMap, unitLoadingMap,
courseUnitsMap,
courseLoadingMap,
referenceMetaMap,
onNavigateToCourse: handleNavigateToCourse,
}; };
return ( return (
<> <>
<PageMeta title={task ? `${task.name} – Requirements - STARR` : undefined} /> <PageMeta title={task ? `${task.name} – Requirements - STARR` : undefined} />
{/* ── Floating "up next" (within unit lessons) ─────────────────── */}
{/* ── Floating "up next" ────────────────────────────────────────── */}
{scrollPct >= 100 && nextLesson && ( {scrollPct >= 100 && nextLesson && (
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300"> <div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div <div
@@ -531,13 +714,13 @@ const ViewRequirement = () => {
{/* ── Desktop sidebar ───────────────────────────────────────────── */} {/* ── Desktop sidebar ───────────────────────────────────────────── */}
{desktopOpen && ( {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} /> <SidebarContent {...sidebarProps} />
</div> </div>
)} )}
{/* ── Main content ──────────────────────────────────────────────── */} {/* ── 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"> <div className="relative w-full h-full max-w-3xl mx-auto">
{loading ? ( {loading ? (
<ContentSkeleton /> <ContentSkeleton />
@@ -548,48 +731,35 @@ const ViewRequirement = () => {
) : ( ) : (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Content per type */}
{selectedReq.type === 'read_course' && ( {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' && ( {selectedReq.type === 'read_unit' && (
lockedReqs.has(selectedReq.requirement_id) lockedReqs.has(selectedReq.requirement_id) ? <LockedContent /> :
? <LockedContent /> selectedLesson ? <LessonBlock lesson={selectedLesson} loading={false} /> :
: selectedLesson <ContentSkeleton />
? <LessonBlock lesson={selectedLesson} loading={false} />
: <ContentSkeleton />
)} )}
{selectedReq.type === 'read_lesson' && ( {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 */} {/* Turn-in footer — only shown while not yet completed */}
{!lockedReqs.has(selectedReq.requirement_id) && ( {!lockedReqs.has(selectedReq.requirement_id) && !selectedDone && (
<div className="flex items-center justify-between pt-4 border-t"> <div className="flex items-center pt-4 border-t">
{selectedDone ? ( {nextLesson ? (
<> <p className="text-sm text-muted-foreground">Continue reading all lessons to complete this requirement.</p>
<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>
) : !canMarkDone ? ( ) : !canMarkDone ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Scroll to the end to complete this requirement.</p>
Scroll to the end to complete this requirement.
</p>
) : ( ) : (
<span className="flex items-center gap-1.5 text-sm text-muted-foreground"> <span className="flex items-center gap-1.5 text-sm text-muted-foreground">
<RefreshCw className="size-3.5 animate-spin" /> Turning in… <RefreshCw className="size-3.5 animate-spin" /> Turning in…
+12 -4
View File
@@ -247,13 +247,14 @@ const ViewTask = () => {
fetchProgress, fetchProgress,
isVisited, isCompleted, isVisited, isCompleted,
visitLink, visitLink,
unvisitLink,
resetProgress, resetProgress,
} = useTaskProgress(); } = useTaskProgress();
const { group, fetchGroup } = useGroup(); const { group, fetchGroup } = useGroup();
const [taskModal, setTaskModal] = useState(false); 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 [note, setNote] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [previewFile, setPreviewFile] = useState(null); const [previewFile, setPreviewFile] = useState(null);
@@ -285,9 +286,13 @@ const ViewTask = () => {
await visitLink(groupId, taskListId, taskId, requirementId); await visitLink(groupId, taskListId, taskId, requirementId);
}, [groupId, taskListId, taskId, visitLink]); }, [groupId, taskListId, taskId, visitLink]);
const handleUnvisitLink = useCallback(async (requirementId) => {
await unvisitLink(groupId, taskListId, taskId, requirementId);
}, [groupId, taskListId, taskId, unvisitLink]);
// ── Submit handler ──────────────────────────────────────────────────────── // ── Submit handler ────────────────────────────────────────────────────────
const handleSubmit = async () => { const handleSubmit = async () => {
if (uploadState.isUploading || uploadState.isOverLimit) return; if (uploadState.isUploading) return;
if (!uploadState.files.length) return; if (!uploadState.files.length) return;
setSubmitting(true); setSubmitting(true);
@@ -329,7 +334,7 @@ const ViewTask = () => {
setTaskModal(false); setTaskModal(false);
setNote(''); setNote('');
setUploadState({ files: [], isUploading: false, isOverLimit: false }); setUploadState({ files: [], isUploading: false });
} catch (err) { } catch (err) {
toast.error('Failed to submit. Please try again.'); toast.error('Failed to submit. Please try again.');
} finally { } finally {
@@ -371,7 +376,6 @@ const ViewTask = () => {
disabled={ disabled={
submitting || submitting ||
uploadState.isUploading || uploadState.isUploading ||
uploadState.isOverLimit ||
uploadState.files.length === 0 uploadState.files.length === 0
} }
> >
@@ -463,6 +467,7 @@ const ViewTask = () => {
) )
} }
onVisit={handleVisitLink} onVisit={handleVisitLink}
onUnvisit={handleUnvisitLink}
/> />
)} )}
@@ -476,6 +481,9 @@ const ViewTask = () => {
description: r.description ?? '', description: r.description ?? '',
completed: isCompleted(r.requirement_id, r.reference_id), completed: isCompleted(r.requirement_id, r.reference_id),
}))} }))}
groupId={groupId}
taskListId={taskListId}
taskId={taskId}
/> />
)} )}
+66
View File
@@ -0,0 +1,66 @@
// ─── pages/Suspended.jsx ──────────────────────────────────────────────────────
import { useLocation, Link } from 'react-router-dom'
import { ShieldBan } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useDateFormat } from '@/hooks/useDateFormat'
export default function Suspended() {
const { state } = useLocation()
const { fmtDateTime } = useDateFormat()
const reason = state?.reason ?? null
const banType = state?.ban_type ?? null
const banExpiresAt = state?.ban_expires_at ?? null
const expiryText = banExpiresAt ? fmtDateTime(banExpiresAt) : null
return (
<div className="min-h-screen flex items-center justify-center bg-muted/40 px-4">
<div className="flex flex-col items-center text-center gap-6 max-w-md w-full">
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-destructive/10">
<ShieldBan className="size-8 text-destructive" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">Account Suspended</h1>
<p className="text-muted-foreground text-sm text-balance">
Your account has been suspended and you cannot access the platform at this time.
</p>
</div>
{(reason || expiryText) && (
<div className="w-full rounded-lg border bg-card p-4 text-left flex flex-col gap-3">
{reason && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium mb-0.5">Reason</p>
<p className="text-sm">{reason}</p>
</div>
)}
{banType === 'temporary' && expiryText && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium mb-0.5">Suspended Until</p>
<p className="text-sm">{expiryText}</p>
</div>
)}
{banType === 'permanent' && (
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium mb-0.5">Duration</p>
<p className="text-sm">Permanent</p>
</div>
)}
</div>
)}
<p className="text-sm text-muted-foreground">
If you believe this is a mistake, please contact your administrator.
</p>
<Button asChild variant="outline" size="sm">
<Link to="/login">Back to Login</Link>
</Button>
</div>
</div>
)
}
@@ -1,14 +1,6 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useDateFormat } from "@/hooks/useDateFormat";
function formatDate(iso) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-PH", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function InfoRow({ label, value }) { function InfoRow({ label, value }) {
return ( return (
@@ -20,6 +12,7 @@ function InfoRow({ label, value }) {
} }
export default function MemberDetailTabs({ member }) { export default function MemberDetailTabs({ member }) {
const { fmtDate } = useDateFormat();
const info = member.personal_info ?? {}; const info = member.personal_info ?? {};
const name = info.name ?? {}; const name = info.name ?? {};
const phones = info.phone_number ?? []; const phones = info.phone_number ?? [];
@@ -39,7 +32,7 @@ export default function MemberDetailTabs({ member }) {
<InfoRow label="Last name" value={name.last_name} /> <InfoRow label="Last name" value={name.last_name} />
<InfoRow label="Middle name" value={name.middle_name} /> <InfoRow label="Middle name" value={name.middle_name} />
<InfoRow label="Extension" value={name.extension_name} /> <InfoRow label="Extension" value={name.extension_name} />
<InfoRow label="Date of birth" value={formatDate(info.date_of_birth)} /> <InfoRow label="Date of birth" value={fmtDate(info.date_of_birth)} />
<InfoRow label="Occupation" value={info.occupation} /> <InfoRow label="Occupation" value={info.occupation} />
<InfoRow <InfoRow
label="Phone" label="Phone"
@@ -74,7 +67,7 @@ export default function MemberDetailTabs({ member }) {
{member.is_active ? "Active" : "Inactive"} {member.is_active ? "Active" : "Inactive"}
</span> </span>
</div> </div>
<InfoRow label="Joined group" value={formatDate(member.UserGroupMember?.joined_at)} /> <InfoRow label="Joined group" value={fmtDate(member.UserGroupMember?.joined_at)} />
</div> </div>
</TabsContent> </TabsContent>
@@ -18,14 +18,7 @@ const STATUS_LABEL = {
not_started: "Not started", not_started: "Not started",
}; };
function formatDate(iso) { import { useDateFormat } from "@/hooks/useDateFormat";
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-PH", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function StatusBadge({ status }) { function StatusBadge({ status }) {
const map = { const map = {
@@ -45,6 +38,7 @@ export default function TaskListDetail({ taskList }) {
const tasks = taskList.tasks ?? []; const tasks = taskList.tasks ?? [];
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const { fmtDate } = useDateFormat();
// ── Derived stats ─────────────────────────────────────────────────────── // ── Derived stats ───────────────────────────────────────────────────────
const counts = useMemo(() => { const counts = useMemo(() => {
@@ -151,8 +145,8 @@ export default function TaskListDetail({ taskList }) {
{/* Meta */} {/* Meta */}
<div className="text-xs text-muted-foreground space-y-1 pt-2 border-t"> <div className="text-xs text-muted-foreground space-y-1 pt-2 border-t">
<p>Created: <span className="text-foreground">{formatDate(taskList.createdAt)}</span></p> <p>Created: <span className="text-foreground">{fmtDate(taskList.createdAt)}</span></p>
<p>Assigned: <span className="text-foreground">{formatDate(taskList.TaskListGroup?.assignedAt)}</span></p> <p>Assigned: <span className="text-foreground">{fmtDate(taskList.TaskListGroup?.assignedAt)}</span></p>
</div> </div>
</TabsContent> </TabsContent>
@@ -195,7 +189,7 @@ export default function TaskListDetail({ taskList }) {
<StatusBadge status={task.status} /> <StatusBadge status={task.status} />
</TableCell> </TableCell>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{formatDate(task.due_date)} {fmtDate(task.due_date)}
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
@@ -19,17 +19,11 @@ import { PieBreakdown } from "@/components/generic/Dashboard/PieBreakdown";
import { BarBreakdown } from "@/components/generic/Dashboard/BarBreakdown"; import { BarBreakdown } from "@/components/generic/Dashboard/BarBreakdown";
import TaskListDetail from "./TaskListDetail"; import TaskListDetail from "./TaskListDetail";
function formatDate(iso) { import { useDateFormat } from "@/hooks/useDateFormat";
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-PH", {
year: "numeric",
month: "short",
day: "numeric",
});
}
export default function TaskListsTab({ taskLists = [] }) { export default function TaskListsTab({ taskLists = [] }) {
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
const { fmtDate } = useDateFormat();
// PieBreakdown: overall task completion status across all lists // PieBreakdown: overall task completion status across all lists
const completionPieData = useMemo(() => { const completionPieData = useMemo(() => {
@@ -127,7 +121,7 @@ export default function TaskListsTab({ taskLists = [] }) {
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{formatDate(tl.TaskListGroup?.assignedAt)} {fmtDate(tl.TaskListGroup?.assignedAt)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button <Button
+4 -11
View File
@@ -7,15 +7,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useStaffGroups } from "@/contexts/StaffGroupContext"; import { useStaffGroups } from "@/contexts/StaffGroupContext";
import MembersTable from "../components/members/MembersTable"; import MembersTable from "../components/members/MembersTable";
import TaskListsTab from "../components/TaskListsTab"; import TaskListsTab from "../components/TaskListsTab";
import { useDateFormat } from "@/hooks/useDateFormat";
function formatDate(iso) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-PH", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function getInitials(name = "") { function getInitials(name = "") {
const parts = name.trim().split(/\s+/); const parts = name.trim().split(/\s+/);
@@ -27,6 +19,7 @@ function getInitials(name = "") {
export default function GroupDetailPage() { export default function GroupDetailPage() {
const { groupId } = useParams(); const { groupId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { fmtDate } = useDateFormat();
const { const {
fetchGroupById, fetchGroupById,
@@ -128,8 +121,8 @@ export default function GroupDetailPage() {
</div> </div>
<div className="text-xs text-muted-foreground text-right space-y-0.5"> <div className="text-xs text-muted-foreground text-right space-y-0.5">
<p>Created <span className="text-foreground">{formatDate(group.createdAt)}</span></p> <p>Created <span className="text-foreground">{fmtDate(group.createdAt)}</span></p>
<p>Updated <span className="text-foreground">{formatDate(group.updatedAt)}</span></p> <p>Updated <span className="text-foreground">{fmtDate(group.updatedAt)}</span></p>
</div> </div>
</div> </div>
+87
View File
@@ -0,0 +1,87 @@
/**
* datetime.util.js
*
* Pure date/time formatting functions. All accept an optional options object
* with { timezone, locale }:
* timezone — 'local' (default) | 'UTC'
* locale — defaults to navigator.language (browser OS setting)
*
* These are the raw functions. In React components, use the useDateFormat()
* hook instead — it reads the user's timezone preference automatically.
*/
function tzOpt(timezone) {
return timezone === 'UTC' ? { timeZone: 'UTC' } : {};
}
function loc(locale) {
return locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en-US');
}
/** "Jun 27, 2026" */
export function fmtDate(value, { timezone = 'local', locale } = {}) {
if (!value) return '—';
return new Date(value).toLocaleDateString(loc(locale), {
month: 'short', day: 'numeric', year: 'numeric',
...tzOpt(timezone),
});
}
/** "Jun 27, 2026, 3:45 PM" */
export function fmtDateTime(value, { timezone = 'local', locale } = {}) {
if (!value) return '—';
return new Date(value).toLocaleString(loc(locale), {
month: 'short', day: 'numeric', year: 'numeric',
hour: 'numeric', minute: '2-digit',
...tzOpt(timezone),
});
}
/** "Jun 27" — no year, for compact table cells */
export function fmtDateShort(value, { timezone = 'local', locale } = {}) {
if (!value) return '—';
return new Date(value).toLocaleDateString(loc(locale), {
month: 'short', day: 'numeric',
...tzOpt(timezone),
});
}
/** "3:45 PM" */
export function fmtTime(value, { timezone = 'local', locale } = {}) {
if (!value) return '—';
return new Date(value).toLocaleTimeString(loc(locale), {
hour: 'numeric', minute: '2-digit',
...tzOpt(timezone),
});
}
/**
* "2026-06-27" — ISO date string for date-picker inputs.
* Always uses local calendar date; timezone conversion does not apply here
* because the value is used as a form input, not a display label.
*/
export function fmtISO(value) {
if (!value) return '';
const d = new Date(value);
return [
d.getFullYear(),
String(d.getMonth() + 1).padStart(2, '0'),
String(d.getDate()).padStart(2, '0'),
].join('-');
}
/** "₱1,234.00" / "$99.00" — currency formatting using browser locale */
export function fmtCurrency(value, currency = 'PHP', { locale } = {}) {
if (value === null || value === undefined) return '—';
return new Intl.NumberFormat(loc(locale), {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format(Number(value));
}
/** "1,234.00" — plain number with decimal places */
export function fmtNumber(value, { locale, minimumFractionDigits = 2 } = {}) {
if (value === null || value === undefined) return '—';
return Number(value).toLocaleString(loc(locale), { minimumFractionDigits });
}
+24
View File
@@ -0,0 +1,24 @@
import { getTierColor } from './tierColors';
/**
* Resolves badge + panel colors for a tier slug using the live tierMap from the API.
* The category's stored `color` key drives all styling — no rank-indexed arrays.
*/
export function resolveTierBadge(slug, tierMap = {}) {
const info = tierMap[slug];
const colorKey = info?.color ?? ((!slug || slug === 'free') ? 'green' : 'purple');
const colors = getTierColor(colorKey);
const rank = info?.rank ?? 0;
const label = info?.name ?? (slug ? slug.charAt(0).toUpperCase() + slug.slice(1) : 'Free');
return { rank, label, cls: colors.badge, panel: colors.panel, colorKey };
}
/** Returns badge Tailwind class string for a stored color key. */
export function tierBadgeClass(colorKey = 'green') {
return getTierColor(colorKey).badge;
}
/** Returns panel Tailwind classes { bg, border } for a stored color key. */
export function tierPanelColors(colorKey = 'green') {
return getTierColor(colorKey).panel;
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Official Tailwind color palette for tier categories.
*
* Each key is stored in tier_categories.color (DB).
* badge — classes applied to <Badge> components
* panel — bg + border classes for upsell/info panels
* swatch — hex for the color picker dot in the admin UI
*/
export const TIER_COLOR_MAP = {
green: {
label: "Green",
swatch: "#22c55e",
badge: "bg-green-500 text-white border-0",
panel: { bg: "bg-green-50 dark:bg-green-950/30", border: "border-green-200 dark:border-green-800" },
},
purple: {
label: "Purple",
swatch: "#a855f7",
badge: "bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white border-0",
panel: { bg: "bg-fuchsia-50 dark:bg-fuchsia-950/30", border: "border-fuchsia-200 dark:border-fuchsia-800" },
},
rose: {
label: "Rose",
swatch: "#f43f5e",
badge: "bg-gradient-to-r from-rose-500 to-red-600 text-white border-0",
panel: { bg: "bg-rose-50 dark:bg-rose-950/30", border: "border-rose-200 dark:border-rose-800" },
},
amber: {
label: "Amber",
swatch: "#f59e0b",
badge: "bg-gradient-to-r from-amber-400 to-orange-500 text-white border-0",
panel: { bg: "bg-amber-50 dark:bg-amber-950/30", border: "border-amber-200 dark:border-amber-800" },
},
sky: {
label: "Sky",
swatch: "#0ea5e9",
badge: "bg-gradient-to-r from-sky-500 to-blue-600 text-white border-0",
panel: { bg: "bg-sky-50 dark:bg-sky-950/30", border: "border-sky-200 dark:border-sky-800" },
},
indigo: {
label: "Indigo",
swatch: "#6366f1",
badge: "bg-gradient-to-r from-indigo-500 to-violet-600 text-white border-0",
panel: { bg: "bg-indigo-50 dark:bg-indigo-950/30", border: "border-indigo-200 dark:border-indigo-800" },
},
teal: {
label: "Teal",
swatch: "#14b8a6",
badge: "bg-gradient-to-r from-teal-500 to-emerald-600 text-white border-0",
panel: { bg: "bg-teal-50 dark:bg-teal-950/30", border: "border-teal-200 dark:border-teal-800" },
},
orange: {
label: "Orange",
swatch: "#f97316",
badge: "bg-gradient-to-r from-orange-500 to-red-500 text-white border-0",
panel: { bg: "bg-orange-50 dark:bg-orange-950/30", border: "border-orange-200 dark:border-orange-800" },
},
pink: {
label: "Pink",
swatch: "#ec4899",
badge: "bg-gradient-to-r from-pink-500 to-rose-500 text-white border-0",
panel: { bg: "bg-pink-50 dark:bg-pink-950/30", border: "border-pink-200 dark:border-pink-800" },
},
cyan: {
label: "Cyan",
swatch: "#06b6d4",
badge: "bg-gradient-to-r from-cyan-500 to-sky-500 text-white border-0",
panel: { bg: "bg-cyan-50 dark:bg-cyan-950/30", border: "border-cyan-200 dark:border-cyan-800" },
},
lime: {
label: "Lime",
swatch: "#84cc16",
badge: "bg-gradient-to-r from-lime-500 to-green-600 text-white border-0",
panel: { bg: "bg-lime-50 dark:bg-lime-950/30", border: "border-lime-200 dark:border-lime-800" },
},
slate: {
label: "Slate",
swatch: "#64748b",
badge: "bg-gradient-to-r from-slate-600 to-slate-800 text-white border-0",
panel: { bg: "bg-slate-50 dark:bg-slate-950/30", border: "border-slate-200 dark:border-slate-700" },
},
};
/** Ordered list for the color picker UI. */
export const TIER_COLOR_OPTIONS = Object.entries(TIER_COLOR_MAP).map(([key, val]) => ({
key,
label: val.label,
swatch: val.swatch,
}));
/** Fallback when a stored color key is not in the map. */
const FALLBACK = TIER_COLOR_MAP.purple;
/**
* Returns the color definition for a stored color key.
* Falls back to purple for unknown keys.
*/
export function getTierColor(colorKey) {
return TIER_COLOR_MAP[colorKey] ?? FALLBACK;
}