+8
-5
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { AuthProvider, useAuth, decodeToken } from './contexts/AuthContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { DateTimePreferenceProvider } from './contexts/DateTimePreferenceContext';
|
||||
import { Helmet, HelmetProvider } from "react-helmet-async";
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
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" />
|
||||
</Helmet>
|
||||
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<AuthProvider>
|
||||
<AppWithAuth />
|
||||
</AuthProvider>
|
||||
</TooltipProvider>
|
||||
<DateTimePreferenceProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<AuthProvider>
|
||||
<AppWithAuth />
|
||||
</AuthProvider>
|
||||
</TooltipProvider>
|
||||
</DateTimePreferenceProvider>
|
||||
</ThemeProvider>
|
||||
</HelmetProvider>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Search, CheckCircle2 } from "lucide-react";
|
||||
import { useEffect, useState, useCallback, useRef } from "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 { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
|
||||
const DEBOUNCE_MS = 400;
|
||||
|
||||
function AssetCard({ asset, selected, onSelect }) {
|
||||
const thumb = asset.thumbnail_url ?? asset.file_url;
|
||||
const EXT_OPTIONS = {
|
||||
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 (
|
||||
<button
|
||||
@@ -28,18 +35,12 @@ function AssetCard({ asset, selected, onSelect }) {
|
||||
className={[
|
||||
"relative rounded-lg border-2 overflow-hidden transition-all text-left w-full",
|
||||
"hover:border-primary/60 hover:shadow-sm",
|
||||
selected
|
||||
? "border-primary ring-2 ring-primary/20"
|
||||
: "border-border",
|
||||
selected ? "border-primary ring-2 ring-primary/20" : "border-border",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="aspect-video bg-muted w-full overflow-hidden">
|
||||
{thumb ? (
|
||||
<img
|
||||
src={thumb}
|
||||
alt={asset.display_name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<img src={thumb} alt={asset.display_name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground">No preview</span>
|
||||
@@ -48,6 +49,9 @@ function AssetCard({ asset, selected, onSelect }) {
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<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>
|
||||
{selected && (
|
||||
<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 }) {
|
||||
if (!open) return null;
|
||||
|
||||
const { fetchAssets, assets, pagination, loading } = useAssets();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [committed, setCommitted] = useState(""); // ← only updates on search trigger
|
||||
const [page, setPage] = useState(1);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeExts, setActiveExts] = useState(new Set());
|
||||
const [page, setPage] = useState(1);
|
||||
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 triggerSearch = useCallback(() => {
|
||||
setCommitted(search);
|
||||
setPage(1);
|
||||
}, [search]);
|
||||
const extOptions = EXT_OPTIONS[fileType] ?? [];
|
||||
|
||||
// ── 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(() => {
|
||||
if (!open) return;
|
||||
fetchAssets({
|
||||
page,
|
||||
limit: LIMIT,
|
||||
filters: [
|
||||
...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
|
||||
...(committed ? [{ id: "display_name", value: [committed] }] : []),
|
||||
],
|
||||
});
|
||||
}, [open, page, committed, fileType]);
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setPage(1);
|
||||
doFetch(search, activeExts, 1);
|
||||
}, DEBOUNCE_MS);
|
||||
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);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [assets]);
|
||||
|
||||
// ── Reset on close ────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch("");
|
||||
setCommitted("");
|
||||
setActiveExts(new Set());
|
||||
setPage(1);
|
||||
setSelected(null);
|
||||
setFilterOpen(false);
|
||||
setStreamUrls({});
|
||||
}
|
||||
}, [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) => {
|
||||
setSelected(asset.asset_id);
|
||||
onSelect(asset);
|
||||
@@ -107,6 +185,8 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
|
||||
? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s`
|
||||
: "Assets";
|
||||
|
||||
const hasActiveFilters = activeExts.size > 0;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<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 ── */}
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b">
|
||||
<SheetTitle>Select {label}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Click an asset to attach it.
|
||||
</SheetDescription>
|
||||
<SheetDescription>Click an asset to attach it.</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{/* ── Search ── */}
|
||||
<div className="px-6 py-3 border-b">
|
||||
{/* ── Search + Filter ── */}
|
||||
<div className="px-6 py-3 border-b space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<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()}…`}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && triggerSearch()}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={triggerSearch}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <Spinner className="h-4 w-4" /> : "Search"}
|
||||
</Button>
|
||||
|
||||
{extOptions.length > 0 && (
|
||||
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={hasActiveFilters ? "default" : "outline"}
|
||||
size="icon"
|
||||
className="relative shrink-0"
|
||||
>
|
||||
<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>
|
||||
</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 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>
|
||||
|
||||
{/* ── Grid ── */}
|
||||
@@ -156,6 +290,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
|
||||
<AssetCard
|
||||
key={asset.asset_id}
|
||||
asset={asset}
|
||||
streamSrc={streamUrls[String(asset.asset_id)] ?? null}
|
||||
selected={selected === asset.asset_id}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
const TYPE_ICON = {
|
||||
achievement: Trophy,
|
||||
@@ -31,20 +32,10 @@ function timeAgo(dateStr) {
|
||||
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() {
|
||||
const { notifications, unseenCount, loading, fetchNotifications, markSeen, markAllSeen } =
|
||||
useClientNotifications();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [copiedCode, setCopiedCode] = useState(false);
|
||||
@@ -79,15 +70,16 @@ export default function ClientNotificationBell() {
|
||||
</PopoverTrigger>
|
||||
|
||||
<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>
|
||||
{unseenCount > 0 && (
|
||||
<button
|
||||
<Button
|
||||
onClick={markAllSeen}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -186,7 +178,7 @@ export default function ClientNotificationBell() {
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="capitalize">{selected?.type}</span>
|
||||
<span>{selected ? formatDate(selected.createdAt) : ""}</span>
|
||||
<span>{selected ? fmtDateTime(selected.createdAt) : ""}</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
const TYPE_ICON = {
|
||||
task_overdue: AlertCircle,
|
||||
@@ -29,20 +30,10 @@ function timeAgo(dateStr) {
|
||||
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() {
|
||||
const { notifications, unseenCount, loading, fetchNotifications, markSeen, markAllSeen } =
|
||||
useAdminNotifications();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
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">
|
||||
<span className="capitalize">{selected?.type?.replace(/_/g, ' ')}</span>
|
||||
<span>{selected ? formatDate(selected.createdAt) : ""}</span>
|
||||
<span>{selected ? fmtDateTime(selected.createdAt) : ""}</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 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.labelKey] Key for the display name. Default: "full_name"
|
||||
* @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[]
|
||||
*
|
||||
@@ -63,6 +66,7 @@ export function AddSheet({
|
||||
idKey = "user_id",
|
||||
labelKey = "full_name",
|
||||
subLabelKey = null,
|
||||
warningKey = null,
|
||||
|
||||
onSubmit,
|
||||
}) {
|
||||
@@ -102,6 +106,14 @@ export function AddSheet({
|
||||
const allFilteredSelected =
|
||||
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() {
|
||||
if (!selected.length) return;
|
||||
await onSubmit(selected);
|
||||
@@ -166,9 +178,10 @@ export function AddSheet({
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((user) => {
|
||||
const id = user[idKey];
|
||||
const label = user[labelKey];
|
||||
const sub = subLabelKey ? user[subLabelKey] : null;
|
||||
const id = user[idKey];
|
||||
const label = user[labelKey];
|
||||
const sub = subLabelKey ? user[subLabelKey] : null;
|
||||
const warning = warningKey ? user[warningKey] : null;
|
||||
|
||||
return (
|
||||
<label
|
||||
@@ -180,13 +193,19 @@ export function AddSheet({
|
||||
checked={selected.includes(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>
|
||||
{sub && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{sub}
|
||||
</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>
|
||||
</label>
|
||||
);
|
||||
@@ -197,17 +216,25 @@ export function AddSheet({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="shrink-0 flex gap-2 border-t px-6 py-4">
|
||||
<Button variant="outline" className="flex-1" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!selected.length || loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{selected.length > 0 ? `${submitLabel} (${selected.length})` : submitLabel}
|
||||
</Button>
|
||||
<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}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!selected.length || loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{selected.length > 0 ? `${submitLabel} (${selected.length})` : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</SheetContent>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -14,14 +15,12 @@ const FIELD_DISPLAY_MAP = {
|
||||
const BOOLEAN_FIELDS = Object.keys(FIELD_DISPLAY_MAP);
|
||||
|
||||
// ─── Generic formatter ────────────────────────────────────────────────────────
|
||||
const formatFilterItem = (item, field, type) => {
|
||||
const formatFilterItem = (item, field, type, fmtDate) => {
|
||||
if (FIELD_DISPLAY_MAP[field]) {
|
||||
return FIELD_DISPLAY_MAP[field][String(item)] ?? item;
|
||||
}
|
||||
if (type === "date" && item) {
|
||||
return new Date(item).toLocaleDateString("en-US", {
|
||||
year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
return fmtDate(item);
|
||||
}
|
||||
return item;
|
||||
};
|
||||
@@ -35,6 +34,7 @@ const EmptyState = ({ search }) => (
|
||||
|
||||
// ─── Reusable item list renderer ──────────────────────────────────────────────
|
||||
const FilterList = ({ items, field, type, selected, onToggle, inputType = "checkbox" }) => {
|
||||
const { fmtDate } = useDateFormat();
|
||||
if (items.length === 0) return <EmptyState />;
|
||||
|
||||
return items.map((item) => (
|
||||
@@ -45,7 +45,7 @@ const FilterList = ({ items, field, type, selected, onToggle, inputType = "check
|
||||
checked={selected.includes(String(item))}
|
||||
onChange={() => onToggle(item)}
|
||||
/>
|
||||
{formatFilterItem(item, field, type)}
|
||||
{formatFilterItem(item, field, type, fmtDate)}
|
||||
</label>
|
||||
));
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { useProfile } from '@/contexts/ProfileProvider'
|
||||
import { useDateTimePreference } from '@/contexts/DateTimePreferenceContext'
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -11,7 +12,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem,
|
||||
import { Dialog, DialogContent } from '@/components/ui/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'
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
function SettingsDialog({ open, onClose }) {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const { timezone, setTimezone } = useDateTimePreference()
|
||||
const [tab, setTab] = useState('appearance')
|
||||
|
||||
const TABS = [
|
||||
{ id: 'appearance', label: 'Appearance', icon: Sun },
|
||||
{ id: 'datetime', label: 'Date & Time', icon: Clock },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -100,6 +123,45 @@ function SettingsDialog({ open, onClose }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date & Time */}
|
||||
{tab === 'datetime' && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Date & 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'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 & 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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ export function AdminTiersProvider({ children }) {
|
||||
const [userTiers, setUserTiers] = useState([]);
|
||||
const [planAttributes, setPlanAttributes] = useState([]);
|
||||
const [paymentAttributes, setPaymentAttributes] = useState([]);
|
||||
const [planCourses, setPlanCourses] = useState([]);
|
||||
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 [loading, setLoading] = useState(false);
|
||||
@@ -183,28 +182,6 @@ export function AdminTiersProvider({ children }) {
|
||||
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 (
|
||||
<AdminTiersContext.Provider value={{
|
||||
// State
|
||||
@@ -228,8 +205,6 @@ const syncPlanCourses = useCallback(async (planId, courseIds) => {
|
||||
// Payment actions
|
||||
fetchPayments, fetchPayment,
|
||||
|
||||
// Course plans
|
||||
planCourses, fetchPlanCourses, syncPlanCourses,
|
||||
}}>
|
||||
{children}
|
||||
</AdminTiersContext.Provider>
|
||||
|
||||
@@ -34,6 +34,8 @@ export const UserProvider = ({ children }) => {
|
||||
const [activity, setActivity] = useState([]);
|
||||
const [activityPagination, setActivityPagination] = useState(PAGINATION_INIT);
|
||||
const [activityLoading, setActivityLoading] = useState(false);
|
||||
const [bans, setBans] = useState([]);
|
||||
const [bansLoading, setBansLoading] = useState(false);
|
||||
|
||||
const request = useCallback(async (fn) => {
|
||||
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 (
|
||||
<UserContext.Provider value={{
|
||||
users, user, sessions, achievements, achievementsLoading, pagination, attributes, loading, error,
|
||||
activity, activityPagination, activityLoading,
|
||||
bans, bansLoading,
|
||||
setPagination,
|
||||
fetchUsers, fetchArchivedUsers, fetchUser,
|
||||
addStaffUser, updateUser,
|
||||
@@ -302,6 +384,7 @@ export const UserProvider = ({ children }) => {
|
||||
fetchUserFieldValues,
|
||||
fetchUserAchievements,
|
||||
fetchActivity, fetchUserActivity,
|
||||
banUser, unbanUser, bulkBanUsers, bulkUnbanUsers, fetchUserBans,
|
||||
}}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
|
||||
@@ -33,8 +33,9 @@ export function AuthProvider({ children }) {
|
||||
return { success: true, user: data.data.user }
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.message || 'Login failed. Please try again.'
|
||||
const errors = err.response?.data?.errors ?? null
|
||||
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
|
||||
* (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)
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
@@ -26,8 +29,10 @@ export function useCourseReadingProgress() {
|
||||
|
||||
export function CourseReadingProgressProvider({ children }) {
|
||||
// { [reference_id]: 'in_progress' | 'completed' }
|
||||
const [progressMap, setProgressMap] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [progressMap, setProgressMap] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Tasks whose all read requirements just became complete — consumed by UnitList for toasts
|
||||
const [completedTasks, setCompletedTasks] = useState([]);
|
||||
|
||||
// ─── Lookup helpers ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -43,8 +48,6 @@ export function CourseReadingProgressProvider({ children }) {
|
||||
|
||||
// ─── 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) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -64,11 +67,6 @@ export function CourseReadingProgressProvider({ children }) {
|
||||
|
||||
// ─── 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) => {
|
||||
// Optimistic update — lesson only
|
||||
setProgressMap((prev) => ({ ...prev, [lessonUuid]: status }));
|
||||
@@ -90,6 +88,11 @@ export function CourseReadingProgressProvider({ children }) {
|
||||
return next;
|
||||
});
|
||||
|
||||
// Signal any tasks that just had all read requirements completed
|
||||
if (result.completed_tasks?.length) {
|
||||
setCompletedTasks(result.completed_tasks);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
// 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 ──────────────────────────────────
|
||||
|
||||
const resetProgress = useCallback(() => setProgressMap({}), []);
|
||||
const resetProgress = useCallback(() => {
|
||||
setProgressMap({});
|
||||
setCompletedTasks([]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CourseReadingProgressContext.Provider value={{
|
||||
@@ -115,6 +125,8 @@ export function CourseReadingProgressProvider({ children }) {
|
||||
isCompleted,
|
||||
fetchCourseProgress,
|
||||
upsertLessonProgress,
|
||||
completedTasks,
|
||||
clearCompletedTasks,
|
||||
resetProgress,
|
||||
}}>
|
||||
{children}
|
||||
|
||||
@@ -146,6 +146,12 @@ export function ClientCoursesProvider({ children }) {
|
||||
} 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) => {
|
||||
try {
|
||||
const { data } = await api.get(`/client/courses/${courseId}/assessment/${assessmentId}/session`);
|
||||
@@ -239,6 +245,7 @@ export function ClientCoursesProvider({ children }) {
|
||||
getCourseAssessment,
|
||||
startCourseAssessment,
|
||||
saveDraft,
|
||||
saveQuizDraft,
|
||||
refreshAssessmentSession,
|
||||
submitUnitQuiz,
|
||||
submitCourseAssessment,
|
||||
|
||||
@@ -137,6 +137,29 @@ export function TaskProgressProvider({ children }) {
|
||||
[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)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
@@ -238,6 +261,7 @@ export function TaskProgressProvider({ children }) {
|
||||
// ── Actions ───────────────────────────────────────────────────────
|
||||
fetchProgress,
|
||||
visitLink,
|
||||
unvisitLink,
|
||||
updateLessonProgress,
|
||||
resetProgress,
|
||||
}}>
|
||||
|
||||
@@ -24,6 +24,13 @@ export function ClientTiersProvider({ children }) {
|
||||
const [payments, setPayments] = useState([]);
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
const resetMyTier = useCallback(() => setMyTier(null), []);
|
||||
@@ -132,6 +163,8 @@ export function ClientTiersProvider({ children }) {
|
||||
plans, plansLoading,
|
||||
checkoutLoading,
|
||||
payments, paymentsLoading,
|
||||
systemBadges, systemBadgesLoading,
|
||||
tierCategories, tierMap,
|
||||
|
||||
// actions
|
||||
getMyTier,
|
||||
@@ -141,6 +174,8 @@ export function ClientTiersProvider({ children }) {
|
||||
captureOrder,
|
||||
cancelOrder,
|
||||
getMyPayments,
|
||||
getSystemBadges,
|
||||
getTierCategories,
|
||||
|
||||
// resets
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
} from '@/components/ui/pagination';
|
||||
import { useAdminCourseReadingProgress } from '@/contexts/AdminCourseReadingProgressContext';
|
||||
import { useDateFormat } from '@/hooks/useDateFormat';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
@@ -64,6 +65,7 @@ function UserAvatar({ name, email, avatarUrl }) {
|
||||
|
||||
function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
|
||||
const { detailCache, detailLoading, fetchUserReadingProgress } = useAdminCourseReadingProgress();
|
||||
const { fmtDate, fmtDateShort } = useDateFormat();
|
||||
const breakdown = entry ? detailCache[entry.user_id] : null;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -72,9 +74,7 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
|
||||
}
|
||||
}, [open, entry]);
|
||||
|
||||
const lastSeen = entry?.last_accessed_at
|
||||
? 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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -154,7 +154,7 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
|
||||
</span>
|
||||
{lesson.status === 'completed' && lesson.completed_at && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -178,9 +178,8 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
|
||||
// ─── User summary card ────────────────────────────────────────────────────────
|
||||
|
||||
function UserCard({ entry, onOpen }) {
|
||||
const lastSeen = entry.last_accessed_at
|
||||
? new Date(entry.last_accessed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—';
|
||||
const { fmtDate } = useDateFormat();
|
||||
const lastSeen = entry.last_accessed_at ? fmtDate(entry.last_accessed_at) : '—';
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function UnitsTable({ courseId }) {
|
||||
|
||||
const rowActions = useMemo(() => buildRowActions({
|
||||
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`),
|
||||
onView: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/view`),
|
||||
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 { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import api from "@/utils/api.util";
|
||||
import DataTable from "@/components/generic/Table/DataTable";
|
||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||
import { buildDataColumns, columnPinning } from "../../config/tiers/payments/columns.config";
|
||||
@@ -18,6 +20,7 @@ export default function PaymentsTable({ planId = null }) {
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const {
|
||||
payments, paymentAttributes,
|
||||
@@ -25,6 +28,13 @@ export default function PaymentsTable({ planId = null }) {
|
||||
loading, fetchPayments,
|
||||
} = 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 = {
|
||||
allData: payments,
|
||||
attributes: paymentAttributes,
|
||||
@@ -51,8 +61,8 @@ export default function PaymentsTable({ planId = null }) {
|
||||
});
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildDataColumns(paymentAttributes, rowActions),
|
||||
[paymentAttributes]
|
||||
() => buildDataColumns(paymentAttributes, rowActions, fmtDateTime, tierMap),
|
||||
[paymentAttributes, fmtDateTime, tierMap]
|
||||
);
|
||||
|
||||
// Pass planId as a locked filter to DataTable's onFetch
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo, useRef, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMemo, useRef, useState, useCallback, useEffect } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { Layers } from "lucide-react";
|
||||
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
import DataTable from "@/components/generic/Table/DataTable";
|
||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||
@@ -24,6 +26,17 @@ export default function TierPlansTable() {
|
||||
bulkDeletePlans, bulkRestorePlans,
|
||||
} = 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 [archiveTarget, setArchiveTarget] = useState(null);
|
||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||
@@ -82,14 +95,15 @@ export default function TierPlansTable() {
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
fetchPlans,
|
||||
pagination: planPagination,
|
||||
pagination: planPagination,
|
||||
exportConfig,
|
||||
navigate,
|
||||
showArchived,
|
||||
onToggleArchived: handleToggleArchived,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
hasAvailableCategories,
|
||||
onToggleArchived: handleToggleArchived,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const selectionActions = buildSelectionActions({
|
||||
@@ -108,6 +122,18 @@ export default function TierPlansTable() {
|
||||
|
||||
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
|
||||
title="Tier Plans"
|
||||
data={plans}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useDashboard } from "@/contexts/AdminDashboardContext";
|
||||
import DataTable from "@/components/generic/Table/DataTable";
|
||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||
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 { buildDataColumns, columnPinning } from "../../config/users/columns.config";
|
||||
@@ -22,6 +24,10 @@ import { getTimestamp } from "@/utils/timestamp.util";
|
||||
export default function UsersTable() {
|
||||
const [archiveTarget, setArchiveTarget] = 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({
|
||||
getFilters: () => [],
|
||||
@@ -35,6 +41,7 @@ export default function UsersTable() {
|
||||
const {
|
||||
users, attributes, pagination, setPagination, loading,
|
||||
fetchUsers, fetchUserFieldValues, deactivateUser, deactivateUsers,
|
||||
banUser, unbanUser, bulkBanUsers, bulkUnbanUsers,
|
||||
} = useUsers();
|
||||
|
||||
const { usersDashboard, fetchUsersDashboard } = useDashboard();
|
||||
@@ -55,7 +62,12 @@ export default function UsersTable() {
|
||||
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({
|
||||
fetchUsers, pagination, exportConfig, navigate,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
@@ -64,8 +76,10 @@ export default function UsersTable() {
|
||||
});
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
archiveUser: (row) => setArchiveTarget(row),
|
||||
archiveUser: (row) => setArchiveTarget(row),
|
||||
archiveUsers: (ids) => setArchiveIds(ids),
|
||||
banUsers: (ids) => setBanIds(ids),
|
||||
unbanUsers: (ids) => setUnbanIds(ids),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
@@ -79,6 +93,20 @@ export default function UsersTable() {
|
||||
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
|
||||
// knows which column/value to apply when clicked ──────────────────────
|
||||
const dashboardStats = (usersDashboard?.stats ?? []).map((s) => ({ // ← was dashboard?.users?.stats
|
||||
@@ -167,6 +195,52 @@ export default function UsersTable() {
|
||||
loading={loading}
|
||||
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 { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
|
||||
export const columnPinning = {
|
||||
right: ["actions"],
|
||||
@@ -17,39 +18,35 @@ const STATUS_BADGE = {
|
||||
refunded: "outline",
|
||||
};
|
||||
|
||||
const TIER_BADGE = { premium: "default", exclusive: "destructive" };
|
||||
|
||||
const cellOverrides = {
|
||||
status: (info) => (
|
||||
<Badge variant={STATUS_BADGE[info.getValue()] ?? "outline"} className="capitalize">
|
||||
{info.getValue()}
|
||||
</Badge>
|
||||
),
|
||||
amount: (info) => {
|
||||
const row = info.row.original;
|
||||
return (
|
||||
<span className="text-sm font-medium">
|
||||
{row.currency} {Number(info.getValue()).toFixed(2)}
|
||||
export function buildDataColumns(attributes, rowActions, fmtDateTime = (v) => v ? new Date(v).toLocaleString() : "—", tierMap = {}) {
|
||||
const cellOverrides = {
|
||||
status: (info) => (
|
||||
<Badge variant={STATUS_BADGE[info.getValue()] ?? "outline"} className="capitalize">
|
||||
{info.getValue()}
|
||||
</Badge>
|
||||
),
|
||||
amount: (info) => {
|
||||
const row = info.row.original;
|
||||
return (
|
||||
<span className="text-sm font-medium">
|
||||
{row.currency} {Number(info.getValue()).toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
"plan.tier": (info) => {
|
||||
const { cls, label } = resolveTierBadge(info.getValue(), tierMap);
|
||||
return <Badge className={`${cls} capitalize`}>{label}</Badge>;
|
||||
},
|
||||
paid_at: (info) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{fmtDateTime(info.getValue())}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
"plan.tier": (info) => (
|
||||
<Badge variant={TIER_BADGE[info.getValue()] ?? "outline"} className="capitalize">
|
||||
{info.getValue()}
|
||||
</Badge>
|
||||
),
|
||||
paid_at: (info) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{info.getValue() ? new Date(info.getValue()).toLocaleString() : "—"}
|
||||
</span>
|
||||
),
|
||||
"user.email": (info) => (
|
||||
<span className="text-sm">{info.getValue() ?? "—"}</span>
|
||||
),
|
||||
),
|
||||
"user.email": (info) => (
|
||||
<span className="text-sm">{info.getValue() ?? "—"}</span>
|
||||
),
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||
return [
|
||||
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";
|
||||
|
||||
export function buildToolbarActions({
|
||||
@@ -7,6 +7,7 @@ export function buildToolbarActions({
|
||||
exportConfig,
|
||||
navigate,
|
||||
showArchived,
|
||||
hasAvailableCategories,
|
||||
onToggleArchived,
|
||||
getFilters,
|
||||
getSort,
|
||||
@@ -38,13 +39,21 @@ export function buildToolbarActions({
|
||||
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",
|
||||
type: "button",
|
||||
label: "New Plan",
|
||||
icon: <Plus className="h-3.5 w-3.5" />,
|
||||
variant: "default",
|
||||
hidden: showArchived,
|
||||
hidden: showArchived || !hasAvailableCategories,
|
||||
onClick: () => navigate("/admin/tiers/plans/add"),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
// modules/admin/config/user_groups/view/rowActions.config.jsx
|
||||
|
||||
import { UserMinus } from "lucide-react";
|
||||
import { UserMinus, UserCheck } from "lucide-react";
|
||||
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
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 [
|
||||
{
|
||||
key: "remove",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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";
|
||||
|
||||
/**
|
||||
@@ -8,9 +8,11 @@ import { exportTableToExcel } from "@/utils/excel.util";
|
||||
* @param {Object} deps.exportConfig { allData, attributes, filename, sheetName }
|
||||
* @param {Function} deps.onRemoveMember Opens single remove dialog (row)
|
||||
* @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 }) {
|
||||
return [
|
||||
export function buildSelectionActions({ exportConfig, onRemoveMember, onRemoveMembers, onAssignMembers, isNoGroup }) {
|
||||
const actions = [
|
||||
{
|
||||
key: "export-selected",
|
||||
label: "Export",
|
||||
@@ -18,7 +20,17 @@ export function buildSelectionActions({ exportConfig, onRemoveMember, onRemoveMe
|
||||
onClick: (rows, 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",
|
||||
label: "Remove",
|
||||
icon: <UserMinus className="h-3.5 w-3.5" />,
|
||||
@@ -26,9 +38,11 @@ export function buildSelectionActions({ exportConfig, onRemoveMember, onRemoveMe
|
||||
onClick: (rows) => {
|
||||
const ids = rows.map((r) => r.user_id);
|
||||
ids.length === 1
|
||||
? onRemoveMember(rows[0]) // single confirm dialog
|
||||
: onRemoveMembers(ids); // bulk confirm dialog
|
||||
? onRemoveMember(rows[0])
|
||||
: onRemoveMembers(ids);
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
@@ -3,15 +3,17 @@
|
||||
//
|
||||
// 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 {Function} deps.navigate React Router navigate
|
||||
* @param {Function} deps.archiveUser Archive handler from useManagement
|
||||
* @param {Function} deps.navigate React Router navigate
|
||||
* @param {Function} deps.onArchive Opens archive dialog
|
||||
* @param {Function} deps.onBan Opens ban dialog
|
||||
* @param {Function} deps.onUnban Opens unban dialog
|
||||
* @returns {Array} rowActions
|
||||
*/
|
||||
export function buildRowActions({ navigate, onArchive }) {
|
||||
export function buildRowActions({ navigate, onArchive, onBan, onUnban }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
@@ -26,14 +28,31 @@ export function buildRowActions({ navigate, onArchive }) {
|
||||
onClick: (row) => navigate(`edit/${row.user_id}`),
|
||||
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",
|
||||
label: "Archive",
|
||||
className: "text-destructive focus:text-destructive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onArchive(row), // ← opens dialog, not deactivateUser
|
||||
hidden: (row) => !row.is_active, // ← hide if already inactive
|
||||
separator: true,
|
||||
onClick: (row) => onArchive(row),
|
||||
hidden: (row) => !row.is_active,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
// 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";
|
||||
|
||||
/**
|
||||
* @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 }) {
|
||||
export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers, banUsers, unbanUsers, getTableInstance }) {
|
||||
return [
|
||||
{
|
||||
key: "export-selected",
|
||||
@@ -17,6 +11,27 @@ export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers,
|
||||
onClick: (rows, table) =>
|
||||
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",
|
||||
label: "Archive",
|
||||
@@ -25,8 +40,8 @@ export function buildSelectionActions({ exportConfig, archiveUser, archiveUsers,
|
||||
onClick: (rows) => {
|
||||
const ids = rows.map((r) => r.user_id);
|
||||
ids.length === 1
|
||||
? archiveUser(rows[0]) // opens single dialog
|
||||
: archiveUsers(ids); // opens bulk dialog
|
||||
? archiveUser(rows[0])
|
||||
: archiveUsers(ids);
|
||||
},
|
||||
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 { timeAgo } from "@/utils/timestamp.util";
|
||||
import { fmtISO } from "@/utils/datetime.util";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
const BREADCRUMB = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
@@ -25,7 +27,7 @@ const LIMIT = 20;
|
||||
|
||||
function toDateStr(d) {
|
||||
if (!d) return undefined;
|
||||
return d.toLocaleDateString("en-CA"); // YYYY-MM-DD
|
||||
return fmtISO(d);
|
||||
}
|
||||
|
||||
export default function ActivityFeed() {
|
||||
@@ -196,10 +198,9 @@ export default function ActivityFeed() {
|
||||
// ─── DatePickerButton ─────────────────────────────────────────────────────────
|
||||
function DatePickerButton({ value, onChange, placeholder, disabled }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
const label = value
|
||||
? value.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
||||
: placeholder;
|
||||
const label = value ? fmtDate(value) : placeholder;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -252,6 +253,7 @@ function initials(name, email) {
|
||||
}
|
||||
|
||||
function ActivityRow({ row, onViewUser }) {
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
const { label, className } = getActionBadge(row.action);
|
||||
const ts = row.created_at;
|
||||
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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{new Date(ts).toLocaleString("en-US", {
|
||||
month: "short", day: "numeric", year: "numeric",
|
||||
hour: "numeric", minute: "2-digit", second: "2-digit",
|
||||
})}
|
||||
{fmtDateTime(ts)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
|
||||
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
|
||||
import { timeAgo } from "@/utils/timestamp.util";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
@@ -156,6 +157,7 @@ export default function UserActivityPage() {
|
||||
|
||||
// ─── Item ──────────────────────────────────────────────────────────────────────
|
||||
function ActivityItem({ row }) {
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
const { label, className } = getActionBadge(row.action);
|
||||
const ts = row.created_at;
|
||||
|
||||
@@ -188,10 +190,7 @@ function ActivityItem({ row }) {
|
||||
<span className="text-muted-foreground cursor-default">{timeAgo(ts)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{new Date(ts).toLocaleString("en-US", {
|
||||
month: "short", day: "numeric", year: "numeric",
|
||||
hour: "numeric", minute: "2-digit", second: "2-digit",
|
||||
})}
|
||||
{fmtDateTime(ts)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2 } from
|
||||
|
||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -162,6 +163,7 @@ function StatCard({ label, value, tone = "default" }) {
|
||||
// ─── Advertisement card ─────────────────────────────────────────────────────
|
||||
|
||||
function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
|
||||
const { fmtDate } = useDateFormat();
|
||||
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
|
||||
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
|
||||
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 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 (
|
||||
<div className={`bg-background rounded-lg border overflow-hidden flex flex-col ${isDimmed ? "opacity-70" : ""}`}>
|
||||
@@ -255,12 +257,10 @@ function EmptyState({ onCreate }) {
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDateRange(start, end) {
|
||||
function formatDateRange(start, end, fmtDate) {
|
||||
if (!start && !end) return null;
|
||||
const fmt = (d) => new Date(d).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||
|
||||
if (start && end) return `${fmt(start)} - ${fmt(end)}`;
|
||||
if (start) return `Starts ${fmt(start)}`;
|
||||
if (end) return `Ends ${fmt(end)}`;
|
||||
if (start && end) return `${fmtDate(start)} - ${fmtDate(end)}`;
|
||||
if (start) return `Starts ${fmtDate(start)}`;
|
||||
if (end) return `Ends ${fmtDate(end)}`;
|
||||
return null;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { House, Edit, ArrowLeft, Megaphone, MousePointerClick, ExternalLink } fr
|
||||
|
||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ViewAdvertisement() {
|
||||
const navigate = useNavigate();
|
||||
const { advertisementId } = useParams();
|
||||
const { fetchAdvertisement, loading } = useAdvertisements();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const [advertisement, setAdvertisement] = useState(null);
|
||||
|
||||
@@ -171,8 +166,8 @@ export default function ViewAdvertisement() {
|
||||
{/* ── Scheduling & display ──────────────────────────────────── */}
|
||||
<SectionCard title="Scheduling & display">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Start date">{formatDateTime(advertisement.start_date)}</Field>
|
||||
<Field label="End date">{formatDateTime(advertisement.end_date)}</Field>
|
||||
<Field label="Start date">{fmtDateTime(advertisement.start_date)}</Field>
|
||||
<Field label="End date">{fmtDateTime(advertisement.end_date)}</Field>
|
||||
<Field label="Order">{advertisement.order ?? 0}</Field>
|
||||
<Field label="Active">{advertisement.is_active ? "Yes" : "No"}</Field>
|
||||
</div>
|
||||
@@ -191,9 +186,9 @@ export default function ViewAdvertisement() {
|
||||
<SectionCard title="Audit">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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 at">{formatDateTime(advertisement.updatedAt)}</Field>
|
||||
<Field label="Last updated at">{fmtDateTime(advertisement.updatedAt)}</Field>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Globe, Music2 } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -29,6 +30,7 @@ function MetaRow({ label, value }) {
|
||||
export default function ViewAudioAsset() {
|
||||
const { assetId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
const [streamUrl, setStreamUrl] = useState(null);
|
||||
@@ -138,8 +140,8 @@ export default function ViewAudioAsset() {
|
||||
<Separator className="my-2" />
|
||||
<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" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} />
|
||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||
</div>
|
||||
|
||||
{a.description && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Globe, FileText } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -26,6 +27,7 @@ const PREVIEWABLE = ["pdf", "txt", "html", "htm", "csv", "md"];
|
||||
export default function ViewDocumentAsset() {
|
||||
const { assetId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
const [streamUrl, setStreamUrl] = useState(null);
|
||||
@@ -132,8 +134,8 @@ export default function ViewDocumentAsset() {
|
||||
<Separator className="my-2" />
|
||||
<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" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} />
|
||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||
</div>
|
||||
|
||||
{a.description && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import api from "@/utils/api.util";
|
||||
import { ArrowLeft, Lock, Globe } from "lucide-react";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -24,6 +25,7 @@ function MetaRow({ label, value }) {
|
||||
export default function ViewImageAsset() {
|
||||
const { assetId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
const [streamUrl, setStreamUrl] = useState(null);
|
||||
@@ -124,8 +126,8 @@ export default function ViewImageAsset() {
|
||||
<Separator className="my-2" />
|
||||
<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" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} />
|
||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||
</div>
|
||||
|
||||
{a.description && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Lock, Globe } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -34,6 +35,7 @@ function formatDuration(seconds) {
|
||||
export default function ViewVideoAsset() {
|
||||
const { assetId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||
const [streamUrl, setStreamUrl] = useState(null);
|
||||
@@ -160,8 +162,8 @@ export default function ViewVideoAsset() {
|
||||
<Separator className="my-2" />
|
||||
<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" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} />
|
||||
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
|
||||
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||
</div>
|
||||
|
||||
{a.description && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "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 { useAuth } from "@/contexts/AuthContext";
|
||||
import api from "@/utils/api.util";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -29,7 +31,7 @@ const schema = z.object({
|
||||
course_code: z.string().optional(),
|
||||
order_index: z.coerce.number().min(0).default(0),
|
||||
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([]),
|
||||
});
|
||||
|
||||
@@ -61,6 +63,13 @@ export default function AddCourse() {
|
||||
const { createCourse, loading } = useCourses();
|
||||
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 {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -178,8 +187,11 @@ export default function AddCourse() {
|
||||
<SelectValue placeholder="Select subscription" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="free">Free</SelectItem>
|
||||
<SelectItem value="premium">Premium</SelectItem>
|
||||
{tierCategories.map((c) => (
|
||||
<SelectItem key={c.slug} value={c.slug}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.subscription?.message} />
|
||||
|
||||
@@ -16,9 +16,9 @@ import api from "@/utils/api.util";
|
||||
|
||||
// ── 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({
|
||||
title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours,
|
||||
title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions,
|
||||
questions: questions.map((q) => ({
|
||||
question_id: q.question_id ?? null,
|
||||
question: q.question,
|
||||
@@ -222,6 +222,7 @@ export default function CourseAssessment() {
|
||||
const [maxQuestions, setMaxQuestions] = useState("");
|
||||
const [maxAttempts, setMaxAttempts] = useState(3);
|
||||
const [cooldownHours, setCooldownHours] = useState(24);
|
||||
const [shuffleQuestions, setShuffleQuestions] = useState(false);
|
||||
|
||||
// ── Update confirmation dialog ─────────────────────────────────────────────
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
@@ -253,10 +254,11 @@ export default function CourseAssessment() {
|
||||
const mq = assessment.max_questions ?? "";
|
||||
const ma = assessment.max_attempts ?? 3;
|
||||
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 ?? [] }));
|
||||
setTitle(t); setPassingScore(ps); setTimeLimit(tl); setIsRequired(ir);
|
||||
setMaxQuestions(mq); setMaxAttempts(ma); setCooldownHours(ch); setQuestions(qs);
|
||||
initialSnapshot.current = snapAssessment({ title: t, passingScore: ps, timeLimit: tl, isRequired: ir, maxQuestions: mq, maxAttempts: ma, cooldownHours: ch, questions: 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, shuffleQuestions: sq, questions: qs });
|
||||
}, [assessment]);
|
||||
|
||||
// ── Measure sticky header → --assessment-h ────────────────────────────────
|
||||
@@ -366,7 +368,7 @@ export default function CourseAssessment() {
|
||||
// ── Dirty tracking ─────────────────────────────────────────────────────────
|
||||
const isDirty = initialSnapshot.current === null
|
||||
? 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 ───────────────────────────────────────────────────────────────────
|
||||
const handleSave = async () => {
|
||||
@@ -384,8 +386,9 @@ export default function CourseAssessment() {
|
||||
time_limit_minutes: timeLimit ? parseInt(timeLimit) : null,
|
||||
is_required: isRequired,
|
||||
max_questions: maxQuestions ? parseInt(maxQuestions) : null,
|
||||
max_attempts: parseInt(maxAttempts) || 3,
|
||||
cooldown_hours: parseInt(cooldownHours) || 24,
|
||||
max_attempts: parseInt(maxAttempts) || 3,
|
||||
cooldown_hours: parseInt(cooldownHours) || 24,
|
||||
shuffle_questions: shuffleQuestions,
|
||||
updatedBy: 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 () => {
|
||||
@@ -589,6 +592,17 @@ export default function CourseAssessment() {
|
||||
Required to complete course
|
||||
</Label>
|
||||
</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>
|
||||
|
||||
{maxQuestions && parseInt(maxQuestions) < questions.length && (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import CourseInstructorPicker from "@/modules/admin/components/courses/CourseInstructorPicker";
|
||||
import { useCategories } from "@/contexts/AdminCategoriesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import api from "@/utils/api.util";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -47,7 +48,7 @@ const schema = z.object({
|
||||
course_code: z.string().optional(),
|
||||
order_index: z.coerce.number().min(0).default(0),
|
||||
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([]),
|
||||
@@ -100,6 +101,14 @@ export default function EditCourse() {
|
||||
const { categories: allCategories, fetchCategories } = useCategories();
|
||||
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 ─────────────────────────────────────────────────────
|
||||
const [selectedCategoryIds, setSelectedCategoryIds] = useState([]);
|
||||
const [categoriesDirty, setCategoriesDirty] = useState(false);
|
||||
@@ -290,7 +299,7 @@ export default function EditCourse() {
|
||||
|
||||
const result = await updateCourse(courseId, payload);
|
||||
if (!result) return;
|
||||
navigate(-1);
|
||||
navigate(`/admin/courses/${courseId}/view`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -393,16 +402,17 @@ export default function EditCourse() {
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watch("subscription") ?? "free"}
|
||||
onValueChange={(val) =>
|
||||
setValue("subscription", val, { shouldDirty: true })
|
||||
}
|
||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select subscription" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="free">Free</SelectItem>
|
||||
<SelectItem value="premium">Premium</SelectItem>
|
||||
{tierCategories.map((c) => (
|
||||
<SelectItem key={c.slug} value={c.slug}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.subscription?.message} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -103,6 +104,7 @@ function QuestionView({ question, index }) {
|
||||
// ─── Completions tab ──────────────────────────────────────────────────────────
|
||||
|
||||
function CompletionRow({ row }) {
|
||||
const { fmtDate, fmtDateTime } = useDateFormat();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
@@ -112,8 +114,15 @@ function CompletionRow({ row }) {
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{row.full_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.email}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-sm">{row.attempt_count}</td>
|
||||
@@ -128,7 +137,7 @@ function CompletionRow({ row }) {
|
||||
)}
|
||||
</td>
|
||||
<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 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" />}
|
||||
@@ -158,7 +167,7 @@ function CompletionRow({ row }) {
|
||||
? <span className="text-green-600 dark:text-green-400 font-medium">Pass</span>
|
||||
: <span className="text-red-500 font-medium">Fail</span>}
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -235,6 +244,7 @@ function fmtDuration(secs) {
|
||||
}
|
||||
|
||||
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 (!sessions) return (
|
||||
@@ -274,8 +284,15 @@ function SessionsTab({ sessions, loading }) {
|
||||
{rows.map((s) => (
|
||||
<tr key={s.session_id} className="border-b last:border-0 hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-medium">{s.full_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{s.email}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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 className="px-4 py-3 text-center">
|
||||
<Badge className={SESSION_BADGE[s.status] ?? ""}>
|
||||
@@ -283,10 +300,10 @@ function SessionsTab({ sessions, loading }) {
|
||||
</Badge>
|
||||
</td>
|
||||
<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 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 className="px-4 py-3 text-center text-sm">{fmtDuration(s.time_spent_seconds)}</td>
|
||||
</tr>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
@@ -45,8 +46,7 @@ const LEVEL_BADGE = {
|
||||
};
|
||||
|
||||
const SUBSCRIPTION_BADGE = {
|
||||
free: "secondary",
|
||||
premium: "default",
|
||||
free: "secondary",
|
||||
};
|
||||
|
||||
function LoadingSkeleton() {
|
||||
@@ -69,6 +69,7 @@ export default function ViewCourse() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId } = useParams();
|
||||
const { fetchCourse, course, loading } = useCourses();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourse(courseId);
|
||||
@@ -232,10 +233,10 @@ export default function ViewCourse() {
|
||||
<InfoRow label="Created By">{course.creator?.full_name ?? course.createdBy ?? "—"}</InfoRow>
|
||||
<InfoRow label="Updated By">{course.updater?.full_name ?? course.updatedBy ?? "—"}</InfoRow>
|
||||
<InfoRow label="Created At">
|
||||
{course.createdAt ? new Date(course.createdAt).toLocaleString() : "—"}
|
||||
{course.createdAt ? fmtDateTime(course.createdAt) : "—"}
|
||||
</InfoRow>
|
||||
<InfoRow label="Updated At">
|
||||
{course.updatedAt ? new Date(course.updatedAt).toLocaleString() : "—"}
|
||||
{course.updatedAt ? fmtDateTime(course.updatedAt) : "—"}
|
||||
</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,6 +14,7 @@ export default function LessonsList() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId } = useParams();
|
||||
const { fetchCourse, fetchUnit, course, unit, loading } = useCourses();
|
||||
const { fmtDate } = useDateFormat();
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -81,13 +83,13 @@ export default function LessonsList() {
|
||||
<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="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>
|
||||
</div>
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { House, Pencil, ArrowLeft, Clock, ListChecks, FileText } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -21,6 +22,7 @@ export default function ViewLesson() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId, lessonId } = useParams();
|
||||
const { fetchLesson, course, unit } = useCourses();
|
||||
const { fmtDate } = useDateFormat();
|
||||
const [lesson, setLesson] = useState(null);
|
||||
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 (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<PageMeta title={lesson ? `${lesson.title} - STARR` : undefined} />
|
||||
@@ -83,11 +82,11 @@ export default function ViewLesson() {
|
||||
</div>
|
||||
<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="font-semibold text-sm">{formatDate(lesson?.createdAt)}</p>
|
||||
<p className="font-semibold text-sm">{fmtDate(lesson?.createdAt)}</p>
|
||||
</div>
|
||||
<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="font-semibold text-sm">{formatDate(lesson?.updatedAt)}</p>
|
||||
<p className="font-semibold text-sm">{fmtDate(lesson?.updatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function EditUnit() {
|
||||
if (!isDirty) return navigate(-1);
|
||||
const result = await updateUnit(courseId, unitId, { ...data, updatedBy: user?.user_id });
|
||||
if (!result) return;
|
||||
navigate(-1);
|
||||
navigate(`/admin/courses/${courseId}/units/${unitId}/view`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
+23
-9
@@ -30,9 +30,9 @@ function validate(questions) {
|
||||
|
||||
// ── 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({
|
||||
title, passingScore, isRequired, maxQuestions,
|
||||
title, passingScore, isRequired, maxQuestions, shuffleQuestions,
|
||||
questions: questions.map((q) => ({
|
||||
question_id: q.question_id ?? null,
|
||||
question: q.question,
|
||||
@@ -192,7 +192,7 @@ function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function UnitQuiz() {
|
||||
export default function ModifyQuiz() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId, unitId } = useParams();
|
||||
const {
|
||||
@@ -211,6 +211,7 @@ export default function UnitQuiz() {
|
||||
const [passingScore, setPassingScore] = useState(70);
|
||||
const [isRequired, setIsRequired] = useState(false);
|
||||
const [maxQuestions, setMaxQuestions] = useState("");
|
||||
const [shuffleQuestions, setShuffleQuestions] = useState(false);
|
||||
|
||||
const questionRefs = useRef([]);
|
||||
const navItemRefs = useRef([]);
|
||||
@@ -241,9 +242,10 @@ export default function UnitQuiz() {
|
||||
const ps = quiz.passing_score ?? 70;
|
||||
const ir = quiz.is_required === true || quiz.is_required === 1;
|
||||
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 ?? [] }));
|
||||
setTitle(t); setPassingScore(ps); setIsRequired(ir); setMaxQuestions(mq); setQuestions(qs);
|
||||
initialSnapshot.current = snapQuiz({ title: t, passingScore: ps, isRequired: ir, maxQuestions: mq, questions: qs });
|
||||
setTitle(t); setPassingScore(ps); setIsRequired(ir); setMaxQuestions(mq); setShuffleQuestions(sq); setQuestions(qs);
|
||||
initialSnapshot.current = snapQuiz({ title: t, passingScore: ps, isRequired: ir, maxQuestions: mq, shuffleQuestions: sq, questions: qs });
|
||||
}, [quiz]);
|
||||
|
||||
// ── Measure sticky header → --quiz-h ──────────────────────────────────────
|
||||
@@ -353,7 +355,7 @@ export default function UnitQuiz() {
|
||||
// ── Dirty tracking ─────────────────────────────────────────────────────────
|
||||
const isDirty = initialSnapshot.current === null
|
||||
? 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 ───────────────────────────────────────────────────────────────────
|
||||
const handleSave = async () => {
|
||||
@@ -368,8 +370,9 @@ export default function UnitQuiz() {
|
||||
const meta = {
|
||||
title: title || "Unit Quiz",
|
||||
passing_score: passingScore,
|
||||
is_required: isRequired,
|
||||
max_questions: maxQuestions ? parseInt(maxQuestions) : null,
|
||||
is_required: isRequired,
|
||||
max_questions: maxQuestions ? parseInt(maxQuestions) : null,
|
||||
shuffle_questions: shuffleQuestions,
|
||||
updatedBy: 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);
|
||||
};
|
||||
|
||||
@@ -507,6 +510,17 @@ export default function UnitQuiz() {
|
||||
Required to proceed to next unit
|
||||
</Label>
|
||||
</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>
|
||||
|
||||
{maxQuestions && parseInt(maxQuestions) < questions.length && (
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Plus } from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
@@ -13,6 +14,7 @@ export default function UnitsList() {
|
||||
const navigate = useNavigate();
|
||||
const { courseId } = useParams();
|
||||
const { fetchCourse, course, loading } = useCourses();
|
||||
const { fmtDate } = useDateFormat();
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,7 +41,7 @@ export default function UnitsList() {
|
||||
|
||||
return (
|
||||
<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="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
@@ -81,13 +83,13 @@ export default function UnitsList() {
|
||||
<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="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>
|
||||
</div>
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CheckCircle2, Circle, Users,
|
||||
ChevronDown, ChevronUp,
|
||||
} from "lucide-react";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
@@ -103,6 +104,7 @@ function QuestionView({ question, index }) {
|
||||
|
||||
function CompletionRow({ row }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { fmtDate, fmtDateTime } = useDateFormat();
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
@@ -111,8 +113,15 @@ function CompletionRow({ row }) {
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{row.full_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.email}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-sm">{row.attempt_count}</td>
|
||||
@@ -127,7 +136,7 @@ function CompletionRow({ row }) {
|
||||
)}
|
||||
</td>
|
||||
<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 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" />}
|
||||
@@ -157,7 +166,7 @@ function CompletionRow({ row }) {
|
||||
? <span className="text-green-600 dark:text-green-400 font-medium">Pass</span>
|
||||
: <span className="text-red-500 font-medium">Fail</span>}
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -284,7 +293,7 @@ export default function ViewUnitQuiz() {
|
||||
<Button
|
||||
variant="outline"
|
||||
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" />
|
||||
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">
|
||||
<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>
|
||||
<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" />
|
||||
Create Quiz
|
||||
</Button>
|
||||
|
||||
@@ -41,6 +41,8 @@ export default function CreateTaskList() {
|
||||
if (selectedGroupIds.length > 0) {
|
||||
await assignGroups(created.task_list_id, selectedGroupIds);
|
||||
}
|
||||
|
||||
navigate(`/admin/taskList/${created.task_list_id}/view`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||
import { useDateFormat } from '@/hooks/useDateFormat';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -136,6 +137,7 @@ export default function ViewTaskList() {
|
||||
const navigate = useNavigate();
|
||||
const { taskListId } = useParams();
|
||||
const { fetchTaskList } = useAdminTask();
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
const [taskList, setTaskList] = useState(null);
|
||||
|
||||
@@ -273,11 +275,7 @@ export default function ViewTaskList() {
|
||||
<span className="text-sm">
|
||||
Deadline:{' '}
|
||||
<span className="text-foreground font-medium">
|
||||
{new Date(task.deadline).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
{fmtDate(task.deadline)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function EditTask() {
|
||||
requirements: form.requirements,
|
||||
});
|
||||
|
||||
if (updated) navigate(`/admin/taskList/${taskListId}/tasks/${taskId}`);
|
||||
if (updated) navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/view`);
|
||||
};
|
||||
|
||||
if (!form) return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { useAdminTask } from '@/contexts/AdminTaskContext';
|
||||
import { useDateFormat } from '@/hooks/useDateFormat';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -128,6 +129,7 @@ export default function ViewTask() {
|
||||
const navigate = useNavigate();
|
||||
const { taskListId, taskId } = useParams();
|
||||
const { fetchTask } = useAdminTask();
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
const [task, setTask] = useState(null);
|
||||
|
||||
@@ -200,11 +202,7 @@ export default function ViewTask() {
|
||||
<span className="text-sm">
|
||||
Deadline:{' '}
|
||||
<span className="text-foreground font-medium">
|
||||
{new Date(task.deadline).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
{fmtDate(task.deadline)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,280 +1,203 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "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 { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
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 { cn } from "@/lib/utils";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const schema = z.object({
|
||||
tier: z.enum(["premium", "exclusive"]),
|
||||
label: z.string().min(1, "Label is required."),
|
||||
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."),
|
||||
currency: z.string().length(3, "Must be a 3-letter currency code.").default("USD"),
|
||||
tier_category_id: z.string().min(1, "Tier category is required."),
|
||||
label: z.string().min(1, "Label is required."),
|
||||
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."),
|
||||
currency: z.string().length(3, "Must be a 3-letter currency code.").default("USD"),
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
function SectionCard({ title, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
{title && (
|
||||
<div className="pb-1 border-b">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
{title && <div className="pb-1 border-b"><h2 className="text-sm font-semibold">{title}</h2></div>}
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AddPlan() {
|
||||
const navigate = useNavigate();
|
||||
const { createPlan, syncPlanCourses, loading } = useTiers();
|
||||
const { fetchCourses, courses } = useCourses();
|
||||
const navigate = useNavigate();
|
||||
const { createPlan, loading } = useTiers();
|
||||
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [catLoading, setCatLoading] = useState(true);
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourses({ limit: 200 });
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
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({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { tier: "premium", label: "", duration_days: 30, price: "", currency: "USD" },
|
||||
});
|
||||
const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { tier_category_id: "", label: "", duration_days: 30, price: "", currency: "USD" },
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const result = await createPlan(values);
|
||||
if (!result) return;
|
||||
const selectedCategoryId = watch("tier_category_id");
|
||||
|
||||
const planId = String(result.plan_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]);
|
||||
|
||||
if (selectedCourseIds.length > 0) {
|
||||
await syncPlanCourses(planId, selectedCourseIds);
|
||||
}
|
||||
// Reset picker when category changes
|
||||
useEffect(() => {
|
||||
setSelectedCourseIds(new Set());
|
||||
}, [categorySlug]);
|
||||
|
||||
navigate("/admin/tiers/plans");
|
||||
};
|
||||
const onSubmit = async (values) => {
|
||||
const result = await createPlan(values);
|
||||
if (!result) return;
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title="Add Plan - 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">
|
||||
// Sync selected courses
|
||||
if (selectedCourseIds.size > 0) {
|
||||
await api.post(`/admin/tiers/plans/${result.plan_id}/courses`, {
|
||||
course_ids: [...selectedCourseIds].map(Number),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
<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: "Add Plan" },
|
||||
]} />
|
||||
</div>
|
||||
navigate(`/admin/tiers/plans`);
|
||||
};
|
||||
|
||||
<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">Add Plan</h1>
|
||||
<p className="text-sm text-muted-foreground">Create a new premium or exclusive plan.</p>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title="Add Plan - 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">
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
<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: "Add Plan" },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
{/* ── Plan Details ── */}
|
||||
<SectionCard title="Plan Details">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Tier <span className="text-destructive">*</span></Label>
|
||||
<Select value={watch("tier")} onValueChange={(v) => setValue("tier", v, { shouldDirty: true })}>
|
||||
<SelectTrigger><SelectValue placeholder="Select tier" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="premium">Premium</SelectItem>
|
||||
<SelectItem value="exclusive">Exclusive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.tier?.message} />
|
||||
</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">Add Plan</h1>
|
||||
<p className="text-sm text-muted-foreground">Create a new paid tier plan.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
|
||||
<Input id="label" placeholder="e.g. Premium – 1 Month" {...register("label")} />
|
||||
<FieldError message={errors.label?.message} />
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="duration_days">Duration (days) <span className="text-destructive">*</span></Label>
|
||||
<Input id="duration_days" type="number" min={1} {...register("duration_days")} />
|
||||
<FieldError message={errors.duration_days?.message} />
|
||||
<SectionCard title="Plan Details">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Tier Category <span className="text-destructive">*</span></Label>
|
||||
{catLoading ? (
|
||||
<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>
|
||||
{categories.map((c) => (
|
||||
<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>
|
||||
</Select>
|
||||
)}
|
||||
<FieldError message={errors.tier_category_id?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
|
||||
<Input id="label" placeholder="e.g. Premium – 1 Month" {...register("label")} />
|
||||
<FieldError message={errors.label?.message} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="duration_days">Duration (days) <span className="text-destructive">*</span></Label>
|
||||
<Input id="duration_days" type="number" min={1} {...register("duration_days")} />
|
||||
<FieldError message={errors.duration_days?.message} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="price">Price <span className="text-destructive">*</span></Label>
|
||||
<Input id="price" type="number" step="0.01" min={0} placeholder="0.00" {...register("price")} />
|
||||
<FieldError message={errors.price?.message} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Input id="currency" maxLength={3} placeholder="USD" {...register("currency")} />
|
||||
<FieldError message={errors.currency?.message} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{categorySlug && (
|
||||
<SectionCard title="Assigned Courses">
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Select which <span className="font-medium capitalize">{categorySlug}</span> courses are included in this plan.
|
||||
</p>
|
||||
<CoursePicker
|
||||
subscription={categorySlug}
|
||||
selectedIds={selectedCourseIds}
|
||||
onChange={setSelectedCourseIds}
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
|
||||
<Button type="submit" disabled={loading || catLoading || !selectedCategoryId}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="price">Price <span className="text-destructive">*</span></Label>
|
||||
<Input id="price" type="number" step="0.01" min={0} placeholder="0.00" {...register("price")} />
|
||||
<FieldError message={errors.price?.message} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Input id="currency" maxLength={3} placeholder="USD" {...register("currency")} />
|
||||
<FieldError message={errors.currency?.message} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Courses ── */}
|
||||
<SectionCard title="Courses">
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Assign courses included in this plan. You can also manage this later from the plan's detail page.
|
||||
</p>
|
||||
<CourseMultiSelect
|
||||
courses={courses}
|
||||
selected={selectedCourseIds}
|
||||
onChange={setSelectedCourseIds}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "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 { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -13,14 +12,9 @@ import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
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 { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
const schema = z.object({
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const { planId } = useParams();
|
||||
const { fetchPlan, plan, updatePlan, fetchPlanCourses, planCourses, syncPlanCourses, loading } = useTiers();
|
||||
const { courses: allCourses, fetchCourses } = useCourses();
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState([]);
|
||||
const { fetchPlan, plan, updatePlan, loading } = useTiers();
|
||||
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
||||
const [coursesLoaded, setCoursesLoaded] = useState(false);
|
||||
|
||||
const { register, handleSubmit, setValue, watch, reset, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -155,28 +53,41 @@ export default function EditPlan() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlan(planId);
|
||||
fetchPlanCourses(planId);
|
||||
fetchCourses({ page: 1, limit: 1000 });
|
||||
}, [planId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCourseIds(planCourses.map(c => String(c.course_id)));
|
||||
}, [planCourses]);
|
||||
|
||||
useEffect(() => {
|
||||
if (plan) reset({
|
||||
label: plan.label,
|
||||
duration_days: plan.duration_days,
|
||||
price: plan.price,
|
||||
currency: plan.currency,
|
||||
is_active: plan.is_active,
|
||||
});
|
||||
if (plan) {
|
||||
reset({
|
||||
label: plan.label,
|
||||
duration_days: plan.duration_days,
|
||||
price: plan.price,
|
||||
currency: plan.currency,
|
||||
is_active: plan.is_active,
|
||||
});
|
||||
}
|
||||
}, [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 result = await updatePlan(planId, values);
|
||||
await syncPlanCourses(planId, selectedCourseIds);
|
||||
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");
|
||||
};
|
||||
|
||||
@@ -249,16 +160,18 @@ export default function EditPlan() {
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Courses">
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Assign courses included in this plan. Changes are saved when you click Save Changes.
|
||||
</p>
|
||||
<CourseMultiSelect
|
||||
courses={allCourses}
|
||||
selected={selectedCourseIds}
|
||||
onChange={setSelectedCourseIds}
|
||||
/>
|
||||
</SectionCard>
|
||||
{plan?.tier && (
|
||||
<SectionCard title="Assigned Courses">
|
||||
<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>
|
||||
<CoursePicker
|
||||
subscription={plan.tier}
|
||||
selectedIds={selectedCourseIds}
|
||||
onChange={setSelectedCourseIds}
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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 { ArrowLeft, House, ShieldPlus, ShieldOff, BadgeCheck } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -19,9 +21,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
const TIER_BADGE = { free: "secondary", premium: "default", exclusive: "destructive" };
|
||||
const STATUS_BADGE = { active: "default", expired: "secondary", revoked: "outline" };
|
||||
|
||||
function InfoRow({ label, children }) {
|
||||
@@ -50,20 +52,46 @@ export default function UserTierList() {
|
||||
const { userId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
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 [grantForm, setGrantForm] = useState({ tier: "premium", plan_id: "", notes: "" });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [grantForm, setGrantForm] = useState({ tier: "", plan_id: "", notes: "" });
|
||||
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(() => {
|
||||
fetchUserTiers(userId);
|
||||
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]);
|
||||
|
||||
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 tierBadge = (slug) => {
|
||||
const { cls, label } = resolveTierBadge(slug, tierMap);
|
||||
return <Badge className={`${cls} capitalize`}>{label}</Badge>;
|
||||
};
|
||||
|
||||
const handleGrant = async () => {
|
||||
if (!grantForm.plan_id) return;
|
||||
setSubmitting(true);
|
||||
@@ -118,12 +146,10 @@ export default function UserTierList() {
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">Current Tier</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={TIER_BADGE[activeTier.tier] ?? "outline"} className="capitalize text-sm px-3 py-0.5">
|
||||
{activeTier.tier}
|
||||
</Badge>
|
||||
<span className="text-sm px-3 py-0.5">{tierBadge(activeTier.tier)}</span>
|
||||
{activeTier.expires_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Expires {new Date(activeTier.expires_at).toLocaleDateString()}
|
||||
Expires {fmtDate(activeTier.expires_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -152,17 +178,17 @@ export default function UserTierList() {
|
||||
<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 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>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">#{t.tier_id}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoRow label="Starts At">{t.starts_at ? new Date(t.starts_at).toLocaleString() : "—"}</InfoRow>
|
||||
<InfoRow label="Expires At">{t.expires_at ? new Date(t.expires_at).toLocaleString() : "Never"}</InfoRow>
|
||||
<InfoRow label="Starts At">{t.starts_at ? fmtDateTime(t.starts_at) : "—"}</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>
|
||||
{t.revoked_at && (
|
||||
<InfoRow label="Revoked At">{new Date(t.revoked_at).toLocaleString()}</InfoRow>
|
||||
<InfoRow label="Revoked At">{fmtDateTime(t.revoked_at)}</InfoRow>
|
||||
)}
|
||||
</div>
|
||||
{t.notes && <p className="text-xs text-muted-foreground italic">{t.notes}</p>}
|
||||
@@ -191,8 +217,9 @@ export default function UserTierList() {
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="premium">Premium</SelectItem>
|
||||
<SelectItem value="exclusive">Exclusive</SelectItem>
|
||||
{grantableCategories.map((c) => (
|
||||
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, CreditCard, BadgeCheck, User } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -7,7 +7,10 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import api from "@/utils/api.util";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
|
||||
const STATUS_BADGE = {
|
||||
pending: "secondary",
|
||||
@@ -17,7 +20,6 @@ const STATUS_BADGE = {
|
||||
expired: "outline",
|
||||
refunded: "outline",
|
||||
};
|
||||
const TIER_BADGE = { free: "secondary", premium: "default", exclusive: "destructive" };
|
||||
|
||||
// ─── Provider label map (extend as you add more providers) ───────────────────
|
||||
const PROVIDER_LABELS = {
|
||||
@@ -102,7 +104,7 @@ function ProviderReference({ payment }) {
|
||||
{/* Cancelled info — shown for any provider */}
|
||||
{payload.cancelled_at && (
|
||||
<InfoRow label="Cancelled At">
|
||||
{new Date(payload.cancelled_at).toLocaleString()}
|
||||
{fmtDateTime(payload.cancelled_at)}
|
||||
</InfoRow>
|
||||
)}
|
||||
|
||||
@@ -117,8 +119,15 @@ export default function ViewPayment() {
|
||||
const navigate = useNavigate();
|
||||
const { paymentId } = useParams();
|
||||
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 (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
@@ -174,10 +183,10 @@ export default function ViewPayment() {
|
||||
</InfoRow>
|
||||
)}
|
||||
<InfoRow label="Paid At">
|
||||
{payment.paid_at ? new Date(payment.paid_at).toLocaleString() : "—"}
|
||||
{payment.paid_at ? fmtDateTime(payment.paid_at) : "—"}
|
||||
</InfoRow>
|
||||
<InfoRow label="Created At">
|
||||
{payment.createdAt ? new Date(payment.createdAt).toLocaleString() : "—"}
|
||||
{payment.createdAt ? fmtDateTime(payment.createdAt) : "—"}
|
||||
</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
@@ -196,9 +205,7 @@ export default function ViewPayment() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Label">{payment.plan.label}</InfoRow>
|
||||
<InfoRow label="Tier">
|
||||
<Badge variant={TIER_BADGE[payment.plan.tier] ?? "outline"} className="capitalize mt-0.5">
|
||||
{payment.plan.tier}
|
||||
</Badge>
|
||||
{(() => { const { cls, label } = resolveTierBadge(payment.plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
|
||||
</InfoRow>
|
||||
<InfoRow label="Duration">{payment.plan.duration_days} days</InfoRow>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
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" };
|
||||
|
||||
function InfoRow({ label, children }) {
|
||||
@@ -48,11 +50,15 @@ function LoadingSkeleton() {
|
||||
export default function ViewPlan() {
|
||||
const navigate = useNavigate();
|
||||
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(() => {
|
||||
fetchPlan(planId);
|
||||
fetchPlanCourses(planId);
|
||||
api.get("/admin/tiers/categories").then(({ data }) => setTierCategories(data.data ?? [])).catch(() => {});
|
||||
}, [planId]);
|
||||
|
||||
return (
|
||||
@@ -79,15 +85,26 @@ export default function ViewPlan() {
|
||||
<p className="text-sm text-muted-foreground">View plan information.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/tiers/plans/${planId}/edit`)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
<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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/tiers/plans/${planId}/edit`)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && !plan ? (
|
||||
@@ -101,9 +118,7 @@ export default function ViewPlan() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Label">{plan.label}</InfoRow>
|
||||
<InfoRow label="Tier">
|
||||
<Badge variant={TIER_BADGE[plan.tier] ?? "outline"} className="capitalize mt-0.5">
|
||||
{plan.tier}
|
||||
</Badge>
|
||||
{(() => { const { cls, label } = resolveTierBadge(plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
|
||||
</InfoRow>
|
||||
<InfoRow label="Duration">{plan.duration_days} days</InfoRow>
|
||||
<InfoRow label="Price">
|
||||
@@ -121,37 +136,14 @@ export default function ViewPlan() {
|
||||
<SectionCard icon={BadgeCheck} title="Audit">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Created At">
|
||||
{plan.createdAt ? new Date(plan.createdAt).toLocaleString() : "—"}
|
||||
{plan.createdAt ? fmtDateTime(plan.createdAt) : "—"}
|
||||
</InfoRow>
|
||||
<InfoRow label="Updated At">
|
||||
{plan.updatedAt ? new Date(plan.updatedAt).toLocaleString() : "—"}
|
||||
{plan.updatedAt ? fmtDateTime(plan.updatedAt) : "—"}
|
||||
</InfoRow>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRef, useMemo, useState, useEffect, useCallback } from "react";
|
||||
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 AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
@@ -16,9 +16,21 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} 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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { buildDataColumns, columnPinning } from "../../config/user_groups/view/columns.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 ─────────────────────────────────────────────────────────────────────
|
||||
export default function ViewGroup() {
|
||||
const { groupId } = useParams();
|
||||
@@ -135,13 +283,19 @@ export default function ViewGroup() {
|
||||
const [archiveIds, setArchiveIds] = useState(null);
|
||||
const [memberAttrs, setMemberAttrs] = useState([]);
|
||||
|
||||
const [assignOpen, setAssignOpen] = useState(false);
|
||||
const [assignTarget, setAssignTarget] = useState(null); // single row
|
||||
const [assignIds, setAssignIds] = useState(null); // bulk ids[]
|
||||
|
||||
const {
|
||||
group,
|
||||
groups,
|
||||
members,
|
||||
usersNotIn,
|
||||
pagination,
|
||||
setPagination,
|
||||
loading,
|
||||
fetchGroups,
|
||||
fetchGroup,
|
||||
fetchGroupFieldValues,
|
||||
fetchUsersNotInGroup,
|
||||
@@ -149,6 +303,8 @@ export default function ViewGroup() {
|
||||
removeUsersFromGroup,
|
||||
} = useUserGroups();
|
||||
|
||||
const isNoGroup = group?.group_code === 'NOGRP';
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupId) return;
|
||||
fetchGroup(groupId).then((res) => {
|
||||
@@ -169,7 +325,9 @@ export default function ViewGroup() {
|
||||
};
|
||||
|
||||
const rowActions = buildRowActions({
|
||||
onRemove: (row) => setArchiveTarget(row),
|
||||
onRemove: (row) => setArchiveTarget(row),
|
||||
onAssign: (row) => openAssignDialog(row),
|
||||
isNoGroup,
|
||||
});
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
@@ -184,13 +342,15 @@ export default function ViewGroup() {
|
||||
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
onRemoveMember: (row) => setArchiveTarget(row),
|
||||
onRemoveMembers: (ids) => setArchiveIds(ids),
|
||||
onRemoveMember: (row) => setArchiveTarget(row),
|
||||
onRemoveMembers: (ids) => setArchiveIds(ids),
|
||||
onAssignMembers: (ids) => openAssignDialog(ids),
|
||||
isNoGroup,
|
||||
});
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildDataColumns(memberAttrs, rowActions),
|
||||
[memberAttrs],
|
||||
[memberAttrs, isNoGroup],
|
||||
);
|
||||
|
||||
const handleRemoveSuccess = () => {
|
||||
@@ -200,23 +360,38 @@ export default function ViewGroup() {
|
||||
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 = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "User Groups", to: "/admin/groups" },
|
||||
{ label: group?.name ?? "View Group" },
|
||||
];
|
||||
|
||||
const formattedCreated = group?.createdAt
|
||||
? new Date(group.createdAt).toLocaleDateString("en-PH", {
|
||||
year: "numeric", month: "long", day: "numeric",
|
||||
})
|
||||
: "—";
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
const formattedUpdated = group?.updatedAt
|
||||
? new Date(group.updatedAt).toLocaleDateString("en-PH", {
|
||||
year: "numeric", month: "long", day: "numeric",
|
||||
})
|
||||
: "—";
|
||||
const formattedCreated = group?.createdAt ? fmtDate(group.createdAt) : "—";
|
||||
const formattedUpdated = group?.updatedAt ? fmtDate(group.updatedAt) : "—";
|
||||
|
||||
const handleFetch = useCallback(
|
||||
(params) => fetchGroup(groupId, params),
|
||||
@@ -256,8 +431,8 @@ export default function ViewGroup() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Generate invite link button ── */}
|
||||
{group?.group_code && (
|
||||
{/* ── Generate invite link button — hidden for NOGRP (system default group) ── */}
|
||||
{group?.group_code && group.group_code !== 'NOGRP' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -330,6 +505,16 @@ export default function ViewGroup() {
|
||||
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 ───────────────────────────────────────────────────── */}
|
||||
<AddSheet
|
||||
open={addMemberOpen}
|
||||
@@ -341,6 +526,7 @@ export default function ViewGroup() {
|
||||
onFetch={() => fetchUsersNotInGroup(groupId)}
|
||||
idKey="user_id"
|
||||
labelKey="full_name"
|
||||
warningKey="current_group"
|
||||
onSubmit={async (user_ids) => {
|
||||
await addUsersToGroup(groupId, user_ids);
|
||||
fetchGroup(groupId, { page: 1, limit: pagination.limit });
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
// ─── pages/users/ViewUser.jsx ─────────────────────────────────────────────────
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useUsers } from "@/contexts/AdminUserContext";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
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 { BADGE_STYLES } from "@/utils/table.util";
|
||||
import { getActionBadge } from "@/data/activity.data";
|
||||
import { timeAgo } from "@/utils/timestamp.util";
|
||||
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 ───────────────────────────────────────────────────────────────────
|
||||
const StatusBadge = ({ value }) => (
|
||||
@@ -26,18 +31,24 @@ const ACHIEVEMENT_ICON = { badge: BadgeCheck, milestone: Trophy };
|
||||
export default function ViewUser() {
|
||||
const { userId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { fmtDate, fmtDateTime } = useDateFormat();
|
||||
const {
|
||||
user, fetchUser, loading,
|
||||
achievements, achievementsLoading, fetchUserAchievements,
|
||||
activity, activityPagination, activityLoading, fetchUserActivity,
|
||||
bans, bansLoading, fetchUserBans,
|
||||
banUser, unbanUser,
|
||||
} = useUsers();
|
||||
|
||||
const [activityPage, setActivityPage] = useState(1);
|
||||
const [banDialogOpen, setBanDialogOpen] = useState(false);
|
||||
const [unbanDialogOpen, setUnbanDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUser(userId);
|
||||
fetchUserAchievements(userId);
|
||||
fetchUserActivity(userId, { page: 1, limit: 10 });
|
||||
fetchUserBans(userId);
|
||||
}, [userId]);
|
||||
|
||||
const loadActivityPage = (p) => {
|
||||
@@ -64,19 +75,56 @@ export default function ViewUser() {
|
||||
return (
|
||||
<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 ──────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/users`)}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<Avatar className="size-11 shrink-0">
|
||||
<AvatarImage src={user.personal_info?.avatar?.url ?? undefined} alt={name.full_name ?? user.email} />
|
||||
<AvatarFallback className="text-sm font-semibold">
|
||||
{(name.full_name ?? user.email ?? "?")
|
||||
.split(" ").map((n) => n[0]).slice(0, 2).join("").toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{name.full_name ?? "—"}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{name.full_name ?? "—"}
|
||||
</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>
|
||||
</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>
|
||||
|
||||
{/* ─── Account Info ────────────────────────────────────────────────── */}
|
||||
@@ -87,12 +135,20 @@ export default function ViewUser() {
|
||||
<Field label="Status">
|
||||
<StatusBadge value={user.is_active ? "Active" : "Not Active"} />
|
||||
</Field>
|
||||
<Field label="Ban Status">
|
||||
<StatusBadge value={user.is_banned ? "Banned" : "Not Banned"} />
|
||||
</Field>
|
||||
<Field label="Verified">
|
||||
<StatusBadge value={user.is_verified ? "Verified" : "Not Verified"} />
|
||||
</Field>
|
||||
<Field label="Registration Type">
|
||||
<StatusBadge value={user.reg_type} />
|
||||
</Field>
|
||||
{user.ban_expires_at && (
|
||||
<Field label="Ban Expires">
|
||||
{fmtDateTime(user.ban_expires_at)}
|
||||
</Field>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ─── Personal Info ───────────────────────────────────────────────── */}
|
||||
@@ -169,7 +225,7 @@ export default function ViewUser() {
|
||||
<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 mt-0.5">
|
||||
{new Date(a.granted_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
||||
{fmtDate(a.granted_at)}
|
||||
</p>
|
||||
</div>
|
||||
{isCert && (
|
||||
@@ -244,10 +300,7 @@ export default function ViewUser() {
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{new Date(row.created_at).toLocaleString("en-US", {
|
||||
month: "short", day: "numeric", year: "numeric",
|
||||
hour: "numeric", minute: "2-digit", second: "2-digit",
|
||||
})}
|
||||
{fmtDateTime(row.created_at)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
@@ -283,13 +336,106 @@ export default function ViewUser() {
|
||||
)}
|
||||
</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 ───────────────────────────────────────────────────────── */}
|
||||
<Section title="Audit Trail">
|
||||
<Field label="Created At">{user.createdAt ? new Date(user.createdAt).toLocaleString() : "—"}</Field>
|
||||
<Field label="Updated At">{user.updatedAt ? new Date(user.updatedAt).toLocaleString() : "—"}</Field>
|
||||
<Field label="Deleted At">{user.deletedAt ? new Date(user.deletedAt).toLocaleString() : "—"}</Field>
|
||||
<Field label="Created At">{fmtDateTime(user.createdAt)}</Field>
|
||||
<Field label="Updated At">{fmtDateTime(user.updatedAt)}</Field>
|
||||
<Field label="Deleted At">{fmtDateTime(user.deletedAt)}</Field>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ import LessonPageBuilder from '../pages/courses/lessons/LessonPageBuilder'
|
||||
import ViewLessonPage from '../pages/courses/lessons/ViewLessonPage'
|
||||
import CourseAssessment from '../pages/courses/CourseAssessment'
|
||||
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'
|
||||
|
||||
// Task List
|
||||
@@ -81,13 +81,16 @@ import AddCategory from '../pages/categories/AddCategory';
|
||||
import EditCategory from '../pages/categories/EditCategory';
|
||||
|
||||
// Tiers
|
||||
import PlanList from '../pages/tiers/PlanList';
|
||||
import AddPlan from '../pages/tiers/AddPlan';
|
||||
import ViewPlan from '../pages/tiers/ViewPlan';
|
||||
import EditPlan from '../pages/tiers/EditPlan';
|
||||
import UserTierList from '../pages/tiers/UserTierList';
|
||||
import PaymentList from '../pages/tiers/PaymentList';
|
||||
import ViewPayment from '../pages/tiers/ViewPayment';
|
||||
import PlanList from '../pages/tiers/PlanList';
|
||||
import AddPlan from '../pages/tiers/AddPlan';
|
||||
import ViewPlan from '../pages/tiers/ViewPlan';
|
||||
import EditPlan from '../pages/tiers/EditPlan';
|
||||
import SystemBadges from '../pages/tiers/SystemBadges';
|
||||
import UserTierList from '../pages/tiers/UserTierList';
|
||||
import PaymentList from '../pages/tiers/PaymentList';
|
||||
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 ViewTaskCompletion from '../pages/task_list/task/ViewTaskCompletion'
|
||||
|
||||
@@ -123,6 +126,7 @@ export const AdminRoutes = {
|
||||
{ path: 'add/staff', element: <AddUser /> },
|
||||
{ path: 'view/:userId', element: <ViewUser /> },
|
||||
{ path: 'archived', element: <ArchivedUserList /> },
|
||||
{ path: ':userId/activity', element: <UserActivityPage /> },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -187,7 +191,7 @@ export const AdminRoutes = {
|
||||
{ path: 'add', element: <AddUnit /> },
|
||||
{ path: ':unitId/view', element: <ViewUnit /> },
|
||||
{ path: ':unitId/edit', element: <EditUnit /> },
|
||||
{ path: ":unitId/quiz", element: <UnitQuiz /> },
|
||||
{ path: ":unitId/quiz/edit", element: <ModifyQuiz /> },
|
||||
{ path: ":unitId/quiz/view", element: <ViewUnitQuiz /> },
|
||||
|
||||
// Lessons
|
||||
@@ -248,10 +252,20 @@ export const AdminRoutes = {
|
||||
children: [
|
||||
{ index: true, element: <PlanList /> },
|
||||
{ path: 'add', element: <AddPlan /> },
|
||||
{ path: ':planId/view', element: <ViewPlan /> },
|
||||
{ path: ':planId/edit', element: <EditPlan /> },
|
||||
{ path: ':planId/view', element: <ViewPlan /> },
|
||||
{ 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',
|
||||
element: <UserTierList />,
|
||||
@@ -283,7 +297,6 @@ export const AdminRoutes = {
|
||||
|
||||
// Activity Feed
|
||||
{ path: 'activity', element: <ActivityFeed /> },
|
||||
{ path: 'users/:userId/activity', element: <UserActivityPage /> },
|
||||
|
||||
// Add here
|
||||
]
|
||||
|
||||
@@ -71,6 +71,17 @@ export function LoginForm({ className, ...props }) {
|
||||
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.')
|
||||
setErrorDialogOpen(true)
|
||||
}
|
||||
|
||||
@@ -18,29 +18,90 @@
|
||||
* Date Created: Jun. 21, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
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 { useDateFormat } from '@/hooks/useDateFormat'
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
access_denied: 'You cancelled the Google sign-in.',
|
||||
account_deactivated: 'Your account has been deactivated. Please contact support.',
|
||||
session_expired: 'The sign-in session expired. Please try again.',
|
||||
state_mismatch: 'Security check failed. Please try signing in again.',
|
||||
auth_failed: 'Google sign-in failed. Please try again.',
|
||||
const ERROR_MAP = {
|
||||
access_denied: { icon: UserX, message: 'You cancelled the Google sign-in.' },
|
||||
account_deactivated: { icon: UserX, message: 'Your account has been deactivated. Please contact support.' },
|
||||
session_expired: { icon: Clock, message: 'The sign-in session expired. Please try again.' },
|
||||
state_mismatch: { icon: AlertTriangle, message: 'Security check failed. Please try signing in again.' },
|
||||
auth_failed: { icon: RefreshCw, message: 'Google sign-in failed. Please try again.' },
|
||||
}
|
||||
|
||||
export default function OAuthCallback() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { fmtDateTime } = useDateFormat()
|
||||
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) {
|
||||
const { icon: Icon, message } = ERROR_MAP[error] ?? { icon: AlertTriangle, message: 'An unexpected error occurred. Please try again.' }
|
||||
return (
|
||||
<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">
|
||||
<p className="text-sm text-destructive font-medium">
|
||||
{ERROR_MESSAGES[error] ?? 'An unexpected error occurred. Please try again.'}
|
||||
</p>
|
||||
<Button asChild variant="outline">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-muted">
|
||||
<Icon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-destructive font-medium">{message}</p>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/login">Back to Login</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import LandingPage from '@/modules/public/pages/LandingPage'
|
||||
import Login from '../pages/Login'
|
||||
import Register from '../pages/Register'
|
||||
import OAuthCallback from '../pages/OAuthCallback'
|
||||
import Suspended from '@/modules/public/pages/Suspended'
|
||||
|
||||
|
||||
export const AuthRoutes = {
|
||||
@@ -19,6 +20,7 @@ export const AuthRoutes = {
|
||||
{ path: "login", element: <Login />},
|
||||
{ path: "signup", element: <Register />},
|
||||
{ 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:
|
||||
@@ -25,6 +25,15 @@ const CourseCompleteBlock = ({ course }) => {
|
||||
You've passed all required units and the final assessment for this course.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-left">
|
||||
<Clock className="size-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">Certificate Pending</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Certificates are issued automatically every hour. Yours will be ready within the next hour — check your notifications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,15 +10,10 @@ import {
|
||||
Paperclip,
|
||||
Plus,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
const DEFAULT_MAX_BYTES = 500 * 1024 * 1024; // 500 MB
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function formatBytes(b) {
|
||||
if (b >= 1024 * 1024 * 1024) return (b / 1024 / 1024 / 1024).toFixed(1) + " GB";
|
||||
@@ -84,46 +79,12 @@ const FileItem = ({ file, onRemove }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// ── StorageBar ────────────────────────────────────────────────────────────────
|
||||
const StorageBar = ({ usedBytes, maxBytes }) => {
|
||||
const pct = Math.min(100, (usedBytes / maxBytes) * 100);
|
||||
const isOver = usedBytes > maxBytes;
|
||||
const isWarn = pct > 75 && !isOver;
|
||||
|
||||
return (
|
||||
<div className="mt-3 p-4 border rounded-md bg-muted/50 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<Database className="size-4" /> Total size
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-sm font-medium",
|
||||
isOver && "text-destructive",
|
||||
isWarn && "text-amber-600 dark:text-amber-400",
|
||||
!isOver && !isWarn && "text-muted-foreground"
|
||||
)}>
|
||||
{formatBytes(usedBytes)} of {formatBytes(maxBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={pct}
|
||||
className={cn(
|
||||
"h-1.5",
|
||||
isOver && "[&>div]:bg-destructive",
|
||||
isWarn && "[&>div]:bg-amber-500"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── FileUpload ────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Standalone file upload UI — no modal, no footer buttons.
|
||||
* Compose inside <ResponsiveModal> or any container.
|
||||
*
|
||||
* Props:
|
||||
* maxBytes {number} – total size cap (default: 500 MB)
|
||||
* accept {string} – native <input accept> string (fallback if
|
||||
* allowedFileTypes not provided)
|
||||
* hint {string} – dropzone helper text (fallback if
|
||||
@@ -135,12 +96,11 @@ const StorageBar = ({ usedBytes, maxBytes }) => {
|
||||
* maxFileCount {number} – max number of files allowed. Displayed in
|
||||
* the hint and enforced client-side on file add.
|
||||
* onChange {function} – fires on every file list change:
|
||||
* ({ files, isUploading, isOverLimit }) => void
|
||||
* ({ files, isUploading }) => void
|
||||
* onUploadDone {function} – fires when all uploads finish:
|
||||
* ({ files }) => void
|
||||
*/
|
||||
const FileUpload = ({
|
||||
maxBytes = DEFAULT_MAX_BYTES,
|
||||
accept,
|
||||
hint = "PDF, DOCX, MP4, PNG, JPG",
|
||||
allowedFileTypes,
|
||||
@@ -152,8 +112,6 @@ const FileUpload = ({
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const totalBytes = files.reduce((sum, f) => sum + (f.bytes || 0), 0);
|
||||
const isOverLimit = totalBytes > maxBytes;
|
||||
const isUploading = files.some((f) => f.status === "uploading");
|
||||
|
||||
// ── Derived accept string ───────────────────────────────────────────────────
|
||||
@@ -177,8 +135,7 @@ const FileUpload = ({
|
||||
// Notify parent with full state
|
||||
const notify = (next) => {
|
||||
const uploading = next.some((f) => f.status === "uploading");
|
||||
const overLimit = next.reduce((s, f) => s + (f.bytes || 0), 0) > maxBytes;
|
||||
onChange?.({ files: next, isUploading: uploading, isOverLimit: overLimit });
|
||||
onChange?.({ files: next, isUploading: uploading });
|
||||
};
|
||||
|
||||
// ── simulate upload progress ──────────────────────────────────────────────
|
||||
@@ -204,7 +161,7 @@ const FileUpload = ({
|
||||
});
|
||||
};
|
||||
setTimeout(tick, 300);
|
||||
}, [onChange, onUploadDone, maxBytes]);
|
||||
}, [onChange, onUploadDone]);
|
||||
|
||||
// ── add files ─────────────────────────────────────────────────────────────
|
||||
const addFiles = useCallback((rawFiles) => {
|
||||
@@ -277,7 +234,7 @@ const FileUpload = ({
|
||||
return next;
|
||||
});
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}, [simulateUpload, onChange, maxBytes, allowedFileTypes, maxFileCount]);
|
||||
}, [simulateUpload, onChange, allowedFileTypes, maxFileCount]);
|
||||
|
||||
// ── remove ────────────────────────────────────────────────────────────────
|
||||
const removeFile = (id) => {
|
||||
@@ -315,7 +272,7 @@ const FileUpload = ({
|
||||
<CloudUpload className={cn("size-8 mx-auto mb-3", isDragging ? "text-blue-500" : "text-muted-foreground")} />
|
||||
<p className="text-sm font-medium">Drop files here or click to browse</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Upload your work to submit with this task</p>
|
||||
<p className="text-sm mt-2">{derivedHint} · Max total: {formatBytes(maxBytes)}</p>
|
||||
<p className="text-sm mt-2">{derivedHint}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -348,18 +305,6 @@ const FileUpload = ({
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-2">{derivedHint}</p>
|
||||
|
||||
<StorageBar usedBytes={totalBytes} maxBytes={maxBytes} />
|
||||
|
||||
{isOverLimit && (
|
||||
<div className="mt-2.5 flex items-start gap-2 px-3 py-2.5 rounded-md bg-destructive/10 border border-destructive/30">
|
||||
<AlertTriangle className="size-4 text-destructive shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-destructive leading-relaxed">
|
||||
Total file size exceeds the <strong>{formatBytes(maxBytes)}</strong> limit.
|
||||
Please remove some files before turning in.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -376,4 +321,4 @@ const FileUpload = ({
|
||||
};
|
||||
|
||||
export default FileUpload;
|
||||
export { FileUpload, FileItem, FileIcon, StorageBar, formatBytes, iconForFile };
|
||||
export { FileUpload, FileItem, FileIcon, formatBytes, iconForFile };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,20 +9,24 @@ import { Button } from "@/components/ui/button";
|
||||
import { SendHorizonal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import api from "@/utils/api.util";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
|
||||
const LVL_LABEL = { beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced' };
|
||||
|
||||
function TierBadge({ tier, locked = false }) {
|
||||
if (tier === 'premium')
|
||||
return <Badge className="gap-1 bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Premium</Badge>;
|
||||
if (tier === 'exclusive')
|
||||
return <Badge className="gap-1 bg-gradient-to-r from-rose-500 to-red-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Exclusive</Badge>;
|
||||
if (!locked)
|
||||
return <Badge className="gap-1 bg-green-500 text-white border-0 w-fit shrink-0"><Tag className="size-3" /> Free</Badge>;
|
||||
return null;
|
||||
const { tierMap } = useClientTiers();
|
||||
const { rank, label, cls } = resolveTierBadge(tier ?? 'free', tierMap);
|
||||
if (rank === 0 && !locked) return null;
|
||||
return (
|
||||
<Badge className={`gap-1 ${cls} w-fit shrink-0`}>
|
||||
{rank > 0 ? <Lock className="size-3" /> : <Tag className="size-3" />}
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const ReadCourse = ({ title = "Read Course", courses = [] }) => {
|
||||
const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId, taskId }) => {
|
||||
const navigate = useNavigate();
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [details, setDetails] = useState({});
|
||||
@@ -173,7 +177,21 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-base font-semibold leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors">
|
||||
<h1
|
||||
onClick={(e) => {
|
||||
if (!taskId || !groupId || !taskListId) return;
|
||||
e.stopPropagation();
|
||||
navigate(
|
||||
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
|
||||
{ state: { course: { id: course.id, reference_id: course.reference_id, title: course.title } } }
|
||||
);
|
||||
}}
|
||||
className={`text-base font-semibold leading-snug line-clamp-2 transition-colors ${
|
||||
taskId
|
||||
? 'text-blue-600 dark:text-blue-400 hover:underline cursor-pointer'
|
||||
: 'group-hover:text-blue-700 dark:group-hover:text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{course.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1">
|
||||
@@ -223,7 +241,19 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
|
||||
<Button variant="outline" onClick={() => setSelected(null)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (info?.course_id) navigate(`/course/${info.course_id}/unit`, { state: allRead ? { seekFirstIncomplete: true } : undefined });
|
||||
if (!info?.course_id) return;
|
||||
if (taskId && groupId && taskListId) {
|
||||
// Task context — read inside ViewRequirement
|
||||
navigate(
|
||||
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
|
||||
{ state: { course: { id: selected.id, reference_id: selected.reference_id, title: selected.title } } }
|
||||
);
|
||||
} else {
|
||||
// No task context — fall back to standalone course reader
|
||||
navigate(`/course/${info.course_id}/unit`, {
|
||||
state: allRead ? { seekFirstIncomplete: true } : {},
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={done || !info?.course_id}
|
||||
>
|
||||
|
||||
@@ -2,17 +2,22 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { FileText, CheckCheck, Lock, Zap, Info } from "lucide-react";
|
||||
import { FileText, CheckCheck, Lock, Zap, Info, Tag } from "lucide-react";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import api from "@/utils/api.util";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
|
||||
function TierBadge({ tier }) {
|
||||
if (tier === 'premium')
|
||||
return <Badge className="gap-1 bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Premium</Badge>;
|
||||
if (tier === 'exclusive')
|
||||
return <Badge className="gap-1 bg-gradient-to-r from-rose-500 to-red-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Exclusive</Badge>;
|
||||
return null;
|
||||
const { tierMap } = useClientTiers();
|
||||
const { rank, label, cls } = resolveTierBadge(tier ?? 'free', tierMap);
|
||||
return (
|
||||
<Badge className={`gap-1 ${cls} w-fit shrink-0`}>
|
||||
{rank > 0 ? <Lock className="size-3" /> : <Tag className="size-3" />}
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId, taskId }) => {
|
||||
@@ -156,6 +161,12 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileText className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs text-muted-foreground font-medium">Lesson</span>
|
||||
<div className="ml-auto">
|
||||
{info?.unit?.course?.subscription
|
||||
? <TierBadge tier={info.unit.course.subscription} />
|
||||
: isFetching && <Badge variant="secondary" className="opacity-40 text-xs">Loading…</Badge>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Course breadcrumb */}
|
||||
@@ -166,7 +177,7 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h1 className="text-base font-semibold leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors">
|
||||
<h1 className="text-base font-semibold leading-snug line-clamp-2 text-blue-600 dark:text-blue-400 hover:underline transition-colors cursor-pointer">
|
||||
{lesson.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1">
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Layers, CheckCheck, RefreshCw, SendHorizonal, Lock, Zap, Info } from "lucide-react";
|
||||
import { Layers, CheckCheck, RefreshCw, SendHorizonal, Lock, Zap, Info, Tag } from "lucide-react";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "@/utils/api.util";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
|
||||
function TierBadge({ tier }) {
|
||||
if (tier === 'premium')
|
||||
return <Badge className="gap-1 bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Premium</Badge>;
|
||||
if (tier === 'exclusive')
|
||||
return <Badge className="gap-1 bg-gradient-to-r from-rose-500 to-red-600 text-white border-0 w-fit shrink-0"><Lock className="size-3" /> Exclusive</Badge>;
|
||||
return null;
|
||||
const { tierMap } = useClientTiers();
|
||||
const { rank, label, cls } = resolveTierBadge(tier ?? 'free', tierMap);
|
||||
return (
|
||||
<Badge className={`gap-1 ${cls} w-fit shrink-0`}>
|
||||
{rank > 0 ? <Lock className="size-3" /> : <Tag className="size-3" />}
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskId }) => {
|
||||
@@ -172,10 +177,36 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs text-muted-foreground font-medium">Unit</span>
|
||||
<div className="ml-auto">
|
||||
{info?.course?.subscription
|
||||
? <TierBadge tier={info.course.subscription} />
|
||||
: isFetching && <Badge variant="secondary" className="opacity-40 text-xs">Loading…</Badge>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{info?.course?.title && (
|
||||
<p className="text-xs text-muted-foreground truncate -mt-1">
|
||||
from <span className="text-foreground/70 font-medium">{info.course.title}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h1 className="text-base font-semibold leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors">
|
||||
<h1
|
||||
onClick={(e) => {
|
||||
if (!taskId || !groupId || !taskListId) return;
|
||||
e.stopPropagation();
|
||||
navigate(
|
||||
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
|
||||
{ state: { unit } }
|
||||
);
|
||||
}}
|
||||
className={`text-base font-semibold leading-snug line-clamp-2 transition-colors ${
|
||||
taskId
|
||||
? 'text-blue-600 dark:text-blue-400 hover:underline cursor-pointer'
|
||||
: 'group-hover:text-blue-700 dark:group-hover:text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{unit.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2 mt-1">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExternalLink, CheckCheck } from "lucide-react";
|
||||
import { ExternalLink, CheckCheck, RefreshCcw } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
@@ -22,25 +22,32 @@ const normalizeUrl = (url) => {
|
||||
|
||||
// ── Meta fetcher ──────────────────────────────────────────────────────────────
|
||||
const fetchLinkMeta = async (url) => {
|
||||
const normalized = normalizeUrl(url);
|
||||
try {
|
||||
const res = await fetch(`https://api.microlink.io/?url=${encodeURIComponent(url)}`);
|
||||
const res = await fetch(`https://api.microlink.io/?url=${encodeURIComponent(normalized)}`);
|
||||
const json = await res.json();
|
||||
if (json.status === "success") {
|
||||
return {
|
||||
title: json.data.title ?? null,
|
||||
title: json.data.title ?? null,
|
||||
description: json.data.description ?? null,
|
||||
image: json.data.image?.url ?? json.data.logo?.url ?? null,
|
||||
image: json.data.image?.url ?? json.data.logo?.url ?? null,
|
||||
};
|
||||
}
|
||||
} catch { /* silently fail */ }
|
||||
return { title: null, description: null, image: null };
|
||||
};
|
||||
|
||||
const getDomain = (url) => {
|
||||
try { return new URL(normalizeUrl(url)).hostname.replace(/^www\./, ''); }
|
||||
catch { return url; }
|
||||
};
|
||||
|
||||
// ── LinkCard ──────────────────────────────────────────────────────────────────
|
||||
const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
|
||||
const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting }) => {
|
||||
const [meta, setMeta] = useState({ title: null, description: null, image: null });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [viewModalOpen, setViewModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!link.url) return;
|
||||
@@ -49,8 +56,10 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
|
||||
.finally(() => setLoading(false));
|
||||
}, [link.url]);
|
||||
|
||||
const displayImage = meta.image ?? `https://avatar.vercel.sh/${encodeURIComponent(link.url)}`;
|
||||
const displayTitle = meta.title ?? link.label;
|
||||
const domain = getDomain(link.url);
|
||||
const displayImage = meta.image ?? null;
|
||||
const displayFavicon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`;
|
||||
const displayTitle = meta.title ?? link.label ?? domain;
|
||||
const displayDescription = meta.description ?? link.url;
|
||||
|
||||
const handleTurnIn = async () => {
|
||||
@@ -58,20 +67,33 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const handleUnsubmit = async () => {
|
||||
await onUnvisit(link.requirement_id);
|
||||
setViewModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="relative w-72 shrink-0 pt-0">
|
||||
{loading ? (
|
||||
<div className="relative z-20 h-40 w-full rounded-t-lg bg-muted animate-pulse" />
|
||||
) : (
|
||||
<div className="h-40 w-full rounded-t-lg bg-muted animate-pulse" />
|
||||
) : displayImage ? (
|
||||
<img
|
||||
src={displayImage}
|
||||
alt={displayTitle}
|
||||
className="relative z-20 h-40 w-full object-cover rounded-t-lg"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = `https://avatar.vercel.sh/${encodeURIComponent(link.url)}`;
|
||||
}}
|
||||
className="h-40 w-full object-cover rounded-t-lg"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-40 w-full rounded-t-lg bg-muted flex flex-col items-center justify-center gap-2">
|
||||
<img
|
||||
src={displayFavicon}
|
||||
alt={domain}
|
||||
className="w-12 h-12 rounded-xl"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground font-medium">{domain}</span>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle className="line-clamp-1">
|
||||
@@ -89,9 +111,8 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
{visited ? (
|
||||
<Button className="w-full" variant="secondary" disabled>
|
||||
<CheckCheck className="size-4" />
|
||||
Visited
|
||||
<Button variant="secondary" className="w-full" onClick={() => setViewModalOpen(true)}>
|
||||
<CheckCheck /> Visited
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-full" onClick={() => setModalOpen(true)}>
|
||||
@@ -101,16 +122,15 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Turn-in modal — first visit */}
|
||||
<ResponsiveModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
title={`Visit Link: ${displayTitle}`}
|
||||
description={`By visiting a link, you are about to explore it then Turn-in after.`}
|
||||
description="By visiting a link, you are about to explore it then Turn-in after."
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleTurnIn} disabled={submitting}>
|
||||
<SendHorizonal /> {submitting ? "Submitting…" : "Turn In"}
|
||||
</Button>
|
||||
@@ -121,12 +141,52 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
|
||||
<p className="text-xs text-muted-foreground break-all">{link.url}</p>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="size-4" />
|
||||
Open Link
|
||||
<ExternalLink className="size-4" /> Open Link
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
|
||||
{/* View modal — after visited, with Resubmit */}
|
||||
<ResponsiveModal
|
||||
open={viewModalOpen}
|
||||
onOpenChange={setViewModalOpen}
|
||||
title={displayTitle}
|
||||
description={displayDescription}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setViewModalOpen(false)}>Close</Button>
|
||||
<Button asChild variant="outline">
|
||||
<a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="size-4" /> Open Link
|
||||
</a>
|
||||
</Button>
|
||||
<Button onClick={handleUnsubmit} disabled={unsubmitting} variant="destructive">
|
||||
<RefreshCcw className="size-4" /> {unsubmitting ? "Removing…" : "Unsubmit"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{displayImage ? (
|
||||
<img
|
||||
src={displayImage}
|
||||
alt={displayTitle}
|
||||
className="w-full h-40 object-cover rounded-lg"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-40 rounded-lg bg-muted flex flex-col items-center justify-center gap-2">
|
||||
<img src={displayFavicon} alt={domain} className="w-12 h-12 rounded-xl" onError={(e) => { e.currentTarget.style.display = 'none'; }} />
|
||||
<span className="text-xs text-muted-foreground font-medium">{domain}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 bg-green-500/10 text-green-600 dark:text-green-400 rounded-lg px-4 py-3 text-sm font-medium">
|
||||
<CheckCheck className="size-4 shrink-0" /> Already submitted — you can unsubmit if needed.
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground break-all">{link.url}</p>
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -140,16 +200,20 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
|
||||
* onVisit {function} – async (requirement_id) => void
|
||||
* called when user confirms "Turn In"
|
||||
*/
|
||||
const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit }) => {
|
||||
const [submittingId, setSubmittingId] = useState(null);
|
||||
const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit, onUnvisit }) => {
|
||||
const [submittingId, setSubmittingId] = useState(null);
|
||||
const [unsubmittingId, setUnsubmittingId] = useState(null);
|
||||
|
||||
const handleTurnIn = async (requirementId) => {
|
||||
setSubmittingId(requirementId);
|
||||
try {
|
||||
await onVisit?.(requirementId);
|
||||
} finally {
|
||||
setSubmittingId(null);
|
||||
}
|
||||
try { await onVisit?.(requirementId); }
|
||||
finally { setSubmittingId(null); }
|
||||
};
|
||||
|
||||
const handleUnvisit = async (requirementId) => {
|
||||
setUnsubmittingId(requirementId);
|
||||
try { await onUnvisit?.(requirementId); }
|
||||
finally { setUnsubmittingId(null); }
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -167,7 +231,9 @@ const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit
|
||||
link={link}
|
||||
visited={!!visitedMap[link.requirement_id]}
|
||||
onTurnIn={handleTurnIn}
|
||||
onUnvisit={handleUnvisit}
|
||||
submitting={submittingId === link.requirement_id}
|
||||
unsubmitting={unsubmittingId === link.requirement_id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useProfile } from "@/contexts/ProfileProvider"
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider"
|
||||
import { useGroup } from "@/contexts/ClientGroupContext"
|
||||
import { useEffect, useState } from "react"
|
||||
import { ROLE_CONFIG, AVATAR_COLORS } from "@/data/profile.data"
|
||||
import { AVATAR_COLORS } from "@/data/profile.data"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import ClientNotificationBell from "@/components/generic/ClientNotificationBell"
|
||||
import { QRCodeCanvas } from "qrcode.react"
|
||||
@@ -141,7 +141,7 @@ function ClientNav() {
|
||||
|
||||
// Background fetches only — nav rendering never waits on these
|
||||
const { achievements, getAchievements } = useProfile()
|
||||
const { myTier, getMyTier } = useClientTiers()
|
||||
const { myTier, getMyTier, getTierCategories } = useClientTiers()
|
||||
|
||||
const [referOpen, setReferOpen] = useState(false)
|
||||
|
||||
@@ -149,6 +149,7 @@ function ClientNav() {
|
||||
if (!user) return;
|
||||
if (achievements.length === 0) getAchievements();
|
||||
if (!myTier) getMyTier();
|
||||
getTierCategories();
|
||||
}, [user]);
|
||||
|
||||
// ── Derive directly from auth user — same pattern as admin UserMenu ──────
|
||||
@@ -158,9 +159,17 @@ function ClientNav() {
|
||||
const fullName = given && last ? `${given} ${last}` : (user?.email ?? "")
|
||||
const avatarUrl = user?.personal_info?.avatar?.url ?? user?.personal_info?.avatar ?? ""
|
||||
const email = user?.email ?? ""
|
||||
const role = ROLE_CONFIG[user?.acc_type] ?? { label: user?.acc_type, variant: 'outline' }
|
||||
const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground'
|
||||
|
||||
const TIER_NAV_BADGE = {
|
||||
free: { label: 'Free', className: '' },
|
||||
premium: { label: 'Premium Access', className: 'bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white border-0' },
|
||||
exclusive: { label: 'Exclusive Access', className: 'bg-gradient-to-r from-rose-500 to-red-600 text-white border-0' },
|
||||
}
|
||||
const tierBadge = myTier?.status === 'active'
|
||||
? (TIER_NAV_BADGE[myTier.tier] ?? TIER_NAV_BADGE.free)
|
||||
: TIER_NAV_BADGE.free
|
||||
|
||||
const initials = given && last
|
||||
? (given[0] + last[0]).toUpperCase()
|
||||
: getInitials(fullName)
|
||||
@@ -201,9 +210,11 @@ function ClientNav() {
|
||||
</svg>
|
||||
</div>
|
||||
<div id="name" className="lg:-ml-1 font-medium text-sm flex items-center gap-2">
|
||||
<Badge variant={role.variant} className="xs:hidden md:block capitalize">
|
||||
{role.label}
|
||||
</Badge>
|
||||
{tierBadge && (
|
||||
<Badge className={`xs:hidden md:block ${tierBadge.className}`}>
|
||||
{tierBadge.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { KeyRound, CreditCard, Mail, ChevronRight, Eye, EyeOff, ShieldAlert } from "lucide-react";
|
||||
import { KeyRound, CreditCard, Mail, ChevronRight, Eye, EyeOff, ShieldAlert, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -9,9 +9,20 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useProfile } from "@/contexts/ProfileProvider";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import api from "@/utils/api.util";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -132,6 +143,7 @@ const TIER_COLORS = {
|
||||
function SubscriptionSection() {
|
||||
const navigate = useNavigate();
|
||||
const { myTier, tierLoading, getMyTier, payments, paymentsLoading, getMyPayments } = useClientTiers();
|
||||
const { fmtDate, fmtNumber } = useDateFormat();
|
||||
|
||||
useEffect(() => {
|
||||
getMyTier();
|
||||
@@ -139,9 +151,7 @@ function SubscriptionSection() {
|
||||
}, []);
|
||||
|
||||
const tier = myTier?.tier ?? "free";
|
||||
const expiresAt = myTier?.expires_at
|
||||
? new Date(myTier.expires_at).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })
|
||||
: null;
|
||||
const expiresAt = myTier?.expires_at ? fmtDate(myTier.expires_at) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -192,11 +202,11 @@ function SubscriptionSection() {
|
||||
{payments.map((p) => (
|
||||
<tr key={p.payment_id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{new Date(p.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
||||
{fmtDate(p.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3 capitalize">{p.plan?.tier ?? "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
{p.currency} {Number(p.amount ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}
|
||||
{p.currency} {fmtNumber(p.amount ?? 0)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={p.status === "completed" ? "outline" : ""} className="capitalize text-xs">
|
||||
@@ -268,6 +278,71 @@ function NewsletterSection() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Delete Account ───────────────────────────────────────────────────────────
|
||||
|
||||
function DeleteAccountSection({ logout }) {
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.delete("/client/profile");
|
||||
toast.success("Account deleted. Goodbye!");
|
||||
await logout();
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not delete account.");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-destructive">Delete account</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Permanently remove your account and all associated data. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1.5" />
|
||||
Delete account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete your account and sign you out of all sessions.
|
||||
Your data cannot be recovered after deletion.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{loading ? "Deleting…" : "Yes, delete my account"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AccountSettings() {
|
||||
@@ -292,6 +367,10 @@ export default function AccountSettings() {
|
||||
<Section icon={Mail} title="Newsletter" description="Choose what emails you want to receive from us.">
|
||||
<NewsletterSection />
|
||||
</Section>
|
||||
|
||||
<Section icon={Trash2} title="Danger Zone" description="Irreversible account actions.">
|
||||
<DeleteAccountSection logout={logout} />
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,11 +18,7 @@ import {
|
||||
House, Loader2, ShieldCheck, Tag, Zap, LockIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
function formatPrice(price = 0, currency = "USD") {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency", currency, minimumFractionDigits: 2,
|
||||
}).format(Number(price) || 0);
|
||||
}
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
function formatDuration(days) {
|
||||
if (!days) return "Lifetime";
|
||||
@@ -66,6 +62,7 @@ const CheckoutSkeleton = () => (
|
||||
const Checkout = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { fmtCurrency } = useDateFormat();
|
||||
|
||||
const planId = searchParams.get("plan_id");
|
||||
const returnToken = searchParams.get("token");
|
||||
@@ -237,7 +234,7 @@ const Checkout = () => {
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-primary">
|
||||
{formatPrice(plan.price, plan.currency)}
|
||||
{fmtCurrency(plan.price, plan.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,12 +309,12 @@ const Checkout = () => {
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-muted-foreground">Plan Price</span>
|
||||
<span className="font-medium">{formatPrice(subtotal, plan.currency)}</span>
|
||||
<span className="font-medium">{fmtCurrency(subtotal, plan.currency)}</span>
|
||||
</div>
|
||||
{isPromoApplied && (
|
||||
<div className="flex justify-between gap-4 text-green-600">
|
||||
<span>Promo Discount (PHIL10)</span>
|
||||
<span>-{formatPrice(discount, plan.currency)}</span>
|
||||
<span>-{fmtCurrency(discount, plan.currency)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -348,7 +345,7 @@ const Checkout = () => {
|
||||
|
||||
<div className="flex justify-between items-center text-lg font-semibold">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(total, plan.currency)}</span>
|
||||
<span>{fmtCurrency(total, plan.currency)}</span>
|
||||
</div>
|
||||
|
||||
{isCurrent ? (
|
||||
@@ -366,7 +363,7 @@ const Checkout = () => {
|
||||
? <Loader2 className="size-4 animate-spin" />
|
||||
: <ShieldCheck className="size-4" />
|
||||
}
|
||||
Pay {formatPrice(total, plan.currency)} with PayPal
|
||||
Pay {fmtCurrency(total, plan.currency)} with PayPal
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,12 +9,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useClientCourses } from "@/contexts/ClientCoursesContext";
|
||||
|
||||
function formatPrice(price = 0, currency = "USD") {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency", currency, minimumFractionDigits: 2,
|
||||
}).format(Number(price) || 0);
|
||||
}
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
function formatAccess(days) {
|
||||
if (!days) return "Lifetime access";
|
||||
@@ -38,6 +33,7 @@ const PageSkeleton = () => (
|
||||
export default function CourseCheckout() {
|
||||
const navigate = useNavigate();
|
||||
const { id: courseId } = useParams();
|
||||
const { fmtCurrency } = useDateFormat();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const returnToken = searchParams.get("token");
|
||||
@@ -181,14 +177,14 @@ export default function CourseCheckout() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Course Price</span>
|
||||
<span className="font-medium">{formatPrice(product.price, product.currency)}</span>
|
||||
<span className="font-medium">{fmtCurrency(product.price, product.currency)}</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-between items-center text-lg font-semibold">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(product.price, product.currency)}</span>
|
||||
<span>{fmtCurrency(product.price, product.currency)}</span>
|
||||
</div>
|
||||
|
||||
{course?.has_purchased ? (
|
||||
@@ -206,7 +202,7 @@ export default function CourseCheckout() {
|
||||
? <Loader2 className="size-4 animate-spin" />
|
||||
: <ShieldCheck className="size-4" />
|
||||
}
|
||||
Pay {formatPrice(product.price, product.currency)} with PayPal
|
||||
Pay {fmtCurrency(product.price, product.currency)} with PayPal
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import api from "@/utils/api.util";
|
||||
import {
|
||||
House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon,
|
||||
SendHorizonal, CheckCheck, CheckCircle2, Clock,
|
||||
@@ -21,6 +23,7 @@ import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgressContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { toast } from "sonner";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -155,11 +158,8 @@ const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle,
|
||||
|
||||
// ─── Certificate Card ─────────────────────────────────────────────────────────
|
||||
|
||||
function fmtDate(iso) {
|
||||
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
const CertCard = ({ courseTitle, courseLevel, pendingCert, certificate, delay, nodeRef }) => {
|
||||
const { fmtDate } = useDateFormat();
|
||||
const isIssued = !!certificate;
|
||||
const isPending = !isIssued && !!pendingCert;
|
||||
|
||||
@@ -382,6 +382,17 @@ const CourseDetails = () => {
|
||||
const { myTier, getMyTier } = useClientTiers();
|
||||
const { fetchCourseProgress, isCompleted, resetProgress } = useCourseReadingProgress();
|
||||
|
||||
const [tierMap, setTierMap] = useState({});
|
||||
useEffect(() => {
|
||||
api.get("/client/tiers/categories")
|
||||
.then(({ data }) => {
|
||||
const m = {};
|
||||
(data.data ?? []).forEach((c) => { m[c.slug] = c; });
|
||||
setTierMap(m);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const hasCompleted = !!course?.is_completed;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -458,17 +469,11 @@ const CourseDetails = () => {
|
||||
<div className="flex lg:flex-row items-start justify-between w-full">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{course?.plan_tier && course.plan_tier !== "free" ? (
|
||||
<Badge className={
|
||||
course.plan_tier === "premium"
|
||||
? "bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white"
|
||||
: "bg-gradient-to-r from-rose-500 to-red-600 text-white"
|
||||
}>
|
||||
{course.plan_tier.charAt(0).toUpperCase() + course.plan_tier.slice(1)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-gradient-to-r from-lime-400 to-lime-600 text-white">Free</Badge>
|
||||
)}
|
||||
{(() => {
|
||||
const slug = course?.plan_tier ?? course?.subscription ?? "free";
|
||||
const { label, cls } = resolveTierBadge(slug, tierMap);
|
||||
return <Badge className={cls}>{label}</Badge>;
|
||||
})()}
|
||||
</div>
|
||||
<h1 className="font-bold text-4xl">{course?.title ?? "Course Title"}</h1>
|
||||
<p className="max-w-2xl lg:text-lg">{course?.description ?? ""}</p>
|
||||
|
||||
@@ -11,10 +11,12 @@ import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
import { useClientCourses } from "@/contexts/ClientCoursesContext";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Fragment } from "react";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import api from "@/utils/api.util";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -29,21 +31,13 @@ function formatDuration(seconds = 0) {
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
// ─── Tier access check ────────────────────────────────────────────────────────
|
||||
// Returns true if the user's active tier can access the course's plan_tier
|
||||
function canAccess(userTier, planTier) {
|
||||
if (!planTier || planTier === "free") return true;
|
||||
if (planTier === "premium") return userTier === "premium" || userTier === "exclusive";
|
||||
if (planTier === "exclusive") return userTier === "exclusive";
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Course Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
const CourseCard = ({ course, onViewDetails }) => {
|
||||
const type = course.plan_tier ?? "free";
|
||||
const locked = course.is_locked;
|
||||
const CourseCard = ({ course, tierMap, onViewDetails }) => {
|
||||
const slug = course.subscription ?? "free";
|
||||
const locked = course.is_locked;
|
||||
const duration = formatDuration(course.duration_seconds);
|
||||
const { rank, label, cls } = resolveTierBadge(slug, tierMap);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -57,31 +51,10 @@ const CourseCard = ({ course, onViewDetails }) => {
|
||||
onClick={() => onViewDetails(course)}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{type === "free" && (
|
||||
<Badge className="bg-green-500 text-white">
|
||||
<Tag /> Free
|
||||
</Badge>
|
||||
)}
|
||||
{type === "premium" && !locked && (
|
||||
<Badge className="bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white">
|
||||
<Tag /> Premium
|
||||
</Badge>
|
||||
)}
|
||||
{type === "premium" && locked && (
|
||||
<Badge className="bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white">
|
||||
<LockIcon /> Premium
|
||||
</Badge>
|
||||
)}
|
||||
{type === "exclusive" && !locked && (
|
||||
<Badge className="bg-gradient-to-r from-rose-500 to-red-600 text-white">
|
||||
<LockIcon /> Exclusive
|
||||
</Badge>
|
||||
)}
|
||||
{type === "exclusive" && locked && (
|
||||
<Badge className="bg-gradient-to-r from-rose-500 to-red-600 text-white">
|
||||
<LockIcon /> Exclusive
|
||||
</Badge>
|
||||
)}
|
||||
<Badge className={cls}>
|
||||
{locked ? <LockIcon className="size-3" /> : <Tag className="size-3" />}
|
||||
{label}
|
||||
</Badge>
|
||||
{course.level && (
|
||||
<Badge variant="outline">{course.level.charAt(0).toUpperCase() + course.level.slice(1)}</Badge>
|
||||
)}
|
||||
@@ -137,7 +110,7 @@ const CourseCardSkeleton = () => (
|
||||
|
||||
const Pagination = ({ currentPage, totalPages, totalItems, itemsPerPage, onPageChange }) => {
|
||||
const start = (currentPage - 1) * itemsPerPage + 1;
|
||||
const end = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
const end = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
|
||||
const getPages = () => {
|
||||
const pages = [];
|
||||
@@ -167,12 +140,7 @@ const Pagination = ({ currentPage, totalPages, totalItems, itemsPerPage, onPageC
|
||||
page === "..." ? (
|
||||
<span key={`ellipsis-${i}`} className="w-7 h-7 flex items-center justify-center text-xs text-muted-foreground">···</span>
|
||||
) : (
|
||||
<Button
|
||||
key={page}
|
||||
size="sm"
|
||||
variant={currentPage === page ? "default" : "outline"}
|
||||
onClick={() => onPageChange(page)}
|
||||
>
|
||||
<Button key={page} size="sm" variant={currentPage === page ? "default" : "outline"} onClick={() => onPageChange(page)}>
|
||||
{page}
|
||||
</Button>
|
||||
)
|
||||
@@ -190,8 +158,9 @@ const Pagination = ({ currentPage, totalPages, totalItems, itemsPerPage, onPageC
|
||||
const CoursesList = () => {
|
||||
const navigate = useNavigate();
|
||||
const { courses, coursesLoading, getCourses } = useClientCourses();
|
||||
const { myTier, getMyTier } = useClientTiers();
|
||||
const { fmtCurrency } = useDateFormat();
|
||||
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [levelFilter, setLevelFilter] = useState("All");
|
||||
@@ -200,29 +169,34 @@ const CoursesList = () => {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedCourse, setSelectedCourse] = useState(null);
|
||||
|
||||
// Collect unique categories from loaded courses
|
||||
const allCategories = useMemo(() => {
|
||||
const map = new Map();
|
||||
courses.forEach((c) => (c.categories ?? []).forEach((cat) => map.set(cat.id, cat)));
|
||||
return [...map.values()];
|
||||
}, [courses]);
|
||||
|
||||
const userTier = myTier?.tier ?? "free";
|
||||
|
||||
useEffect(() => {
|
||||
getCourses();
|
||||
if (!myTier) getMyTier();
|
||||
api.get("/client/tiers/categories")
|
||||
.then(({ data }) => setTierCategories(data.data ?? []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ── Filter + sort ─────────────────────────────────────────────────────────
|
||||
// slug → category info map
|
||||
const tierMap = useMemo(() => {
|
||||
const m = {};
|
||||
tierCategories.forEach((c) => { m[c.slug] = c; });
|
||||
return m;
|
||||
}, [tierCategories]);
|
||||
|
||||
// Collect unique product categories from loaded courses
|
||||
const allCategories = useMemo(() => {
|
||||
const map = new Map();
|
||||
courses.forEach((c) => (c.categories ?? []).forEach((cat) => map.set(cat.id, cat)));
|
||||
return [...map.values()];
|
||||
}, [courses]);
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
courses
|
||||
.filter((c) => {
|
||||
const matchSearch = c.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
const matchSearch = c.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(c.description ?? "").toLowerCase().includes(search.toLowerCase());
|
||||
const matchLevel = levelFilter === "All" || (c.level ?? "").toLowerCase() === levelFilter.toLowerCase();
|
||||
const matchSub = subFilter === "All" || c.subscription === subFilter.toLowerCase();
|
||||
const matchLevel = levelFilter === "All" || (c.level ?? "").toLowerCase() === levelFilter.toLowerCase();
|
||||
const matchSub = subFilter === "All" || c.subscription === subFilter;
|
||||
const matchCategory = categoryFilter === "All" || (c.categories ?? []).some((cat) => String(cat.id) === categoryFilter);
|
||||
return matchSearch && matchLevel && matchSub && matchCategory;
|
||||
})
|
||||
@@ -231,17 +205,10 @@ const CoursesList = () => {
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
|
||||
const paginated = filtered.slice(
|
||||
(currentPage - 1) * ITEMS_PER_PAGE,
|
||||
currentPage * ITEMS_PER_PAGE
|
||||
);
|
||||
|
||||
// ── Card click ────────────────────────────────────────────────────────────
|
||||
const paginated = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||
|
||||
const handleViewDetails = (course) => {
|
||||
// Re-check access using live tier — ignore is_locked from backend if tier has changed
|
||||
const accessible = canAccess(userTier, course.plan_tier);
|
||||
if (!accessible) {
|
||||
if (course.is_locked) {
|
||||
setSelectedCourse(course);
|
||||
setModalOpen(true);
|
||||
} else {
|
||||
@@ -254,6 +221,9 @@ const CoursesList = () => {
|
||||
{ label: "Courses" },
|
||||
];
|
||||
|
||||
// Upsell modal tier panel
|
||||
const upsellTier = selectedCourse ? tierMap[selectedCourse.subscription] : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageMeta title="Courses - STARR" description="Browse your available training courses." />
|
||||
@@ -282,39 +252,43 @@ const CoursesList = () => {
|
||||
<SelectItem value="Advanced">Advanced</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={subFilter} onValueChange={(v) => { setSubFilter(v); setCurrentPage(1); }}>
|
||||
<SelectTrigger className="w-full lg:w-48 bg-card">
|
||||
<SelectValue placeholder="Subscription" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All">All</SelectItem>
|
||||
<SelectItem value="Free">Free</SelectItem>
|
||||
<SelectItem value="Premium">Premium</SelectItem>
|
||||
{tierCategories.map((cat) => (
|
||||
<SelectItem key={cat.slug} value={cat.slug}>
|
||||
{cat.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category chips */}
|
||||
{/* Product category chips */}
|
||||
{allCategories.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
className={`px-3 py-1 rounded-full text-sm border transition-colors ${categoryFilter === "All" ? "bg-primary text-primary-foreground border-primary" : "bg-card border-border hover:bg-muted"}`}
|
||||
onClick={() => { setCategoryFilter("All"); setCurrentPage(1); }}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{allCategories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`px-3 py-1 rounded-full text-sm border transition-colors ${categoryFilter === String(cat.id) ? "bg-primary text-primary-foreground border-primary" : "bg-card border-border hover:bg-muted"}`}
|
||||
onClick={() => { setCategoryFilter(String(cat.id)); setCurrentPage(1); }}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
className={`px-3 py-1 rounded-full text-sm border transition-colors ${categoryFilter === "All" ? "bg-primary text-primary-foreground border-primary" : "bg-card border-border hover:bg-muted"}`}
|
||||
onClick={() => { setCategoryFilter("All"); setCurrentPage(1); }}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{allCategories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`px-3 py-1 rounded-full text-sm border transition-colors ${categoryFilter === String(cat.id) ? "bg-primary text-primary-foreground border-primary" : "bg-card border-border hover:bg-muted"}`}
|
||||
onClick={() => { setCategoryFilter(String(cat.id)); setCurrentPage(1); }}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Course Grid */}
|
||||
@@ -332,7 +306,12 @@ const CoursesList = () => {
|
||||
) : (
|
||||
<div className="xs:pt-12 lg:pt-0 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-4">
|
||||
{paginated.map((course) => (
|
||||
<CourseCard key={course.course_id} course={course} onViewDetails={handleViewDetails} />
|
||||
<CourseCard
|
||||
key={course.course_id}
|
||||
course={course}
|
||||
tierMap={tierMap}
|
||||
onViewDetails={handleViewDetails}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -349,7 +328,7 @@ const CoursesList = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upsell Modal — only for locked courses */}
|
||||
{/* Upsell Modal */}
|
||||
<ResponsiveModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
@@ -359,13 +338,13 @@ const CoursesList = () => {
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>Close</Button>
|
||||
{selectedCourse?.product?.is_active && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { setModalOpen(false); navigate(`/course/${selectedCourse.course_id}/checkout`); }}
|
||||
>
|
||||
<ShoppingCart className="size-4" />
|
||||
Buy {new Intl.NumberFormat("en-US", { style: "currency", currency: selectedCourse.product.currency ?? "USD" }).format(selectedCourse.product.price ?? 0)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { setModalOpen(false); navigate(`/course/${selectedCourse.course_id}/checkout`); }}
|
||||
>
|
||||
<ShoppingCart className="size-4" />
|
||||
Buy {fmtCurrency(selectedCourse.product.price ?? 0, selectedCourse.product.currency ?? "USD")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => { setModalOpen(false); navigate("/plans"); }}>
|
||||
<LockIcon /> View Plans
|
||||
@@ -374,43 +353,25 @@ const CoursesList = () => {
|
||||
}
|
||||
>
|
||||
<div className="space-y-6 py-2">
|
||||
{(selectedCourse?.plan_tier ?? "free") === "premium" && (
|
||||
<div className="p-5 bg-gradient-to-r from-fuchsia-50 to-purple-50 dark:from-fuchsia-950/30 dark:to-purple-950/30 rounded-2xl border border-fuchsia-200 dark:border-fuchsia-800">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Badge className="bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white">
|
||||
<Tag className="size-4" /> Premium
|
||||
</Badge>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4">
|
||||
<li className="flex items-center gap-2"><Check /> Lifetime access</li>
|
||||
<li className="flex items-center gap-2"><Check /> Downloadable resources</li>
|
||||
<li className="flex items-center gap-2"><Check /> Certificate of completion</li>
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upgrade to a Premium plan to unlock this course and all other premium content.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(selectedCourse?.plan_tier ?? "free") === "exclusive" && (
|
||||
<div className="p-5 bg-gradient-to-r from-rose-50 to-red-50 dark:from-rose-950/30 dark:to-red-950/30 rounded-2xl border border-rose-200 dark:border-rose-800">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Badge className="bg-gradient-to-r from-rose-500 to-red-600 text-white">
|
||||
<LockIcon className="size-4" /> Exclusive
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="bg-card border rounded-xl p-4 mb-4">
|
||||
<p className="text-sm font-medium flex items-center gap-2 text-rose-600">
|
||||
<LockIcon className="size-4" /> This is an exclusive course
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Only available to members with exclusive access
|
||||
{upsellTier && !upsellTier.is_default && (() => {
|
||||
const { cls, panel } = resolveTierBadge(selectedCourse?.subscription ?? "", tierMap);
|
||||
return (
|
||||
<div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Badge className={cls}>
|
||||
<LockIcon className="size-3" /> {upsellTier.name}
|
||||
</Badge>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4">
|
||||
<li className="flex items-center gap-2"><Check /> Access to {upsellTier.name} content</li>
|
||||
<li className="flex items-center gap-2"><Check /> Certificates & achievements</li>
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upgrade to a <span className="font-medium">{upsellTier.name}</span> plan to unlock this course.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Exclusive courses are granted through special programs, partnerships, or limited enrollment offerings.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
TableOfContents, Users, Timer,
|
||||
Users, Timer,
|
||||
Tag, LockIcon, Check,
|
||||
} from "lucide-react";
|
||||
import { ThemeSwitcher } from "../components/ThemeSwitcher";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table, TableHeader, TableBody,
|
||||
TableHead, TableRow, TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
import { useClientCourses } from "@/contexts/ClientCoursesContext";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useGroup } from "@/contexts/ClientGroupContext";
|
||||
import { useTask } from "@/contexts/ClientTaskContext";
|
||||
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
|
||||
import { Hero, HeroSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Hero";
|
||||
import { Popup } from "@/components/generic/Blocks/Client/Advertisements/Popup";
|
||||
@@ -32,20 +36,21 @@ function formatDuration(seconds = 0) {
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
// Mirrors the same access logic in CoursesList.jsx
|
||||
function canAccess(userTier, planTier) {
|
||||
if (!planTier || planTier === "free") return true;
|
||||
if (planTier === "premium") return userTier === "premium" || userTier === "exclusive";
|
||||
if (planTier === "exclusive") return userTier === "exclusive";
|
||||
return false;
|
||||
// Rank-based access: user's rank must be >= course's required rank.
|
||||
function canAccess(userTier, planTier, tierMap) {
|
||||
const courseRank = tierMap[planTier]?.rank ?? (planTier && planTier !== "free" ? Infinity : 0);
|
||||
const userRank = tierMap[userTier]?.rank ?? 0;
|
||||
return userRank >= courseRank;
|
||||
}
|
||||
|
||||
// ── Course Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
const CourseCard = ({ course, onViewDetails }) => {
|
||||
const type = course.plan_tier ?? "free";
|
||||
const locked = course.is_locked;
|
||||
const { tierMap } = useClientTiers();
|
||||
const slug = course.subscription ?? "free";
|
||||
const locked = course.is_locked;
|
||||
const duration = formatDuration(course.duration_seconds);
|
||||
const { rank, label, cls } = resolveTierBadge(slug, tierMap);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -59,21 +64,10 @@ const CourseCard = ({ course, onViewDetails }) => {
|
||||
onClick={() => onViewDetails(course)}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{type === "free" && (
|
||||
<Badge className="bg-green-500 text-white">
|
||||
<Tag /> Free
|
||||
</Badge>
|
||||
)}
|
||||
{type === "premium" && (
|
||||
<Badge className="bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white">
|
||||
{locked ? <LockIcon /> : <Tag />} Premium
|
||||
</Badge>
|
||||
)}
|
||||
{type === "exclusive" && (
|
||||
<Badge className="bg-gradient-to-r from-rose-500 to-red-600 text-white">
|
||||
<LockIcon /> Exclusive
|
||||
</Badge>
|
||||
)}
|
||||
<Badge className={cls}>
|
||||
{rank > 0 || locked ? <LockIcon className="size-3" /> : <Tag className="size-3" />}
|
||||
{label}
|
||||
</Badge>
|
||||
{course.level && (
|
||||
<Badge variant="outline">
|
||||
{course.level.charAt(0).toUpperCase() + course.level.slice(1)}
|
||||
@@ -127,20 +121,88 @@ const CourseCardSkeleton = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Groups Table ─────────────────────────────────────────────────────────────
|
||||
|
||||
const GroupsTable = ({ groups, onView }) => (
|
||||
<div className="bg-card border rounded-xl overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-10 text-center px-4">#</TableHead>
|
||||
<TableHead className="px-4">Group Name</TableHead>
|
||||
<TableHead className="px-4">Code</TableHead>
|
||||
<TableHead className="px-4 w-full">Description</TableHead>
|
||||
<TableHead className="px-4" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.map((g, i) => {
|
||||
const isDefault = g.group_code === 'NOGRP';
|
||||
return (
|
||||
<TableRow key={g.group_id}>
|
||||
<TableCell className="text-center px-4 text-muted-foreground tabular-nums">
|
||||
{i + 1}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 font-medium">{g.name}</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<Badge variant="outline" className="font-mono text-xs">{g.group_code}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-muted-foreground">
|
||||
{isDefault
|
||||
? <span className="text-xs italic">Awaiting assignment by admin</span>
|
||||
: (g.description ?? <span className="text-xs text-muted-foreground/50">—</span>)
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => onView(g)}>
|
||||
View
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
|
||||
const GroupsTableSkeleton = () => (
|
||||
<div className="bg-card border rounded-xl overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-10 text-center px-4">#</TableHead>
|
||||
<TableHead className="px-4">Group Name</TableHead>
|
||||
<TableHead className="px-4">Code</TableHead>
|
||||
<TableHead className="px-4 w-full">Description</TableHead>
|
||||
<TableHead className="px-4" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[1, 2].map((i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="text-center px-4"><Skeleton className="h-4 w-4 mx-auto" /></TableCell>
|
||||
<TableCell className="px-4"><Skeleton className="h-4 w-32" /></TableCell>
|
||||
<TableCell className="px-4"><Skeleton className="h-5 w-16 rounded-full" /></TableCell>
|
||||
<TableCell className="px-4"><Skeleton className="h-4 w-48" /></TableCell>
|
||||
<TableCell className="px-4 text-right"><Skeleton className="h-8 w-14 ml-auto" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Client Dashboard ─────────────────────────────────────────────────────────
|
||||
|
||||
const Client = () => {
|
||||
const navigate = useNavigate();
|
||||
const { state: navState } = useLocation();
|
||||
const { courses, coursesLoading, getCourses } = useClientCourses();
|
||||
const { myTier, getMyTier } = useClientTiers();
|
||||
const { myTier, getMyTier, tierMap } = useClientTiers();
|
||||
const { groups, fetchGroups, loading: groupLoading } = useGroup();
|
||||
const { fetchTaskLists } = useTask();
|
||||
const { advertisements, loading: adLoading, getActiveAdvertisement, trackClick } = useClientAdvertisements();
|
||||
|
||||
const [completedCount, setCompletedCount] = useState(0);
|
||||
const [dueSoonCount, setDueSoonCount] = useState(0);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedCourse, setSelectedCourse] = useState(null);
|
||||
|
||||
@@ -192,16 +254,14 @@ const Client = () => {
|
||||
|
||||
// Show only first 3
|
||||
const featuredCourses = courses.slice(0, 3);
|
||||
const myGroup = groups?.[0] ?? null;
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "My Group", icon: <Users className="size-4" />, to: `` },
|
||||
{ label: "Statistics" },
|
||||
{ label: "My Groups", icon: <Users className="size-4" /> },
|
||||
];
|
||||
|
||||
// ── Card click — mirrors CoursesList.jsx logic ────────────────────────────
|
||||
const handleViewDetails = (course) => {
|
||||
const accessible = canAccess(userTier, course.plan_tier);
|
||||
const accessible = canAccess(userTier, course.subscription, tierMap);
|
||||
if (!accessible) {
|
||||
setSelectedCourse(course);
|
||||
setModalOpen(true);
|
||||
@@ -211,43 +271,6 @@ const Client = () => {
|
||||
};
|
||||
|
||||
|
||||
// ── Fetch all task lists once group is known, count individual tasks ─────
|
||||
//
|
||||
// "Completed Tasks" and "Due soon" are TASK-level counts (not task-list-level),
|
||||
// so we fetch ALL task lists unfiltered, flatten every task across them, and
|
||||
// count by each task's own has_completed flag — regardless of which bucket
|
||||
// the task list as a whole falls into.
|
||||
|
||||
useEffect(() => {
|
||||
if (!myGroup?.group_id) return;
|
||||
|
||||
fetchTaskLists(myGroup.group_id).then((data) => {
|
||||
if (!data) return;
|
||||
|
||||
const allTasks = data.flatMap((taskList) => taskList.tasks ?? []);
|
||||
|
||||
// ── Completed Tasks: individual tasks with has_completed ───────────────
|
||||
const completed = allTasks.filter((task) => task.has_completed).length;
|
||||
setCompletedCount(completed);
|
||||
|
||||
// ── Due soon: incomplete tasks with deadline within 24h ────────────────
|
||||
const now = Date.now();
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
let dueSoon = 0;
|
||||
allTasks.forEach((task) => {
|
||||
if (!task.deadline) return;
|
||||
if (task.has_completed) return; // already submitted, skip
|
||||
const deadline = new Date(task.deadline).getTime();
|
||||
const diff = deadline - now;
|
||||
if (diff > 0 && diff <= DAY) dueSoon += 1;
|
||||
});
|
||||
setDueSoonCount(dueSoon);
|
||||
});
|
||||
}, [myGroup?.group_id]);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="my-20">
|
||||
@@ -260,52 +283,26 @@ const Client = () => {
|
||||
<Hero ad={heroAd} onCtaClick={handleAdCtaClick} />
|
||||
)}
|
||||
|
||||
{/* ── Group affiliated ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<AppBreadcrumb items={breadcrumbItems} />
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="w-full flex items-center justify-between">
|
||||
{groupLoading ? (
|
||||
<Skeleton className="h-7 w-40" />
|
||||
) : (
|
||||
<h1 className="text-2xl font-medium">{myGroup?.name ?? 'No Group'}</h1>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => myGroup && navigate(`/group/${myGroup.group_id}`)}
|
||||
disabled={!myGroup}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid xs:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<div className="bg-card shadow-md border rounded-md space-y-4 p-4">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button size="sm" variant="secondary">
|
||||
<TableOfContents />
|
||||
</Button>
|
||||
<h1>Completed Tasks</h1>
|
||||
</div>
|
||||
{groupLoading ? (
|
||||
<Skeleton className="h-8 w-10" />
|
||||
) : (
|
||||
<h1 className="font-medium text-2xl">{completedCount}</h1>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-card shadow-md border rounded-md space-y-4 p-4">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button size="sm" variant="secondary">
|
||||
<Timer />
|
||||
</Button>
|
||||
<h1>Due soon</h1>
|
||||
</div>
|
||||
{groupLoading ? (
|
||||
<Skeleton className="h-8 w-10" />
|
||||
) : (
|
||||
<h1 className="font-medium text-2xl">{dueSoonCount}</h1>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* ── My Groups ── */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<AppBreadcrumb items={breadcrumbItems} />
|
||||
{!groupLoading && groups.length > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{groups.length} group{groups.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{groupLoading ? (
|
||||
<GroupsTableSkeleton />
|
||||
) : groups.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">You are not assigned to any group.</p>
|
||||
) : (
|
||||
<GroupsTable
|
||||
groups={groups}
|
||||
onView={(g) => navigate(`/group/${g.group_id}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Featured Courses (first 3) ── */}
|
||||
@@ -363,43 +360,27 @@ const Client = () => {
|
||||
}
|
||||
>
|
||||
<div className="space-y-6 py-2">
|
||||
{(selectedCourse?.plan_tier ?? "free") === "premium" && (
|
||||
<div className="p-5 bg-gradient-to-r from-fuchsia-50 to-purple-50 dark:from-fuchsia-950/30 dark:to-purple-950/30 rounded-2xl border border-fuchsia-200 dark:border-fuchsia-800">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Badge className="bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white">
|
||||
<Tag className="size-4" /> Premium
|
||||
</Badge>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4">
|
||||
<li className="flex items-center gap-2"><Check /> Lifetime access</li>
|
||||
<li className="flex items-center gap-2"><Check /> Downloadable resources</li>
|
||||
<li className="flex items-center gap-2"><Check /> Certificate of completion</li>
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upgrade to a Premium plan to unlock this course and all other premium content.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(selectedCourse?.plan_tier ?? "free") === "exclusive" && (
|
||||
<div className="p-5 bg-gradient-to-r from-rose-50 to-red-50 dark:from-rose-950/30 dark:to-red-950/30 rounded-2xl border border-rose-200 dark:border-rose-800">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Badge className="bg-gradient-to-r from-rose-500 to-red-600 text-white">
|
||||
<LockIcon className="size-4" /> Exclusive
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="bg-card border rounded-xl p-4 mb-4">
|
||||
<p className="text-sm font-medium flex items-center gap-2 text-rose-600">
|
||||
<LockIcon className="size-4" /> This is an exclusive course
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Only available to members with exclusive access
|
||||
{(() => {
|
||||
const slug = selectedCourse?.subscription ?? "free";
|
||||
const { rank, label, cls, panel } = resolveTierBadge(slug, tierMap);
|
||||
if (rank === 0) return null;
|
||||
return (
|
||||
<div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Badge className={cls}>
|
||||
<LockIcon className="size-3" /> {label}
|
||||
</Badge>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4">
|
||||
<li className="flex items-center gap-2"><Check /> Access to {label} content</li>
|
||||
<li className="flex items-center gap-2"><Check /> Certificates & achievements</li>
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upgrade to a <span className="font-medium">{label}</span> plan to unlock this course.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Exclusive courses are granted through special programs, partnerships, or limited enrollment offerings.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useProfile } from "@/contexts/ProfileProvider";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
const ACHIEVEMENT_ICONS = {
|
||||
early_access: Star,
|
||||
@@ -26,6 +27,7 @@ const getFallbackIcon = (type) => type === "badge" ? BadgeCheck : Trophy;
|
||||
export default function MyAchievements() {
|
||||
const navigate = useNavigate();
|
||||
const { achievements, achievementsLoading, getAchievements } = useProfile();
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
useEffect(() => { getAchievements(); }, []);
|
||||
|
||||
@@ -81,9 +83,7 @@ export default function MyAchievements() {
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{item.description}</p>
|
||||
{item.granted_at && (
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
{new Date(item.granted_at).toLocaleDateString("en-US", {
|
||||
month: "long", day: "numeric", year: "numeric",
|
||||
})}
|
||||
{fmtDate(item.granted_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useProfile } from "@/contexts/ProfileProvider";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import api from "@/utils/api.util";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -25,10 +26,9 @@ const CertBadgeIcon = ({ className }) => (
|
||||
|
||||
const CertificateCard = ({ courseTitle, issuedAt, courseUuid }) => {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
const issuedLabel = issuedAt
|
||||
? new Date(issuedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
||||
: "—";
|
||||
const issuedLabel = issuedAt ? fmtDate(issuedAt) : "—";
|
||||
|
||||
const handleDownload = async () => {
|
||||
setDownloading(true);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Card, CardContent, CardDescription,
|
||||
@@ -17,15 +17,16 @@ import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
import { toast } from "sonner";
|
||||
import api from "@/utils/api.util";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatPrice(price, currency = "USD") {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(price);
|
||||
const REFUND_WINDOW_SECS = 5 * 60;
|
||||
|
||||
function formatCountdown(secs) {
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatDuration(days) {
|
||||
@@ -93,7 +94,8 @@ const PlanSkeleton = () => (
|
||||
|
||||
// ─── Plan Card ────────────────────────────────────────────────────────────────
|
||||
|
||||
const PlanCard = ({ plan, myTier, onSelect, onView, onRefund }) => {
|
||||
const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft }) => {
|
||||
const { fmtCurrency } = useDateFormat();
|
||||
const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
|
||||
const Icon = style.icon;
|
||||
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
|
||||
@@ -116,7 +118,7 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund }) => {
|
||||
</div>
|
||||
<CardDescription>
|
||||
<span className="text-3xl font-bold text-foreground">
|
||||
{formatPrice(plan.price, plan.currency)}
|
||||
{fmtCurrency(plan.price, plan.currency)}
|
||||
</span>
|
||||
{duration && (
|
||||
<span className="text-sm text-muted-foreground ml-1">/ {duration}</span>
|
||||
@@ -170,15 +172,16 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund }) => {
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
{isCurrent ? (
|
||||
{isCurrent && refundSecsLeft > 0 ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="destructive"
|
||||
onClick={() => onRefund(plan)}
|
||||
>
|
||||
<RotateCcw className="size-4" /> Refund
|
||||
<RotateCcw className="size-4" />
|
||||
Refund ({formatCountdown(refundSecsLeft)})
|
||||
</Button>
|
||||
) : (
|
||||
) : !isCurrent ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant={style.button}
|
||||
@@ -186,7 +189,7 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund }) => {
|
||||
>
|
||||
{plan.tier === "free" ? "Current" : `Get ${style.label}`}
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
@@ -197,15 +200,34 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund }) => {
|
||||
export default function PlanList() {
|
||||
const navigate = useNavigate();
|
||||
const { plans, plansLoading, myTier, tierLoading, getPlans, getMyTier, resetMyTier } = useClientTiers();
|
||||
const { fmtCurrency, fmtDate } = useDateFormat();
|
||||
|
||||
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
|
||||
const [refundLoading, setRefundLoading] = useState(false);
|
||||
const [refundSecsLeft, setRefundSecsLeft] = useState(0);
|
||||
const refundTimerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
getPlans();
|
||||
getMyTier();
|
||||
}, [getPlans, getMyTier]);
|
||||
|
||||
useEffect(() => {
|
||||
clearInterval(refundTimerRef.current);
|
||||
if (!myTier?.starts_at) { setRefundSecsLeft(0); return; }
|
||||
const compute = () => {
|
||||
const elapsed = Math.floor((Date.now() - new Date(myTier.starts_at).getTime()) / 1000);
|
||||
return Math.max(0, REFUND_WINDOW_SECS - elapsed);
|
||||
};
|
||||
setRefundSecsLeft(compute());
|
||||
refundTimerRef.current = setInterval(() => {
|
||||
const left = compute();
|
||||
setRefundSecsLeft(left);
|
||||
if (left === 0) clearInterval(refundTimerRef.current);
|
||||
}, 1000);
|
||||
return () => clearInterval(refundTimerRef.current);
|
||||
}, [myTier?.starts_at]);
|
||||
|
||||
const handleViewPlan = (plan) => navigate(`/plans/view/${plan.plan_id}`);
|
||||
|
||||
const handleSelectPlan = (plan) => navigate(`/plans/checkout?plan_id=${plan.plan_id}`);
|
||||
@@ -216,7 +238,7 @@ export default function PlanList() {
|
||||
setRefundLoading(true);
|
||||
try {
|
||||
const { data } = await api.post("/client/tiers/checkout/refund");
|
||||
toast.success(data.message ?? "Refund processed. Access remains until end of billing period.");
|
||||
toast.success(data.message ?? "Refund processed. Your access has been revoked.");
|
||||
setRefundPlan(null);
|
||||
resetMyTier();
|
||||
getMyTier();
|
||||
@@ -234,7 +256,7 @@ export default function PlanList() {
|
||||
<div className="lg:container lg:mx-auto space-y-8 p-6">
|
||||
|
||||
{/* Advertisement Banner */}
|
||||
<Card className="overflow-hidden border-primary/20 bg-gradient-to-r from-primary/10 via-primary/5 to-background">
|
||||
{/* <Card className="overflow-hidden border-primary/20 bg-gradient-to-r from-primary/10 via-primary/5 to-background">
|
||||
<CardContent className="flex flex-col gap-4 py-20 md:flex-row md:items-center md:justify-between pl-10">
|
||||
<div className="space-y-2">
|
||||
<Badge>
|
||||
@@ -251,10 +273,10 @@ export default function PlanList() {
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card> */}
|
||||
|
||||
{/* Section Header */}
|
||||
<div className="text-center">
|
||||
<div className="text-center mt-6">
|
||||
<h2 className="text-3xl font-bold">Available Plans</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Choose a subscription that matches your goals.
|
||||
@@ -279,6 +301,7 @@ export default function PlanList() {
|
||||
onSelect={handleSelectPlan}
|
||||
onView={handleViewPlan}
|
||||
onRefund={handleRefundClick}
|
||||
refundSecsLeft={refundSecsLeft}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -305,7 +328,7 @@ export default function PlanList() {
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmRefund}
|
||||
disabled={refundLoading}
|
||||
disabled={refundLoading || refundSecsLeft === 0}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
{refundLoading ? "Processing..." : "Confirm Refund"}
|
||||
@@ -322,30 +345,41 @@ export default function PlanList() {
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Refund amount</span>
|
||||
<span className="font-medium">
|
||||
{refundPlan ? formatPrice(refundPlan.price, refundPlan.currency) : "—"}
|
||||
{refundPlan ? fmtCurrency(refundPlan.price, refundPlan.currency) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
{myTier?.expires_at && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Access until</span>
|
||||
<span className="font-medium">
|
||||
{new Date(myTier.expires_at).toLocaleDateString("en-US", {
|
||||
month: "long", day: "numeric", year: "numeric",
|
||||
})}
|
||||
{fmtDate(myTier.expires_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center pt-1 border-t">
|
||||
<span className="text-muted-foreground">Refund window</span>
|
||||
{refundSecsLeft > 0 ? (
|
||||
<span className="font-semibold tabular-nums text-destructive">
|
||||
{formatCountdown(refundSecsLeft)} remaining
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-semibold text-muted-foreground">Expired</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your refund will be processed through PayPal. You will retain access to your current plan until{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{myTier?.expires_at
|
||||
? new Date(myTier.expires_at).toLocaleDateString("en-US", {
|
||||
month: "long", day: "numeric", year: "numeric",
|
||||
})
|
||||
: "the end of the billing period"}
|
||||
</span>.
|
||||
</p>
|
||||
{refundSecsLeft > 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your refund will be processed through PayPal.{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
Access will be revoked immediately
|
||||
</span>{" "}
|
||||
and your account will be downgraded to Free.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-destructive">
|
||||
The 5-minute refund window has expired. Refunds are no longer available for this payment.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Edit, BookOpen, Award, Trophy, Shield, Star, Zap, Target, BadgeCheck, Medal, Flame, LockIcon, Camera, ChevronRight, Download, RefreshCcw
|
||||
} from "lucide-react";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import { getTierColor } from "@/utils/tierColors";
|
||||
import AvatarUploadDialog from "@/components/generic/AvatarUploadDialog";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -16,12 +18,13 @@ import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
import { useProfile } from "@/contexts/ProfileProvider";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import api from "@/utils/api.util";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// ─── Tier badge config ────────────────────────────────────────────────────────
|
||||
// ─── Tier badge fallbacks (used when no policy is configured in DB) ───────────
|
||||
|
||||
const TIER_BADGES = {
|
||||
const TIER_BADGE_FALLBACKS = {
|
||||
free: {
|
||||
src: "/badges/free-access-badge-leaf-flaticon.svg",
|
||||
label: "Free",
|
||||
@@ -42,13 +45,56 @@ const TIER_BADGES = {
|
||||
},
|
||||
};
|
||||
|
||||
const EARLY_ACCESS_BADGE = {
|
||||
const EARLY_ACCESS_FALLBACK = {
|
||||
src: "/badges/early-access-badge-percent-flaticon.svg",
|
||||
label: "Early Access",
|
||||
description: "Registered during the Philproperties beta period.",
|
||||
information: "Exclusive to members who registered before Dec 31, 2026.",
|
||||
};
|
||||
|
||||
// Renders either a Lucide icon badge or an image badge.
|
||||
function TierBadgeDisplay({ badge, className }) {
|
||||
if (badge?.src) return <img src={badge.src} alt={badge.label} className={className} />;
|
||||
if (badge?.icon) {
|
||||
const Icon = LucideIcons[badge.icon];
|
||||
if (!Icon) return null;
|
||||
const swatch = getTierColor(badge.colorKey ?? "green").swatch;
|
||||
return <Icon className={className} style={{ color: swatch }} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveTierBadge(myTier) {
|
||||
const tier = myTier?.tier ?? "free";
|
||||
const category = myTier?.plan?.category ?? myTier?.category ?? null;
|
||||
|
||||
if (category) {
|
||||
const hasBadge = category.badgeAsset || category.badge_icon || category.badge_label;
|
||||
if (hasBadge) {
|
||||
return {
|
||||
src: category.badgeAsset?.file_url ?? null,
|
||||
icon: !category.badgeAsset ? (category.badge_icon ?? null) : null,
|
||||
colorKey: category.color ?? "green",
|
||||
label: category.badge_label ?? category.name ?? tier,
|
||||
description: "",
|
||||
information: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
return TIER_BADGE_FALLBACKS[tier] ?? TIER_BADGE_FALLBACKS.free;
|
||||
}
|
||||
|
||||
function resolveEarlyAccessBadge(systemBadges) {
|
||||
const found = systemBadges?.find((b) => b.key === "early_access");
|
||||
if (!found) return EARLY_ACCESS_FALLBACK;
|
||||
return {
|
||||
src: found.asset?.file_url ?? EARLY_ACCESS_FALLBACK.src,
|
||||
label: found.label ?? EARLY_ACCESS_FALLBACK.label,
|
||||
description: found.description ?? EARLY_ACCESS_FALLBACK.description,
|
||||
information: found.information ?? EARLY_ACCESS_FALLBACK.information,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Achievement icon map (by key) ───────────────────────────────────────────
|
||||
|
||||
const ACHIEVEMENT_ICONS = {
|
||||
@@ -84,10 +130,9 @@ const CertBadgeIcon = ({ className }) => (
|
||||
|
||||
const LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid }) => {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
const issuedLabel = issuedAt
|
||||
? new Date(issuedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
||||
: "—";
|
||||
const issuedLabel = issuedAt ? fmtDate(issuedAt) : "—";
|
||||
|
||||
const handleDownload = async () => {
|
||||
setDownloading(true);
|
||||
@@ -135,6 +180,7 @@ const LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid }) => {
|
||||
const ProfilePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
const {
|
||||
profile, profileLoading, getProfile,
|
||||
@@ -145,7 +191,7 @@ const ProfilePage = () => {
|
||||
|
||||
const [avatarDialogOpen, setAvatarDialogOpen] = useState(false);
|
||||
|
||||
const { myTier, tierLoading, getMyTier } = useClientTiers();
|
||||
const { myTier, tierLoading, getMyTier, systemBadges, getSystemBadges } = useClientTiers();
|
||||
|
||||
const [badgeOpen, setBadgeOpen] = useState(false);
|
||||
const [selectedBadge, setSelectedBadge] = useState(null);
|
||||
@@ -159,6 +205,7 @@ const ProfilePage = () => {
|
||||
getProfile();
|
||||
getAchievements();
|
||||
getMyTier();
|
||||
getSystemBadges();
|
||||
(async () => {
|
||||
setInProgressCoursesLoading(true);
|
||||
try {
|
||||
@@ -175,7 +222,8 @@ const ProfilePage = () => {
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
|
||||
const tier = myTier?.tier ?? user?.tier ?? "free";
|
||||
const tierBadge = TIER_BADGES[tier] ?? TIER_BADGES.free;
|
||||
const tierBadge = resolveTierBadge(myTier);
|
||||
const earlyAccessBadge = resolveEarlyAccessBadge(systemBadges);
|
||||
const displayName = fullName || user?.personal_info?.name?.full_name || user?.email?.split("@")[0] || "—";
|
||||
const initials = displayName.split(" ").map((w) => w[0]).join("").slice(0, 2).toUpperCase();
|
||||
|
||||
@@ -194,22 +242,14 @@ const ProfilePage = () => {
|
||||
// Build badge object with earnedAt from achievements for modal
|
||||
const getBadgeWithDate = (achievementKey, tierKey) => {
|
||||
const achievement = achievements.find((a) => a.key === achievementKey);
|
||||
const earnedAt = achievement?.granted_at
|
||||
? new Date(achievement.granted_at).toLocaleDateString("en-US", {
|
||||
month: "long", day: "numeric", year: "numeric",
|
||||
})
|
||||
: null;
|
||||
return { ...TIER_BADGES[tierKey], earnedAt };
|
||||
const earnedAt = achievement?.granted_at ? fmtDate(achievement.granted_at) : null;
|
||||
return { ...TIER_BADGE_FALLBACKS[tierKey], earnedAt };
|
||||
};
|
||||
|
||||
const getEarlyAccessBadge = () => {
|
||||
const achievement = achievements.find((a) => a.key === "early_access");
|
||||
const earnedAt = achievement?.granted_at
|
||||
? new Date(achievement.granted_at).toLocaleDateString("en-US", {
|
||||
month: "long", day: "numeric", year: "numeric",
|
||||
})
|
||||
: null;
|
||||
return { ...EARLY_ACCESS_BADGE, earnedAt };
|
||||
const earnedAt = achievement?.granted_at ? fmtDate(achievement.granted_at) : null;
|
||||
return { ...earlyAccessBadge, earnedAt };
|
||||
};
|
||||
|
||||
const getActiveTierBadgeWithDate = () => {
|
||||
@@ -261,12 +301,12 @@ const ProfilePage = () => {
|
||||
<TooltipTrigger asChild>
|
||||
<img
|
||||
className="size-4.5 cursor-pointer"
|
||||
src={EARLY_ACCESS_BADGE.src}
|
||||
alt={EARLY_ACCESS_BADGE.label}
|
||||
src={earlyAccessBadge.src}
|
||||
alt={earlyAccessBadge.label}
|
||||
onClick={() => { setSelectedBadge(getEarlyAccessBadge()); setBadgeOpen(true); }}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>{EARLY_ACCESS_BADGE.label}</p></TooltipContent>
|
||||
<TooltipContent><p>{earlyAccessBadge.label}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -276,7 +316,7 @@ const ProfilePage = () => {
|
||||
<TooltipTrigger asChild>
|
||||
<img
|
||||
className="size-4.5 cursor-pointer"
|
||||
src={TIER_BADGES.premium.src}
|
||||
src={TIER_BADGE_FALLBACKS.premium.src}
|
||||
alt="Premium"
|
||||
onClick={() => { setSelectedBadge(getBadgeWithDate("premium_first_time", "premium")); setBadgeOpen(true); }}
|
||||
/>
|
||||
@@ -291,7 +331,7 @@ const ProfilePage = () => {
|
||||
<TooltipTrigger asChild>
|
||||
<img
|
||||
className="size-4.5 cursor-pointer"
|
||||
src={TIER_BADGES.exclusive.src}
|
||||
src={TIER_BADGE_FALLBACKS.exclusive.src}
|
||||
alt="Exclusive"
|
||||
onClick={() => { setSelectedBadge(getBadgeWithDate("exclusive_first_time", "exclusive")); setBadgeOpen(true); }}
|
||||
/>
|
||||
@@ -305,12 +345,9 @@ const ProfilePage = () => {
|
||||
{userRank === 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<img
|
||||
className="size-4.5 cursor-pointer"
|
||||
src={tierBadge.src}
|
||||
alt={tierBadge.label}
|
||||
onClick={() => { setSelectedBadge(tierBadge); setBadgeOpen(true); }}
|
||||
/>
|
||||
<span className="cursor-pointer" onClick={() => { setSelectedBadge(tierBadge); setBadgeOpen(true); }}>
|
||||
<TierBadgeDisplay badge={tierBadge} className="size-4.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>{tierBadge.label}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -320,12 +357,9 @@ const ProfilePage = () => {
|
||||
{userRank === 1 && !hasPremiumBadge && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<img
|
||||
className="size-4.5 cursor-pointer"
|
||||
src={tierBadge.src}
|
||||
alt={tierBadge.label}
|
||||
onClick={() => { setSelectedBadge(getActiveTierBadgeWithDate()); setBadgeOpen(true); }}
|
||||
/>
|
||||
<span className="cursor-pointer" onClick={() => { setSelectedBadge(getActiveTierBadgeWithDate()); setBadgeOpen(true); }}>
|
||||
<TierBadgeDisplay badge={tierBadge} className="size-4.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>{tierBadge.label}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -363,8 +397,8 @@ const ProfilePage = () => {
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 py-2">
|
||||
{selectedBadge?.src && (
|
||||
<img src={selectedBadge.src} alt={selectedBadge.label} className="size-16" />
|
||||
{(selectedBadge?.src || selectedBadge?.icon) && (
|
||||
<TierBadgeDisplay badge={selectedBadge} className="size-16" />
|
||||
)}
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm font-medium">{selectedBadge?.label}</p>
|
||||
@@ -530,9 +564,7 @@ const ProfilePage = () => {
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Member since</span>
|
||||
<span className="font-medium">
|
||||
{profile?.createdAt
|
||||
? new Date(profile.createdAt).toLocaleDateString("en-US", { month: "long", year: "numeric" })
|
||||
: "—"}
|
||||
{profile?.createdAt ? fmtDate(profile.createdAt) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<Separator />
|
||||
@@ -542,9 +574,7 @@ const ProfilePage = () => {
|
||||
<Skeleton className="h-5 w-20" />
|
||||
) : (
|
||||
<span className="font-medium capitalize">
|
||||
{tier}{myTier?.expires_at && ` · until ${new Date(myTier.expires_at).toLocaleDateString("en-US", {
|
||||
month: "short", day: "numeric", year: "numeric",
|
||||
})}`}
|
||||
{tier}{myTier?.expires_at && ` · until ${fmtDate(myTier.expires_at)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -638,9 +668,7 @@ const ProfilePage = () => {
|
||||
<p className="text-xs text-muted-foreground">{item.description}</p>
|
||||
{item.granted_at && (
|
||||
<p className="text-xs mt-0.5">
|
||||
{new Date(item.granted_at).toLocaleDateString("en-US", {
|
||||
month: "long", day: "numeric", year: "numeric",
|
||||
})}
|
||||
{fmtDate(item.granted_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useParams, useNavigate, useLocation } from "react-router-dom";
|
||||
import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle, Zap } from "lucide-react";
|
||||
import { useParams, useNavigate, useLocation, useBlocker } from "react-router-dom";
|
||||
import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle, Zap, ListChecks } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonGroup } from "@/components/ui/button-group";
|
||||
import {
|
||||
@@ -20,6 +20,64 @@ import { useClientCourses } from "@/contexts/ClientCoursesContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgressContext";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
// ─── Quiz Prerequisite Gate ────────────────────────────────────────────────
|
||||
|
||||
const QuizPrerequisiteGate = ({ previousQuizzes, units, onQuizClick }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const passed = previousQuizzes.filter((q) => q.has_passed).length;
|
||||
const useModal = previousQuizzes.length > QUIZ_MODAL_THRESHOLD;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4">
|
||||
<div className="w-16 h-16 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center mb-4">
|
||||
<Lock className="size-8 text-amber-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold mb-2">
|
||||
Complete Previous {previousQuizzes.length > 1 ? "Quizzes" : "Quiz"} First
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-4 max-w-sm text-sm">
|
||||
You must pass the required {previousQuizzes.length > 1 ? "quizzes" : "quiz"} before you can take this one.
|
||||
</p>
|
||||
|
||||
{useModal ? (
|
||||
<>
|
||||
<p className="text-sm mb-4">{passed} of {previousQuizzes.length} quizzes passed</p>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<ClipboardList className="size-4" />
|
||||
View Required Quizzes
|
||||
</Button>
|
||||
<ResponsiveModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Required Quizzes"
|
||||
description={`Pass the required quizzes to unlock this one.`}
|
||||
hideDrawerClose={false}
|
||||
>
|
||||
<ScrollArea className="max-h-72 pr-1">
|
||||
<QuizGateList
|
||||
requiredQuizzes={previousQuizzes}
|
||||
units={units}
|
||||
onQuizClick={onQuizClick}
|
||||
onItemClick={() => setOpen(false)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full max-w-sm text-left">
|
||||
<QuizGateList
|
||||
requiredQuizzes={previousQuizzes}
|
||||
units={units}
|
||||
onQuizClick={onQuizClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Assessment Gate ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -114,7 +172,7 @@ const SidebarContent = ({
|
||||
units, selectedLessonId, selectedQuizId, onLessonClick, onQuizClick,
|
||||
courseAssessment, selectedAssessment, onAssessmentClick, assessmentLocked,
|
||||
isCompleted, selectedCompletion, onCompletionClick,
|
||||
getLessonCompleted, getUnitCompleted,
|
||||
getLessonCompleted, getUnitCompleted, isQuizLocked,
|
||||
loading,
|
||||
}) => (
|
||||
<ScrollArea className="h-full p-3 md:p-4">
|
||||
@@ -162,24 +220,31 @@ const SidebarContent = ({
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{unit.quiz && (
|
||||
<li
|
||||
onClick={() => onQuizClick({ unit, quiz: unit.quiz })}
|
||||
className={`flex items-center gap-2 pl-6 pr-3 py-1.5 text-sm rounded-md hover:bg-muted-foreground/10 cursor-pointer transition-colors ${
|
||||
selectedQuizId === unit.quiz.quiz_id
|
||||
? "bg-muted-foreground/10 text-foreground font-medium"
|
||||
: unit.quiz.has_passed
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{unit.quiz.has_passed
|
||||
? <CheckCircle2 className="size-3.5 text-emerald-500 shrink-0" />
|
||||
: <ClipboardList className="size-3.5 shrink-0" />
|
||||
}
|
||||
{unit.quiz.title || "Quiz"}
|
||||
</li>
|
||||
)}
|
||||
{unit.quiz && (() => {
|
||||
const quizLocked = isQuizLocked?.(unit);
|
||||
return (
|
||||
<li
|
||||
onClick={() => onQuizClick({ unit, quiz: unit.quiz })}
|
||||
className={`flex items-center gap-2 pl-6 pr-3 py-1.5 text-sm rounded-md cursor-pointer transition-colors ${
|
||||
selectedQuizId === unit.quiz.quiz_id
|
||||
? "bg-muted-foreground/10 text-foreground font-medium"
|
||||
: unit.quiz.has_passed
|
||||
? "text-emerald-600 dark:text-emerald-400 hover:bg-muted-foreground/10"
|
||||
: quizLocked
|
||||
? "text-muted-foreground/50 hover:bg-muted-foreground/5"
|
||||
: "text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{unit.quiz.has_passed
|
||||
? <CheckCircle2 className="size-3.5 text-emerald-500 shrink-0" />
|
||||
: quizLocked
|
||||
? <Lock className="size-3.5 text-amber-500 shrink-0" />
|
||||
: <ClipboardList className="size-3.5 shrink-0" />
|
||||
}
|
||||
{unit.quiz.title || "Quiz"}
|
||||
</li>
|
||||
);
|
||||
})()}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -234,7 +299,7 @@ const UnitList = () => {
|
||||
const {
|
||||
course, courseLoading, courseBlocked, getCourse,
|
||||
lesson, lessonLoading, getLesson, resetLesson,
|
||||
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz,
|
||||
quiz, quizLoading, getUnitQuiz, resetQuiz, submitUnitQuiz, saveQuizDraft,
|
||||
assessment, assessmentLoading, getCourseAssessment, resetAssessment, startCourseAssessment, saveDraft, refreshAssessmentSession, submitCourseAssessment,
|
||||
} = useClientCourses();
|
||||
|
||||
@@ -243,12 +308,17 @@ const UnitList = () => {
|
||||
upsertLessonProgress,
|
||||
isCompleted: isProgressCompleted,
|
||||
isRead: isProgressRead,
|
||||
completedTasks,
|
||||
clearCompletedTasks,
|
||||
resetProgress,
|
||||
} = useCourseReadingProgress();
|
||||
|
||||
// Tracks which lessons have been marked completed this session to avoid duplicate calls
|
||||
const completedSessionRef = useRef(new Set());
|
||||
|
||||
// ── Task context — populated from navigation state or backend fallback ──
|
||||
const [taskCtx, setTaskCtx] = useState(null);
|
||||
|
||||
// ── Local UI state ──────────────────────────────────────────────────────
|
||||
const [selectedLessonId, setSelectedLessonId] = useState(null);
|
||||
const [selectedQuizId, setSelectedQuizId] = useState(null);
|
||||
@@ -260,6 +330,16 @@ const UnitList = () => {
|
||||
|
||||
const resetCompletion = () => setSelectedCompletion(false);
|
||||
|
||||
// ── Session guard — block navigation while a quiz/assessment is in progress ─
|
||||
const quizActiveRef = useRef(false); // sync check inside handlers
|
||||
const [quizSessionActive, setQuizSessionActive] = useState(false);
|
||||
const [pendingNav, setPendingNav] = useState(null); // deferred nav fn
|
||||
|
||||
const setQuizActive = useCallback((active) => {
|
||||
quizActiveRef.current = active;
|
||||
setQuizSessionActive(active);
|
||||
}, []);
|
||||
|
||||
// ── Quiz gate — all required unit quizzes must be passed before assessment ─
|
||||
const requiredQuizzes = (course?.units ?? [])
|
||||
.filter((u) => u.quiz?.is_required)
|
||||
@@ -273,6 +353,47 @@ const UnitList = () => {
|
||||
const allRequiredQuizzesPassed =
|
||||
requiredQuizzes.length === 0 || requiredQuizzes.every((q) => q.has_passed);
|
||||
|
||||
// ── Quiz sequential lock — unit N's quiz is locked until all previous required quizzes are passed ─
|
||||
const lockedQuizUnitIds = new Set(
|
||||
(course?.units ?? [])
|
||||
.filter((u, i, arr) =>
|
||||
u.quiz && arr.slice(0, i).some(prev => prev.quiz?.is_required && !prev.quiz.has_passed)
|
||||
)
|
||||
.map(u => u.unit_id)
|
||||
);
|
||||
|
||||
const selectedUnitIndex = (course?.units ?? []).findIndex(u => u.unit_id === selectedUnitId);
|
||||
const previousRequiredQuizzes = selectedUnitIndex > 0
|
||||
? (course?.units ?? []).slice(0, selectedUnitIndex)
|
||||
.filter(u => u.quiz?.is_required)
|
||||
.map(u => ({
|
||||
unit: u,
|
||||
unitId: u.unit_id,
|
||||
quizId: u.quiz.quiz_id,
|
||||
title: u.quiz.title || "Quiz",
|
||||
has_passed: u.quiz.has_passed ?? false,
|
||||
}))
|
||||
: [];
|
||||
|
||||
// ── Block React Router navigation (back button / programmatic navigate) ──
|
||||
const blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) =>
|
||||
quizSessionActive && currentLocation.pathname !== nextLocation.pathname
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocker.state !== "blocked") return;
|
||||
setPendingNav(() => () => blocker.proceed());
|
||||
}, [blocker.state]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Block browser tab-close / hard navigation during active session ───────
|
||||
useEffect(() => {
|
||||
if (!quizSessionActive) return;
|
||||
const handler = (e) => { e.preventDefault(); e.returnValue = ''; };
|
||||
window.addEventListener('beforeunload', handler);
|
||||
return () => window.removeEventListener('beforeunload', handler);
|
||||
}, [quizSessionActive]);
|
||||
|
||||
// ── Flatten all content (lessons + quiz + final assessment) ──────────────
|
||||
const allContent = (course?.units ?? []).flatMap((u) => [
|
||||
...(u.lessons ?? []).map((l) => ({ type: "lesson", unit: u, lesson: l })),
|
||||
@@ -307,7 +428,24 @@ const UnitList = () => {
|
||||
if (completedSessionRef.current.has(selectedLessonId)) return;
|
||||
if (isProgressCompleted(lesson.uuid)) return;
|
||||
completedSessionRef.current.add(selectedLessonId);
|
||||
upsertLessonProgress(courseId, selectedUnitId, selectedLessonId, lesson.uuid, 'completed');
|
||||
(async () => {
|
||||
const result = await upsertLessonProgress(courseId, selectedUnitId, selectedLessonId, lesson.uuid, 'completed');
|
||||
if (!result) return;
|
||||
toast.success(`"${lesson.title}" marked as read.`);
|
||||
if (result.unit?.status === 'completed') {
|
||||
const unitTitle = currentUnit?.title;
|
||||
toast.success(
|
||||
unitTitle ? `Unit "${unitTitle}" complete!` : 'Unit complete!',
|
||||
{ duration: 4000 }
|
||||
);
|
||||
}
|
||||
if (result.course?.status === 'completed') {
|
||||
toast.success(
|
||||
'All lessons read! Finish the quizzes & assessment to get certified.',
|
||||
{ duration: 5000 }
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, [scrollProgress]);
|
||||
|
||||
// ── Fetch course + progress on mount ─────────────────────────────────
|
||||
@@ -323,12 +461,53 @@ const UnitList = () => {
|
||||
};
|
||||
}, [courseId]);
|
||||
|
||||
// ── Task context: state-first, endpoint fallback ──────────────────────
|
||||
// If navigated from ReadCourse the taskCtx is in location.state;
|
||||
// if the user opened this URL directly, fetch from the backend.
|
||||
useEffect(() => {
|
||||
const stateCtx = location.state?.taskCtx;
|
||||
if (stateCtx) {
|
||||
setTaskCtx(stateCtx);
|
||||
return;
|
||||
}
|
||||
api.get(`/client/courses/${courseId}/task-context`)
|
||||
.then(({ data }) => {
|
||||
if (data?.data?.has_task) setTaskCtx(data.data);
|
||||
})
|
||||
.catch(() => {}); // non-critical — silently swallow
|
||||
}, [courseId]);
|
||||
|
||||
// ── Toast when a task's read requirements are all done ────────────────
|
||||
useEffect(() => {
|
||||
if (!completedTasks.length) return;
|
||||
completedTasks.forEach((t) => {
|
||||
toast.success(`"${t.task_name}" automatically turned in!`);
|
||||
});
|
||||
clearCompletedTasks();
|
||||
}, [completedTasks]);
|
||||
|
||||
// ── Auto-load lesson once course data is available ────────────────────
|
||||
// If navigated from CourseDetails with a specific lesson, open that one;
|
||||
// otherwise fall back to the first lesson.
|
||||
useEffect(() => {
|
||||
if (!course || selectedLessonId || selectedQuizId || selectedAssessment || selectedCompletion) return;
|
||||
const { lessonId, unitId, seekFirstIncomplete } = location.state ?? {};
|
||||
const { lessonId, unitId, seekFirstIncomplete, quizUnitId, seekAssessment } = location.state ?? {};
|
||||
|
||||
if (seekAssessment && course.assessment) {
|
||||
setSelectedAssessment(true);
|
||||
if (allRequiredQuizzesPassed) getCourseAssessment(courseId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (quizUnitId) {
|
||||
const targetUnit = (course.units ?? []).find((u) => String(u.unit_id) === String(quizUnitId));
|
||||
if (targetUnit?.quiz) {
|
||||
setSelectedQuizId(targetUnit.quiz.quiz_id);
|
||||
setSelectedUnitId(targetUnit.unit_id);
|
||||
if (!lockedQuizUnitIds.has(targetUnit.unit_id)) getUnitQuiz(courseId, targetUnit.unit_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (seekFirstIncomplete) {
|
||||
const firstIncompleteUnit = (course.units ?? []).find((u) => u.quiz && !u.quiz.has_passed);
|
||||
@@ -391,78 +570,114 @@ const UnitList = () => {
|
||||
{ label: selectedCompletion ? "Course Complete" : selectedAssessment ? "Course Assessment" : (currentUnit?.title ?? "Select a lesson") },
|
||||
];
|
||||
|
||||
// ── Session guard helpers ──────────────────────────────────────────────
|
||||
const handleConfirmNav = useCallback(() => {
|
||||
const fn = pendingNav;
|
||||
setPendingNav(null);
|
||||
quizActiveRef.current = false;
|
||||
setQuizSessionActive(false);
|
||||
if (blocker.state === "blocked") blocker.proceed();
|
||||
else fn?.();
|
||||
}, [pendingNav, blocker]);
|
||||
|
||||
const handleCancelNav = useCallback(() => {
|
||||
setPendingNav(null);
|
||||
if (blocker.state === "blocked") blocker.reset();
|
||||
}, [blocker]);
|
||||
|
||||
// ── Lesson click ───────────────────────────────────────────────────────
|
||||
const handleLessonClick = useCallback(async ({ unit, lesson: lessonStub }) => {
|
||||
if (lessonStub.lesson_id === selectedLessonId) {
|
||||
if (lessonStub.lesson_id === selectedLessonId) { setSidebarOpen(false); return; }
|
||||
|
||||
const doNav = async () => {
|
||||
setSelectedLessonId(lessonStub.lesson_id);
|
||||
setSelectedQuizId(null);
|
||||
setSelectedAssessment(false);
|
||||
resetCompletion();
|
||||
resetQuiz();
|
||||
resetAssessment();
|
||||
setSelectedUnitId(unit.unit_id);
|
||||
setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
setSelectedLessonId(lessonStub.lesson_id);
|
||||
setSelectedQuizId(null);
|
||||
setSelectedAssessment(false);
|
||||
resetCompletion();
|
||||
resetQuiz();
|
||||
resetAssessment();
|
||||
setSelectedUnitId(unit.unit_id);
|
||||
setSidebarOpen(false);
|
||||
await getLesson(courseId, unit.unit_id, lessonStub.lesson_id);
|
||||
// Fire in_progress only if not already started or completed
|
||||
if (!isProgressRead(lessonStub.uuid)) {
|
||||
upsertLessonProgress(courseId, unit.unit_id, lessonStub.lesson_id, lessonStub.uuid, 'in_progress');
|
||||
}
|
||||
await getLesson(courseId, unit.unit_id, lessonStub.lesson_id);
|
||||
if (!isProgressRead(lessonStub.uuid)) {
|
||||
upsertLessonProgress(courseId, unit.unit_id, lessonStub.lesson_id, lessonStub.uuid, 'in_progress');
|
||||
}
|
||||
};
|
||||
|
||||
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
|
||||
await doNav();
|
||||
}, [selectedLessonId, courseId, getLesson, resetQuiz, resetAssessment, isProgressRead, upsertLessonProgress]);
|
||||
|
||||
// ── Quiz click ─────────────────────────────────────────────────────────
|
||||
const handleQuizClick = useCallback(async ({ unit, quiz: quizStub }) => {
|
||||
if (quizStub.quiz_id === selectedQuizId) {
|
||||
if (quizStub.quiz_id === selectedQuizId) { setSidebarOpen(false); return; }
|
||||
|
||||
const doNav = async () => {
|
||||
setSelectedQuizId(quizStub.quiz_id);
|
||||
setSelectedLessonId(null);
|
||||
setSelectedAssessment(false);
|
||||
resetCompletion();
|
||||
resetLesson();
|
||||
resetAssessment();
|
||||
setSelectedUnitId(unit.unit_id);
|
||||
setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
setSelectedQuizId(quizStub.quiz_id);
|
||||
setSelectedLessonId(null);
|
||||
setSelectedAssessment(false);
|
||||
resetCompletion();
|
||||
resetLesson();
|
||||
resetAssessment();
|
||||
setSelectedUnitId(unit.unit_id);
|
||||
setSidebarOpen(false);
|
||||
await getUnitQuiz(courseId, unit.unit_id);
|
||||
}, [selectedQuizId, courseId, getUnitQuiz, resetLesson, resetAssessment]);
|
||||
if (!lockedQuizUnitIds.has(unit.unit_id)) {
|
||||
await getUnitQuiz(courseId, unit.unit_id);
|
||||
}
|
||||
};
|
||||
|
||||
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
|
||||
await doNav();
|
||||
}, [selectedQuizId, courseId, getUnitQuiz, resetLesson, resetAssessment, lockedQuizUnitIds]);
|
||||
|
||||
// ── Course assessment click ─────────────────────────────────────────────
|
||||
const handleAssessmentClick = useCallback(async () => {
|
||||
if (selectedAssessment) {
|
||||
if (selectedAssessment) { setSidebarOpen(false); return; }
|
||||
|
||||
const doNav = async () => {
|
||||
setSelectedAssessment(true);
|
||||
setSelectedLessonId(null);
|
||||
setSelectedQuizId(null);
|
||||
resetCompletion();
|
||||
resetLesson();
|
||||
resetQuiz();
|
||||
setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
setSelectedAssessment(true);
|
||||
setSelectedLessonId(null);
|
||||
setSelectedQuizId(null);
|
||||
resetCompletion();
|
||||
resetLesson();
|
||||
resetQuiz();
|
||||
setSidebarOpen(false);
|
||||
if (allRequiredQuizzesPassed) {
|
||||
await getCourseAssessment(courseId);
|
||||
}
|
||||
if (allRequiredQuizzesPassed) await getCourseAssessment(courseId);
|
||||
};
|
||||
|
||||
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
|
||||
await doNav();
|
||||
}, [selectedAssessment, courseId, getCourseAssessment, resetLesson, resetQuiz, allRequiredQuizzesPassed]);
|
||||
|
||||
// ── Course complete click ───────────────────────────────────────────────
|
||||
const handleCompletionClick = useCallback(() => {
|
||||
if (selectedCompletion) {
|
||||
if (selectedCompletion) { setSidebarOpen(false); return; }
|
||||
|
||||
const doNav = () => {
|
||||
setSelectedLessonId(null);
|
||||
setSelectedQuizId(null);
|
||||
setSelectedAssessment(false);
|
||||
resetLesson();
|
||||
resetQuiz();
|
||||
resetAssessment();
|
||||
setSelectedCompletion(true);
|
||||
setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
setSelectedLessonId(null);
|
||||
setSelectedQuizId(null);
|
||||
setSelectedAssessment(false);
|
||||
resetLesson();
|
||||
resetQuiz();
|
||||
resetAssessment();
|
||||
setSelectedCompletion(true);
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
|
||||
if (quizActiveRef.current) { setPendingNav(() => doNav); return; }
|
||||
doNav();
|
||||
}, [selectedCompletion, resetLesson, resetQuiz, resetAssessment]);
|
||||
|
||||
// Stable draft callbacks — avoids recreating on every render (which would re-trigger QuizBlock's onDraft effect)
|
||||
const handleQuizDraft = useCallback((answers) => {
|
||||
saveQuizDraft(courseId, selectedUnitId, selectedQuizId, answers);
|
||||
}, [courseId, selectedUnitId, selectedQuizId, saveQuizDraft]);
|
||||
|
||||
const handleAssessmentDraft = useCallback((answers) => {
|
||||
if (!assessment?.assessment_id) return;
|
||||
saveDraft(courseId, assessment.assessment_id, answers);
|
||||
}, [courseId, assessment?.assessment_id, saveDraft]);
|
||||
|
||||
// ── Next content item (lesson, quiz, or final assessment) ─────────────
|
||||
const getNextContent = useCallback(() => {
|
||||
const idx = allContent.findIndex((item) =>
|
||||
@@ -509,6 +724,52 @@ const UnitList = () => {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title={pageTitle} />
|
||||
|
||||
{/* ── Session-guard dialog — shown when user tries to navigate away mid-quiz ── */}
|
||||
<ResponsiveModal
|
||||
open={!!pendingNav || blocker.state === "blocked"}
|
||||
onOpenChange={(open) => !open && handleCancelNav()}
|
||||
title={`Leave ${selectedAssessment ? "Assessment" : "Quiz"}?`}
|
||||
description=""
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancelNav}>
|
||||
Stay
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirmNav}>
|
||||
Leave Anyway
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
You have an ongoing <strong className="text-foreground">{selectedAssessment ? "assessment" : "quiz"}</strong> session in progress.
|
||||
Leaving now will not submit your answers — your session will remain open and the administrator can see it.
|
||||
</p>
|
||||
{selectedAssessment && (
|
||||
<p className="text-amber-600 dark:text-amber-400 font-medium">
|
||||
Your assessment timer will keep counting while you're away.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
|
||||
{/* ── Task-mode banner ─────────────────────────────────────────── */}
|
||||
{taskCtx?.has_task && (
|
||||
<div className={`fixed top-[124px] left-0 right-0 z-30 flex items-center gap-2 text-white text-xs font-medium px-4 py-1.5 shadow-sm transition-colors ${
|
||||
course?.is_completed ? 'bg-green-600' : 'bg-blue-600'
|
||||
}`}>
|
||||
<ListChecks className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{course?.is_completed
|
||||
? 'Course complete — tracking finished'
|
||||
: 'Task mode — reading progress is being tracked automatically'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Up next floating button (lesson only — quizzes/assessments have their own bottom controls) ── */}
|
||||
{scrollProgress >= 100 && nextContent && !selectedQuizId && !selectedAssessment && (
|
||||
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
@@ -568,6 +829,7 @@ const UnitList = () => {
|
||||
onCompletionClick={handleCompletionClick}
|
||||
getLessonCompleted={(l) => isProgressCompleted(l.uuid)}
|
||||
getUnitCompleted={(u) => isProgressCompleted(u.uuid)}
|
||||
isQuizLocked={(u) => lockedQuizUnitIds.has(u.unit_id)}
|
||||
loading={courseLoading}
|
||||
/>
|
||||
</SheetContent>
|
||||
@@ -599,7 +861,7 @@ const UnitList = () => {
|
||||
|
||||
{/* ── Desktop sidebar ── */}
|
||||
{desktopSidebarOpen && (
|
||||
<div className="hidden lg:block fixed top-[124px] bottom-0 left-0 w-80 bg-muted border-r">
|
||||
<div className={`hidden lg:block fixed ${taskCtx?.has_task ? "top-[148px]" : "top-[124px]"} bottom-0 left-0 w-80 bg-muted border-r`}>
|
||||
<SidebarContent
|
||||
units={units}
|
||||
selectedLessonId={selectedLessonId}
|
||||
@@ -621,7 +883,7 @@ const UnitList = () => {
|
||||
)}
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<div className={`mt-32 ${desktopSidebarOpen ? "lg:ml-80" : "lg:ml-0"} p-4 md:p-6 min-h-screen`}>
|
||||
<div className={`${taskCtx?.has_task ? "mt-[8.5rem]" : "mt-32"} ${desktopSidebarOpen ? "lg:ml-80" : "lg:ml-0"} p-4 md:p-6 min-h-screen`}>
|
||||
<div className="relative w-full h-full">
|
||||
{selectedCompletion ? (
|
||||
<CourseCompleteBlock course={course} />
|
||||
@@ -638,7 +900,7 @@ const UnitList = () => {
|
||||
loading={assessmentLoading}
|
||||
label="Assessment"
|
||||
onStart={() => startCourseAssessment(courseId, assessment.assessment_id)}
|
||||
onDraft={(answers) => saveDraft(courseId, assessment.assessment_id, answers)}
|
||||
onDraft={handleAssessmentDraft}
|
||||
onRefreshSession={() => refreshAssessmentSession(courseId, assessment.assessment_id)}
|
||||
onSubmit={async (answers, sessionId) => {
|
||||
const result = await submitCourseAssessment(courseId, assessment.assessment_id, answers, sessionId);
|
||||
@@ -646,19 +908,30 @@ const UnitList = () => {
|
||||
return result;
|
||||
}}
|
||||
onRetake={() => getCourseAssessment(courseId)}
|
||||
onActiveChange={setQuizActive}
|
||||
/>
|
||||
)
|
||||
) : selectedQuizId ? (
|
||||
<QuizBlock
|
||||
quiz={quiz}
|
||||
loading={quizLoading}
|
||||
onSubmit={async (answers) => {
|
||||
const result = await submitUnitQuiz(courseId, selectedUnitId, selectedQuizId, answers);
|
||||
await getCourse(courseId);
|
||||
return result;
|
||||
}}
|
||||
onRetake={() => getUnitQuiz(courseId, selectedUnitId)}
|
||||
/>
|
||||
lockedQuizUnitIds.has(selectedUnitId) ? (
|
||||
<QuizPrerequisiteGate
|
||||
previousQuizzes={previousRequiredQuizzes}
|
||||
units={units}
|
||||
onQuizClick={handleQuizClick}
|
||||
/>
|
||||
) : (
|
||||
<QuizBlock
|
||||
quiz={quiz}
|
||||
loading={quizLoading}
|
||||
onDraft={handleQuizDraft}
|
||||
onSubmit={async (answers) => {
|
||||
const result = await submitUnitQuiz(courseId, selectedUnitId, selectedQuizId, answers);
|
||||
await getCourse(courseId);
|
||||
return result;
|
||||
}}
|
||||
onRetake={() => getUnitQuiz(courseId, selectedUnitId)}
|
||||
onActiveChange={setQuizActive}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<LessonBlock
|
||||
lesson={lesson ? { ...lesson, blocks: lesson.page?.blocks ?? [] } : null}
|
||||
|
||||
@@ -12,18 +12,11 @@ import {
|
||||
Tag, LockIcon, Zap, CalendarDays,
|
||||
} from "lucide-react";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatPrice(price, currency = "USD") {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
function formatDuration(days) {
|
||||
if (!days) return null;
|
||||
if (days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""}`;
|
||||
@@ -82,6 +75,7 @@ const ViewPlan = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { myTier, getMyTier, plans, plansLoading, getPlans } = useClientTiers();
|
||||
const { fmtCurrency } = useDateFormat();
|
||||
|
||||
useEffect(() => {
|
||||
getMyTier();
|
||||
@@ -129,7 +123,7 @@ const ViewPlan = () => {
|
||||
<h2 className="text-2xl font-bold">{plan.label}</h2>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-3xl font-bold">
|
||||
{formatPrice(plan.price, plan.currency)}
|
||||
{fmtCurrency(plan.price, plan.currency)}
|
||||
</span>
|
||||
{duration && (
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
@@ -246,7 +240,7 @@ const ViewPlan = () => {
|
||||
|
||||
onClick={() => navigate(`/plans/checkout?plan_id=${plan.plan_id}`)}
|
||||
>
|
||||
Get {style.label} Plan — {formatPrice(plan.price, plan.currency)}
|
||||
Get {style.label} Plan — {fmtCurrency(plan.price, plan.currency)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : ViewRequirement.jsx
|
||||
* Type : Page (Client)
|
||||
* Description : Task tracker — sidebar lists ALL read_* requirements.
|
||||
* read_unit items expand to show their lessons as sub-items;
|
||||
* clicking a lesson renders its content on the right.
|
||||
* Route: /group/:groupId/view/:taskListId/task/:taskId/requirement
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
/*
|
||||
* ViewRequirement.jsx
|
||||
* Route: /group/:groupId/view/:taskListId/task/:taskId/requirement
|
||||
*
|
||||
* Sidebar: sectioned flat list — one plain heading per requirement type,
|
||||
* each requirement is a clickable button. When selected, its lesson
|
||||
* sub-tree renders inline below the item (no accordion, no scoping).
|
||||
*
|
||||
* read_course → CourseUnitLessonView (on-demand lesson fetch)
|
||||
* read_unit → LessonBlock (lessons from unitLessonsMap)
|
||||
* read_lesson → LessonView (standalone fetch by uuid)
|
||||
*/
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
|
||||
import {
|
||||
House, TableOfContents, CheckCheck, Circle,
|
||||
BookOpen, Layers, FileText, ArrowLeft, ChevronDown, ChevronRight,
|
||||
Lock, Zap, RefreshCw,
|
||||
Layers, ArrowLeft, Lock, Zap, RefreshCw, Tag,
|
||||
ClipboardList, GraduationCap, CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Accordion, AccordionContent, AccordionItem, AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
@@ -29,170 +30,266 @@ import { useTaskProgress } from '@/contexts/ClientTaskProgressContext';
|
||||
|
||||
import LessonBlock from '@/modules/client/components/LessonBlock';
|
||||
import api from '@/utils/api.util';
|
||||
import { useClientTiers } from '@/contexts/ClientTiersProvider';
|
||||
import { resolveTierBadge } from '@/utils/tierBadge.util';
|
||||
|
||||
// ─── Type config ──────────────────────────────────────────────────────────────
|
||||
const TYPE_ICON = { read_course: BookOpen, read_unit: Layers, read_lesson: FileText };
|
||||
const TYPE_LABEL = { read_course: 'Courses', read_unit: 'Units', read_lesson: 'Lessons' };
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
const TYPE_LABEL = {
|
||||
read_course: 'Read a Course',
|
||||
read_unit: 'Read a Unit',
|
||||
read_lesson: 'Read a Lesson',
|
||||
};
|
||||
|
||||
// ─── Compact tier badge ───────────────────────────────────────────────────────
|
||||
const TierBadge = ({ tier }) => {
|
||||
const { tierMap } = useClientTiers();
|
||||
if (!tier) return null;
|
||||
const { label, cls } = resolveTierBadge(tier, tierMap);
|
||||
return (
|
||||
<Badge className={`${cls} text-[10px] px-1.5 py-0 h-[18px] leading-none shrink-0`}>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Sidebar ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// selection = { reqId, lessonUuid? }
|
||||
// • read_course / read_lesson: lessonUuid is undefined
|
||||
// • read_unit: lessonUuid identifies which sub-lesson is open
|
||||
//
|
||||
const SidebarContent = ({
|
||||
requirements,
|
||||
selection,
|
||||
onSelectReq, // (req) → select a read_course / read_lesson requirement
|
||||
onSelectLesson, // (req, lesson) → select a lesson within a read_unit
|
||||
onSelectReq,
|
||||
onSelectLesson,
|
||||
isCompleted,
|
||||
unitLessonsMap, // { [reqId]: { meta, lessons } }
|
||||
unitLoadingMap, // { [reqId]: boolean }
|
||||
unitLessonsMap,
|
||||
unitLoadingMap,
|
||||
courseUnitsMap,
|
||||
courseLoadingMap,
|
||||
referenceMetaMap,
|
||||
onNavigateToCourse,
|
||||
}) => {
|
||||
const groups = ['read_course', 'read_unit', 'read_lesson']
|
||||
.map((type) => ({ type, items: requirements.filter((r) => r.type === type) }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full p-3 md:p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-2 px-2">
|
||||
Requirements
|
||||
</p>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={groups.map((g) => g.type)}
|
||||
className="space-y-1"
|
||||
>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-6">
|
||||
<div className="flex items-center justify-between gap-2 pr-10 lg:pr-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground shrink-0">
|
||||
Requirements
|
||||
</p>
|
||||
{groups.length === 1 && (
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/60 bg-muted px-2 py-0.5 rounded-full shrink-0">
|
||||
{TYPE_LABEL[groups[0].type]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{groups.map(({ type, items }) => (
|
||||
<AccordionItem key={type} value={type} className="border-none">
|
||||
<AccordionTrigger className="px-3 py-2 text-sm font-semibold rounded-lg hover:bg-muted-foreground/10 hover:no-underline">
|
||||
{TYPE_LABEL[type]}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-1">
|
||||
<ul className="space-y-0.5">
|
||||
{items.map((req) => {
|
||||
const Icon = TYPE_ICON[req.type] ?? FileText;
|
||||
const isActive = selection?.reqId === req.requirement_id;
|
||||
<div key={type} className="space-y-1">
|
||||
{/* Section heading — hidden when only one type is visible (entry-scoped) */}
|
||||
{groups.length > 1 && (
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground px-1 mb-2">
|
||||
{TYPE_LABEL[type]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
if (req.type === 'read_unit') {
|
||||
// ── Unit: show lessons as sub-items ──────────────
|
||||
const entry = unitLessonsMap[req.requirement_id];
|
||||
{items.map((req) => {
|
||||
const isSelected = selection?.reqId === req.requirement_id;
|
||||
const done = isCompleted(req.requirement_id, req.reference_id);
|
||||
const meta = referenceMetaMap[req.requirement_id];
|
||||
|
||||
return (
|
||||
<div key={req.requirement_id}>
|
||||
{/* Requirement button */}
|
||||
<button
|
||||
onClick={() => onSelectReq(req)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors text-left ${
|
||||
isSelected
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{done
|
||||
? <CheckCheck className="size-3.5 text-green-500 shrink-0" />
|
||||
: <Circle className="size-3.5 shrink-0 opacity-40" />
|
||||
}
|
||||
<span className="truncate flex-1 font-medium min-w-0">
|
||||
{req.reference_label ?? req.type}
|
||||
</span>
|
||||
<TierBadge tier={meta?.subscription} />
|
||||
</button>
|
||||
|
||||
{/* Breadcrumb — always visible below the button */}
|
||||
{type === 'read_unit' && meta?.courseTitle && (
|
||||
<p className="pl-9 text-[11px] text-muted-foreground truncate -mt-0.5 mb-1">
|
||||
from <span className="font-medium">{meta.courseTitle}</span>
|
||||
</p>
|
||||
)}
|
||||
{type === 'read_lesson' && (meta?.unitTitle || meta?.courseTitle) && (
|
||||
<p className="pl-9 text-[11px] text-muted-foreground truncate -mt-0.5 mb-1">
|
||||
{[meta.unitTitle, meta.courseTitle].filter(Boolean).join(' › ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Sub-tree — only for the selected item */}
|
||||
{isSelected && type === 'read_unit' && (() => {
|
||||
const lessons = unitLessonsMap[req.requirement_id]?.lessons ?? [];
|
||||
const loading = unitLoadingMap[req.requirement_id];
|
||||
const lessons = entry?.lessons ?? [];
|
||||
const done = isCompleted(req.requirement_id, req.reference_id);
|
||||
|
||||
return (
|
||||
<li key={req.requirement_id}>
|
||||
{/* Unit header row (non-clickable — navigates via lessons) */}
|
||||
<div className="flex items-center gap-2 pl-4 pr-3 py-1.5 text-sm rounded-md text-muted-foreground">
|
||||
{done
|
||||
? <CheckCheck className="size-3.5 text-green-500 shrink-0" />
|
||||
: <Circle className="size-3.5 shrink-0 opacity-40" />
|
||||
}
|
||||
<Icon className="size-3.5 shrink-0 opacity-60" />
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{req.reference_label ?? 'Unit'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Lessons sub-list */}
|
||||
<div className="pl-5 mt-1 space-y-0.5 border-l ml-4 mb-2">
|
||||
{loading && (
|
||||
<div className="pl-10 py-1 space-y-1">
|
||||
<div className="py-1 space-y-1.5">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
)}
|
||||
{lessons.map((lesson, i) => {
|
||||
const lessonActive =
|
||||
isActive && selection?.lessonUuid === lesson.uuid;
|
||||
const active = selection?.lessonUuid === lesson.uuid;
|
||||
return (
|
||||
<li
|
||||
<button
|
||||
key={lesson.uuid}
|
||||
onClick={() => onSelectLesson(req, lesson)}
|
||||
className={`flex items-center gap-2 pl-10 pr-3 py-1.5 text-sm rounded-md cursor-pointer transition-colors ${
|
||||
lessonActive
|
||||
? 'bg-muted-foreground/15 font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground'
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors ${
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs tabular-nums w-4 shrink-0 opacity-50">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="text-xs tabular-nums w-4 shrink-0 opacity-40">{i + 1}</span>
|
||||
<span className="truncate">{lesson.title}</span>
|
||||
</li>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</li>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
// ── read_course / read_lesson ─────────────────────────
|
||||
const done = isCompleted(req.requirement_id, req.reference_id);
|
||||
return (
|
||||
<li
|
||||
key={req.requirement_id}
|
||||
onClick={() => onSelectReq(req)}
|
||||
className={`flex items-center gap-2 pl-6 pr-3 py-1.5 text-sm rounded-md cursor-pointer transition-colors ${
|
||||
isActive
|
||||
? 'bg-muted-foreground/10 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:bg-muted-foreground/10 hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{done
|
||||
? <CheckCheck className="size-3.5 text-green-500 shrink-0" />
|
||||
: <Circle className="size-3.5 shrink-0 opacity-40" />
|
||||
}
|
||||
<Icon className="size-3.5 shrink-0 opacity-60" />
|
||||
<span className="truncate">{req.reference_label ?? req.type}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
{isSelected && type === 'read_course' && (() => {
|
||||
const courseData = courseUnitsMap[req.requirement_id] ?? {};
|
||||
const units = courseData.units ?? [];
|
||||
const assessment = courseData.assessment ?? null;
|
||||
const courseId = courseData.course_id;
|
||||
const loading = courseLoadingMap[req.requirement_id];
|
||||
return (
|
||||
<div className="pl-5 mt-1 space-y-3 border-l ml-4 mb-2">
|
||||
{loading && (
|
||||
<div className="py-1 space-y-1.5">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
)}
|
||||
{units.map((unit) => (
|
||||
<div key={unit.unit_id} className="space-y-0.5">
|
||||
<p className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-semibold text-muted-foreground uppercase tracking-wide truncate">
|
||||
<Layers className="size-3 shrink-0 opacity-50" />
|
||||
{unit.title}
|
||||
</p>
|
||||
{(unit.lessons ?? []).map((lesson, lIdx) => {
|
||||
const active = selection?.lessonUuid === lesson.uuid;
|
||||
return (
|
||||
<button
|
||||
key={lesson.uuid}
|
||||
onClick={() => onSelectLesson(req, lesson)}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors ${
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs tabular-nums w-4 shrink-0 opacity-40">{lIdx + 1}</span>
|
||||
<span className="truncate">{lesson.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{unit.quiz && (
|
||||
<button
|
||||
onClick={() => onNavigateToCourse(courseId, { quizUnitId: String(unit.unit_id) })}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ClipboardList className="size-3.5 shrink-0 opacity-50" />
|
||||
<span className="truncate flex-1">{unit.quiz.title || 'Quiz'}</span>
|
||||
{unit.quiz.has_passed
|
||||
? <CheckCircle2 className="size-3 text-green-500 shrink-0" />
|
||||
: <Circle className="size-3 shrink-0 opacity-30" />
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{assessment && (
|
||||
<button
|
||||
onClick={() => onNavigateToCourse(courseId, { seekAssessment: true })}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors text-muted-foreground hover:bg-muted hover:text-foreground border-t pt-2 mt-1"
|
||||
>
|
||||
<GraduationCap className="size-3.5 shrink-0 opacity-50" />
|
||||
<span className="truncate flex-1">{assessment.title || 'Final Assessment'}</span>
|
||||
{assessment.has_passed
|
||||
? <CheckCircle2 className="size-3 text-green-500 shrink-0" />
|
||||
: <Circle className="size-3 shrink-0 opacity-30" />
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</Accordion>
|
||||
<ScrollBar orientation="vertical" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Content: course view ─────────────────────────────────────────────────────
|
||||
const CourseView = ({ req }) => {
|
||||
const [info, setInfo] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [locked, setLocked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!req.reference_id) { setLoading(false); return; }
|
||||
api.get(`/client/courses/uuid/${req.reference_id}`)
|
||||
.then((r) => setInfo(r.data?.data ?? null))
|
||||
.catch((err) => {
|
||||
if (err?.response?.status === 403) setLocked(true);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [req.reference_id]);
|
||||
|
||||
if (loading) return <ContentSkeleton />;
|
||||
if (locked) return <LockedContent />;
|
||||
// ─── Course overview (before first lesson selected) ───────────────────────────
|
||||
const CourseOverview = ({ meta }) => {
|
||||
const { tierMap } = useClientTiers();
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{info?.subscription && <Badge variant="secondary" className="text-xs capitalize">{info.subscription}</Badge>}
|
||||
{info?.level && <Badge variant="outline" className="text-xs capitalize">{info.level}</Badge>}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mt-1">{req.reference_label ?? 'Course'}</h1>
|
||||
</div>
|
||||
{info?.description && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{info.description}</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{meta?.subscription && (() => {
|
||||
const { rank, label, cls } = resolveTierBadge(meta.subscription, tierMap);
|
||||
return (
|
||||
<Badge className={`${cls} capitalize`}>
|
||||
{rank > 0 ? <Lock className="size-3 mr-1" /> : <Tag className="size-3 mr-1" />}
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
{meta?.level && <Badge variant="outline" className="text-xs capitalize">{meta.level}</Badge>}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">{meta?.title ?? 'Course'}</h1>
|
||||
{meta?.description && <p className="text-sm text-muted-foreground leading-relaxed">{meta.description}</p>}
|
||||
<p className="text-sm text-blue-500 dark:text-blue-400 mt-2">Select a lesson from the sidebar to begin reading.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Content: standalone lesson view (for read_lesson requirements) ───────────
|
||||
const LessonView = ({ req }) => {
|
||||
// ─── On-demand lesson fetch for read_course ───────────────────────────────────
|
||||
const CourseUnitLessonView = ({ lessonUuid }) => {
|
||||
const [lesson, setLesson] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lessonUuid) return;
|
||||
setLoading(true);
|
||||
setLesson(null);
|
||||
api.get(`/client/courses/lesson/uuid/${lessonUuid}`)
|
||||
.then((r) => {
|
||||
const d = r.data?.data;
|
||||
if (d) setLesson({ ...d, blocks: d.blocks ?? [] });
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [lessonUuid]);
|
||||
|
||||
if (loading) return <ContentSkeleton />;
|
||||
return <LessonBlock lesson={lesson} loading={false} />;
|
||||
};
|
||||
|
||||
// ─── Standalone lesson view (read_lesson) ─────────────────────────────────────
|
||||
const LessonView = ({ req, onMeta }) => {
|
||||
const [lesson, setLesson] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [locked, setLocked] = useState(false);
|
||||
@@ -202,11 +299,16 @@ const LessonView = ({ req }) => {
|
||||
api.get(`/client/courses/lesson/uuid/${req.reference_id}`)
|
||||
.then((r) => {
|
||||
const d = r.data?.data;
|
||||
if (d) setLesson({ ...d, blocks: d.blocks ?? [] });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err?.response?.status === 403) setLocked(true);
|
||||
if (d) {
|
||||
setLesson({ ...d, blocks: d.blocks ?? [] });
|
||||
onMeta?.(req.requirement_id, {
|
||||
subscription: d.unit?.course?.subscription,
|
||||
courseTitle: d.unit?.course?.title,
|
||||
unitTitle: d.unit?.title,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => { if (err?.response?.status === 403) setLocked(true); })
|
||||
.finally(() => setLoading(false));
|
||||
}, [req.reference_id]);
|
||||
|
||||
@@ -215,7 +317,7 @@ const LessonView = ({ req }) => {
|
||||
return <LessonBlock lesson={lesson} loading={false} />;
|
||||
};
|
||||
|
||||
// ─── Loading skeleton ─────────────────────────────────────────────────────────
|
||||
// ─── Skeletons / locked ───────────────────────────────────────────────────────
|
||||
const ContentSkeleton = () => (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
@@ -225,7 +327,6 @@ const ContentSkeleton = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Locked content placeholder ───────────────────────────────────────────────
|
||||
const LockedContent = () => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
@@ -236,16 +337,14 @@ const LockedContent = () => {
|
||||
<div className="space-y-2 max-w-sm">
|
||||
<h2 className="text-lg font-semibold">Premium / Exclusive Content</h2>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
To take this activity, we advise you to subscribe to one of our available tier plans and unlock access to this content.
|
||||
Subscribe to one of our available tier plans to unlock access to this content.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Button onClick={() => navigate('/plans')} className="gap-1.5">
|
||||
<Zap className="size-4" /> View Available Plans
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Already subscribed? Your plan may not cover this tier.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -258,18 +357,18 @@ const ViewRequirement = () => {
|
||||
const location = useLocation();
|
||||
|
||||
const { task, taskList, loading, fetchTask, fetchTaskList } = useTask();
|
||||
const {
|
||||
fetchProgress, isCompleted, updateLessonProgress, loading: progressLoading,
|
||||
} = useTaskProgress();
|
||||
const { fetchProgress, isCompleted, updateLessonProgress, loading: progressLoading } = useTaskProgress();
|
||||
|
||||
// selection = { reqId, lessonUuid? }
|
||||
const [selection, setSelection] = useState(null);
|
||||
const [unitLessonsMap, setUnitLessonsMap] = useState({});
|
||||
const [unitLoadingMap, setUnitLoadingMap] = useState({});
|
||||
const [lockedReqs, setLockedReqs] = useState(new Set());
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [desktopOpen, setDesktopOpen] = useState(true);
|
||||
const [scrollPct, setScrollPct] = useState(0);
|
||||
const [selection, setSelection] = useState(null);
|
||||
const [unitLessonsMap, setUnitLessonsMap] = useState({});
|
||||
const [unitLoadingMap, setUnitLoadingMap] = useState({});
|
||||
const [courseUnitsMap, setCourseUnitsMap] = useState({});
|
||||
const [courseLoadingMap, setCourseLoadingMap] = useState({});
|
||||
const [referenceMetaMap, setReferenceMetaMap] = useState({});
|
||||
const [lockedReqs, setLockedReqs] = useState(new Set());
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [desktopOpen, setDesktopOpen] = useState(true);
|
||||
const [scrollPct, setScrollPct] = useState(0);
|
||||
const initialised = useRef(false);
|
||||
const lastAutoMarkRef = useRef(null);
|
||||
|
||||
@@ -280,12 +379,25 @@ const ViewRequirement = () => {
|
||||
fetchProgress(groupId, taskListId, taskId);
|
||||
}, [groupId, taskListId, taskId]);
|
||||
|
||||
// ── Filtered requirements ─────────────────────────────────────────────────
|
||||
// ── read_* requirements only ─────────────────────────────────────────────
|
||||
const requirements = (task?.requirements ?? []).filter((r) =>
|
||||
['read_course', 'read_unit', 'read_lesson'].includes(r.type)
|
||||
);
|
||||
|
||||
// ── Fetch lessons for every read_unit requirement ─────────────────────────
|
||||
// ── Scope sidebar to the type that was clicked in ViewTaskDetails ─────────
|
||||
const entryType = useMemo(() => {
|
||||
const s = location.state ?? {};
|
||||
if (s.course) return 'read_course';
|
||||
if (s.unit) return 'read_unit';
|
||||
if (s.lesson) return 'read_lesson';
|
||||
return null;
|
||||
}, [location.state]);
|
||||
|
||||
const sidebarRequirements = entryType
|
||||
? requirements.filter((r) => r.type === entryType)
|
||||
: requirements;
|
||||
|
||||
// ── Fetch lessons for read_unit requirements ──────────────────────────────
|
||||
useEffect(() => {
|
||||
requirements.forEach((req) => {
|
||||
if (req.type !== 'read_unit') return;
|
||||
@@ -293,92 +405,167 @@ const ViewRequirement = () => {
|
||||
setUnitLoadingMap((p) => ({ ...p, [req.requirement_id]: true }));
|
||||
api.get(`/client/courses/unit/uuid/${req.reference_id}/lessons`)
|
||||
.then((r) => {
|
||||
const data = r.data?.data ?? null;
|
||||
const lessons = data?.lessons ?? [];
|
||||
setUnitLessonsMap((p) => ({ ...p, [req.requirement_id]: { meta: data, lessons } }));
|
||||
const data = r.data?.data ?? null;
|
||||
setUnitLessonsMap((p) => ({ ...p, [req.requirement_id]: { meta: data, lessons: data?.lessons ?? [] } }));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err?.response?.status === 403) {
|
||||
setLockedReqs((prev) => new Set(prev).add(req.requirement_id));
|
||||
}
|
||||
if (err?.response?.status === 403)
|
||||
setLockedReqs((p) => new Set(p).add(req.requirement_id));
|
||||
})
|
||||
.finally(() => setUnitLoadingMap((p) => ({ ...p, [req.requirement_id]: false })));
|
||||
});
|
||||
}, [requirements.length]);
|
||||
|
||||
// ── Fetch unit + lesson tree for read_course requirements ─────────────────
|
||||
useEffect(() => {
|
||||
requirements.forEach(async (req) => {
|
||||
if (req.type !== 'read_course') return;
|
||||
if (courseUnitsMap[req.requirement_id] || courseLoadingMap[req.requirement_id]) return;
|
||||
setCourseLoadingMap((p) => ({ ...p, [req.requirement_id]: true }));
|
||||
try {
|
||||
const uuidRes = await api.get(`/client/courses/uuid/${req.reference_id}`);
|
||||
const meta = uuidRes.data?.data;
|
||||
if (!meta?.course_id) return;
|
||||
const fullRes = await api.get(`/client/courses/${meta.course_id}`);
|
||||
const full = fullRes.data?.data;
|
||||
setCourseUnitsMap((p) => ({
|
||||
...p,
|
||||
[req.requirement_id]: {
|
||||
meta: { ...meta, description: full?.description ?? meta.description },
|
||||
units: full?.units ?? [],
|
||||
assessment: full?.assessment ?? null,
|
||||
course_id: meta.course_id,
|
||||
},
|
||||
}));
|
||||
setReferenceMetaMap((p) => ({ ...p, [req.requirement_id]: { subscription: meta.subscription } }));
|
||||
} catch (err) {
|
||||
if (err?.response?.status === 403)
|
||||
setLockedReqs((p) => new Set(p).add(req.requirement_id));
|
||||
} finally {
|
||||
setCourseLoadingMap((p) => ({ ...p, [req.requirement_id]: false }));
|
||||
}
|
||||
});
|
||||
}, [requirements.length]);
|
||||
|
||||
// ── Tier meta for read_unit from unitLessonsMap ───────────────────────────
|
||||
useEffect(() => {
|
||||
requirements.forEach((req) => {
|
||||
if (req.type !== 'read_unit') return;
|
||||
const data = unitLessonsMap[req.requirement_id];
|
||||
if (!data?.meta?.course || referenceMetaMap[req.requirement_id]) return;
|
||||
setReferenceMetaMap((p) => ({
|
||||
...p,
|
||||
[req.requirement_id]: {
|
||||
subscription: data.meta.course.subscription,
|
||||
courseTitle: data.meta.course.title,
|
||||
},
|
||||
}));
|
||||
});
|
||||
}, [unitLessonsMap]);
|
||||
|
||||
// ── Tier meta for read_lesson (separate fetch) ────────────────────────────
|
||||
useEffect(() => {
|
||||
requirements.forEach(async (req) => {
|
||||
if (req.type !== 'read_lesson' || referenceMetaMap[req.requirement_id]) return;
|
||||
try {
|
||||
const res = await api.get(`/client/courses/lesson/uuid/${req.reference_id}`);
|
||||
const d = res.data?.data;
|
||||
if (d) setReferenceMetaMap((p) => ({
|
||||
...p,
|
||||
[req.requirement_id]: {
|
||||
subscription: d.unit?.course?.subscription,
|
||||
courseTitle: d.unit?.course?.title,
|
||||
unitTitle: d.unit?.title,
|
||||
},
|
||||
}));
|
||||
} catch { /* silently fail */ }
|
||||
});
|
||||
}, [requirements.length]);
|
||||
|
||||
// ── Auto-select from router state or first item ───────────────────────────
|
||||
useEffect(() => {
|
||||
if (initialised.current || !requirements.length) return;
|
||||
|
||||
const state = location.state ?? {};
|
||||
const unitId = state.unit?.id;
|
||||
const lesId = state.lesson?.id;
|
||||
const state = location.state ?? {};
|
||||
|
||||
if (unitId) {
|
||||
const req = requirements.find((r) => r.requirement_id === unitId);
|
||||
if (req) {
|
||||
// Select unit; lesson will be auto-picked once lessons are fetched
|
||||
setSelection({ reqId: req.requirement_id, lessonUuid: null });
|
||||
initialised.current = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (lesId) {
|
||||
const req = requirements.find((r) => r.requirement_id === lesId);
|
||||
if (req) {
|
||||
setSelection({ reqId: req.requirement_id });
|
||||
initialised.current = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Default: first requirement
|
||||
const first = requirements[0];
|
||||
if (first.type === 'read_unit') {
|
||||
setSelection({ reqId: first.requirement_id, lessonUuid: null });
|
||||
} else {
|
||||
setSelection({ reqId: first.requirement_id });
|
||||
const trySelect = (key, type) => {
|
||||
if (!state[key]?.id) return false;
|
||||
const req = requirements.find((r) => r.requirement_id === state[key].id);
|
||||
if (!req) return false;
|
||||
const needsLesson = type === 'read_unit' || type === 'read_course';
|
||||
setSelection({ reqId: req.requirement_id, ...(needsLesson ? { lessonUuid: null } : {}) });
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!trySelect('course', 'read_course') && !trySelect('unit', 'read_unit') && !trySelect('lesson', 'read_lesson')) {
|
||||
const first = requirements[0];
|
||||
const needsLesson = first.type === 'read_unit' || first.type === 'read_course';
|
||||
setSelection({ reqId: first.requirement_id, ...(needsLesson ? { lessonUuid: null } : {}) });
|
||||
}
|
||||
|
||||
initialised.current = true;
|
||||
}, [requirements, location.state]);
|
||||
|
||||
// ── Auto-pick first lesson once unit lessons are loaded ───────────────────
|
||||
// ── Auto-pick first lesson once unit/course lessons load ─────────────────
|
||||
useEffect(() => {
|
||||
if (!selection) return;
|
||||
if (!selection || selection.lessonUuid !== null) return;
|
||||
const req = requirements.find((r) => r.requirement_id === selection.reqId);
|
||||
if (req?.type !== 'read_unit') return;
|
||||
if (selection.lessonUuid !== null) return; // already have one (null = "not picked yet")
|
||||
const lessons = unitLessonsMap[selection.reqId]?.lessons ?? [];
|
||||
if (lessons.length) {
|
||||
setSelection((p) => ({ ...p, lessonUuid: lessons[0].uuid }));
|
||||
if (req?.type === 'read_unit') {
|
||||
const lessons = unitLessonsMap[req.requirement_id]?.lessons ?? [];
|
||||
if (lessons.length) setSelection((p) => ({ ...p, lessonUuid: lessons[0].uuid }));
|
||||
}
|
||||
}, [unitLessonsMap, selection?.reqId]);
|
||||
if (req?.type === 'read_course') {
|
||||
const units = courseUnitsMap[req.requirement_id]?.units ?? [];
|
||||
const first = units.flatMap((u) => u.lessons ?? [])[0];
|
||||
if (first) setSelection((p) => ({ ...p, lessonUuid: first.uuid }));
|
||||
}
|
||||
}, [unitLessonsMap, courseUnitsMap, selection?.reqId]);
|
||||
|
||||
// ── Derive selected objects ───────────────────────────────────────────────
|
||||
// ── Derived objects ───────────────────────────────────────────────────────
|
||||
const selectedReq = requirements.find((r) => r.requirement_id === selection?.reqId) ?? null;
|
||||
|
||||
const selectedLesson = (() => {
|
||||
if (!selectedReq || selectedReq.type !== 'read_unit') return null;
|
||||
const lessons = unitLessonsMap[selectedReq.requirement_id]?.lessons ?? [];
|
||||
return lessons.find((l) => l.uuid === selection?.lessonUuid) ?? null;
|
||||
if (!selectedReq || !selection?.lessonUuid) return null;
|
||||
if (selectedReq.type === 'read_unit') {
|
||||
return (unitLessonsMap[selectedReq.requirement_id]?.lessons ?? [])
|
||||
.find((l) => l.uuid === selection.lessonUuid) ?? null;
|
||||
}
|
||||
if (selectedReq.type === 'read_course') {
|
||||
const units = courseUnitsMap[selectedReq.requirement_id]?.units ?? [];
|
||||
return units.flatMap((u) => u.lessons ?? []).find((l) => l.uuid === selection.lessonUuid) ?? null;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
// ── Next lesson (cross-unit for read_course) ──────────────────────────────
|
||||
const nextLesson = (() => {
|
||||
if (!selectedReq) return null;
|
||||
if (selectedReq.type === 'read_unit') {
|
||||
const lessons = unitLessonsMap[selectedReq.requirement_id]?.lessons ?? [];
|
||||
const idx = lessons.findIndex((l) => l.uuid === selection?.lessonUuid);
|
||||
return idx !== -1 && idx < lessons.length - 1 ? lessons[idx + 1] : null;
|
||||
}
|
||||
if (selectedReq.type === 'read_course') {
|
||||
const flat = (courseUnitsMap[selectedReq.requirement_id]?.units ?? []).flatMap((u) => u.lessons ?? []);
|
||||
const idx = flat.findIndex((l) => l.uuid === selection?.lessonUuid);
|
||||
return idx !== -1 && idx < flat.length - 1 ? flat[idx + 1] : null;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
// ── Scroll tracking ───────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
// Re-evaluate immediately — short content may already be at 100%
|
||||
const h = document.documentElement.scrollHeight - window.innerHeight;
|
||||
setScrollPct(h <= 40 ? 100 : 0);
|
||||
}, [selection]);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
const scrollH = document.documentElement.scrollHeight;
|
||||
const viewH = window.innerHeight;
|
||||
const scrollY = window.scrollY;
|
||||
const h = scrollH - viewH;
|
||||
if (h <= 0) { setScrollPct(100); return; }
|
||||
// Within 40px of bottom counts as 100% (handles discrete mouse-wheel steps)
|
||||
if (h - scrollY <= 40) { setScrollPct(100); return; }
|
||||
setScrollPct(Math.min(99, Math.round((scrollY / h) * 100)));
|
||||
const h = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const y = window.scrollY;
|
||||
if (h <= 0 || h - y <= 40) { setScrollPct(100); return; }
|
||||
setScrollPct(Math.min(99, Math.round((y / h) * 100)));
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
@@ -396,9 +583,10 @@ const ViewRequirement = () => {
|
||||
{ label: 'Requirements' },
|
||||
];
|
||||
|
||||
// ── Selection handlers ────────────────────────────────────────────────────
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||
const handleSelectReq = useCallback((req) => {
|
||||
setSelection({ reqId: req.requirement_id });
|
||||
const needsLesson = req.type === 'read_unit' || req.type === 'read_course';
|
||||
setSelection({ reqId: req.requirement_id, ...(needsLesson ? { lessonUuid: null } : {}) });
|
||||
setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
@@ -407,58 +595,53 @@ const ViewRequirement = () => {
|
||||
setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
// ── Mark done (marks the requirement, not individual lessons) ─────────────
|
||||
const handleMarkDone = useCallback(async () => {
|
||||
if (!selectedReq) return;
|
||||
const completed = !isCompleted(selectedReq.requirement_id, selectedReq.reference_id);
|
||||
await updateLessonProgress(groupId, taskListId, taskId, selectedReq.requirement_id, {
|
||||
reference_id: selectedReq.reference_id,
|
||||
reference_id: selectedReq.reference_id,
|
||||
completed,
|
||||
siblingLessons: [],
|
||||
});
|
||||
}, [selectedReq, groupId, taskListId, taskId, isCompleted, updateLessonProgress]);
|
||||
|
||||
const selectedDone = selectedReq
|
||||
? isCompleted(selectedReq.requirement_id, selectedReq.reference_id)
|
||||
: false;
|
||||
|
||||
// ── Derive next lesson within same unit (for scroll-to-next) ─────────────
|
||||
const nextLesson = (() => {
|
||||
if (!selectedReq || selectedReq.type !== 'read_unit') return null;
|
||||
const lessons = unitLessonsMap[selectedReq.requirement_id]?.lessons ?? [];
|
||||
const idx = lessons.findIndex((l) => l.uuid === selection?.lessonUuid);
|
||||
return idx !== -1 && idx < lessons.length - 1 ? lessons[idx + 1] : null;
|
||||
})();
|
||||
|
||||
// ── Mark-done gate: last lesson (or non-unit) AND scrolled to 100% ────────
|
||||
const isLastContent = selectedReq?.type !== 'read_unit' || !nextLesson;
|
||||
const selectedDone = selectedReq ? isCompleted(selectedReq.requirement_id, selectedReq.reference_id) : false;
|
||||
const isLastContent = !nextLesson;
|
||||
const canMarkDone = scrollPct >= 100 && isLastContent;
|
||||
|
||||
// ── Auto turn-in: fires once per requirement when user reaches the end ────────
|
||||
// ── Auto turn-in ──────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!canMarkDone || selectedDone || progressLoading || !selectedReq) return;
|
||||
if (lockedReqs.has(selectedReq.requirement_id)) return;
|
||||
// Deduplicate so scrolling back up and down doesn't re-fire
|
||||
const key = selectedReq.requirement_id + (selection?.lessonUuid ?? '');
|
||||
if (lastAutoMarkRef.current === key) return;
|
||||
lastAutoMarkRef.current = key;
|
||||
handleMarkDone();
|
||||
}, [canMarkDone, selectedDone]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleNavigateToCourse = useCallback((courseId, opts) => {
|
||||
navigate(`/course/${courseId}/unit`, { state: opts });
|
||||
}, [navigate]);
|
||||
|
||||
const sidebarProps = {
|
||||
requirements,
|
||||
requirements: sidebarRequirements,
|
||||
selection,
|
||||
onSelectReq: handleSelectReq,
|
||||
onSelectLesson: handleSelectLesson,
|
||||
onSelectReq: handleSelectReq,
|
||||
onSelectLesson: handleSelectLesson,
|
||||
isCompleted,
|
||||
unitLessonsMap,
|
||||
unitLoadingMap,
|
||||
courseUnitsMap,
|
||||
courseLoadingMap,
|
||||
referenceMetaMap,
|
||||
onNavigateToCourse: handleNavigateToCourse,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title={task ? `${task.name} – Requirements - STARR` : undefined} />
|
||||
{/* ── Floating "up next" (within unit lessons) ─────────────────── */}
|
||||
|
||||
{/* ── Floating "up next" ────────────────────────────────────────── */}
|
||||
{scrollPct >= 100 && nextLesson && (
|
||||
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div
|
||||
@@ -531,13 +714,13 @@ const ViewRequirement = () => {
|
||||
|
||||
{/* ── Desktop sidebar ───────────────────────────────────────────── */}
|
||||
{desktopOpen && (
|
||||
<div className="hidden lg:block fixed top-[124px] bottom-0 left-0 w-80 bg-muted border-r">
|
||||
<div className="hidden lg:block fixed top-[124px] bottom-0 left-0 w-96 bg-muted/60 border-r">
|
||||
<SidebarContent {...sidebarProps} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Main content ──────────────────────────────────────────────── */}
|
||||
<div className={`mt-32 ${desktopOpen ? 'lg:ml-80' : 'lg:ml-0'} p-4 md:p-6 min-h-screen`}>
|
||||
<div className={`mt-32 ${desktopOpen ? 'lg:ml-96' : 'lg:ml-0'} p-4 md:p-6 min-h-screen`}>
|
||||
<div className="relative w-full h-full max-w-3xl mx-auto">
|
||||
{loading ? (
|
||||
<ContentSkeleton />
|
||||
@@ -548,48 +731,35 @@ const ViewRequirement = () => {
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Content per type */}
|
||||
{selectedReq.type === 'read_course' && (
|
||||
<CourseView req={selectedReq} />
|
||||
lockedReqs.has(selectedReq.requirement_id) ? <LockedContent /> :
|
||||
selection?.lessonUuid ? <CourseUnitLessonView lessonUuid={selection.lessonUuid} /> :
|
||||
courseLoadingMap[selectedReq.requirement_id] ? <ContentSkeleton /> :
|
||||
<CourseOverview meta={courseUnitsMap[selectedReq.requirement_id]?.meta} />
|
||||
)}
|
||||
|
||||
{selectedReq.type === 'read_unit' && (
|
||||
lockedReqs.has(selectedReq.requirement_id)
|
||||
? <LockedContent />
|
||||
: selectedLesson
|
||||
? <LessonBlock lesson={selectedLesson} loading={false} />
|
||||
: <ContentSkeleton />
|
||||
lockedReqs.has(selectedReq.requirement_id) ? <LockedContent /> :
|
||||
selectedLesson ? <LessonBlock lesson={selectedLesson} loading={false} /> :
|
||||
<ContentSkeleton />
|
||||
)}
|
||||
|
||||
{selectedReq.type === 'read_lesson' && (
|
||||
<LessonView req={selectedReq} />
|
||||
<LessonView
|
||||
req={selectedReq}
|
||||
onMeta={(reqId, meta) =>
|
||||
setReferenceMetaMap((p) => ({ ...p, [reqId]: meta }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Turn-in footer — hidden for locked requirements */}
|
||||
{!lockedReqs.has(selectedReq.requirement_id) && (
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
{selectedDone ? (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
You have completed this requirement.
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleMarkDone}
|
||||
disabled={progressLoading}
|
||||
variant="outline"
|
||||
>
|
||||
<CheckCheck className="size-4" />
|
||||
Mark as not done
|
||||
</Button>
|
||||
</>
|
||||
) : nextLesson ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Continue reading all lessons to complete this requirement.
|
||||
</p>
|
||||
{/* Turn-in footer — only shown while not yet completed */}
|
||||
{!lockedReqs.has(selectedReq.requirement_id) && !selectedDone && (
|
||||
<div className="flex items-center pt-4 border-t">
|
||||
{nextLesson ? (
|
||||
<p className="text-sm text-muted-foreground">Continue reading all lessons to complete this requirement.</p>
|
||||
) : !canMarkDone ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scroll to the end to complete this requirement.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Scroll to the end to complete this requirement.</p>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<RefreshCw className="size-3.5 animate-spin" /> Turning in…
|
||||
|
||||
@@ -247,13 +247,14 @@ const ViewTask = () => {
|
||||
fetchProgress,
|
||||
isVisited, isCompleted,
|
||||
visitLink,
|
||||
unvisitLink,
|
||||
resetProgress,
|
||||
} = useTaskProgress();
|
||||
|
||||
const { group, fetchGroup } = useGroup();
|
||||
|
||||
const [taskModal, setTaskModal] = useState(false);
|
||||
const [uploadState, setUploadState] = useState({ files: [], isUploading: false, isOverLimit: false });
|
||||
const [uploadState, setUploadState] = useState({ files: [], isUploading: false });
|
||||
const [note, setNote] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [previewFile, setPreviewFile] = useState(null);
|
||||
@@ -285,9 +286,13 @@ const ViewTask = () => {
|
||||
await visitLink(groupId, taskListId, taskId, requirementId);
|
||||
}, [groupId, taskListId, taskId, visitLink]);
|
||||
|
||||
const handleUnvisitLink = useCallback(async (requirementId) => {
|
||||
await unvisitLink(groupId, taskListId, taskId, requirementId);
|
||||
}, [groupId, taskListId, taskId, unvisitLink]);
|
||||
|
||||
// ── Submit handler ────────────────────────────────────────────────────────
|
||||
const handleSubmit = async () => {
|
||||
if (uploadState.isUploading || uploadState.isOverLimit) return;
|
||||
if (uploadState.isUploading) return;
|
||||
if (!uploadState.files.length) return;
|
||||
|
||||
setSubmitting(true);
|
||||
@@ -329,7 +334,7 @@ const ViewTask = () => {
|
||||
|
||||
setTaskModal(false);
|
||||
setNote('');
|
||||
setUploadState({ files: [], isUploading: false, isOverLimit: false });
|
||||
setUploadState({ files: [], isUploading: false });
|
||||
} catch (err) {
|
||||
toast.error('Failed to submit. Please try again.');
|
||||
} finally {
|
||||
@@ -371,7 +376,6 @@ const ViewTask = () => {
|
||||
disabled={
|
||||
submitting ||
|
||||
uploadState.isUploading ||
|
||||
uploadState.isOverLimit ||
|
||||
uploadState.files.length === 0
|
||||
}
|
||||
>
|
||||
@@ -463,6 +467,7 @@ const ViewTask = () => {
|
||||
)
|
||||
}
|
||||
onVisit={handleVisitLink}
|
||||
onUnvisit={handleUnvisitLink}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -476,6 +481,9 @@ const ViewTask = () => {
|
||||
description: r.description ?? '',
|
||||
completed: isCompleted(r.requirement_id, r.reference_id),
|
||||
}))}
|
||||
groupId={groupId}
|
||||
taskListId={taskListId}
|
||||
taskId={taskId}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 { Badge } from "@/components/ui/badge";
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("en-PH", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
function InfoRow({ label, value }) {
|
||||
return (
|
||||
@@ -20,6 +12,7 @@ function InfoRow({ label, value }) {
|
||||
}
|
||||
|
||||
export default function MemberDetailTabs({ member }) {
|
||||
const { fmtDate } = useDateFormat();
|
||||
const info = member.personal_info ?? {};
|
||||
const name = info.name ?? {};
|
||||
const phones = info.phone_number ?? [];
|
||||
@@ -39,7 +32,7 @@ export default function MemberDetailTabs({ member }) {
|
||||
<InfoRow label="Last name" value={name.last_name} />
|
||||
<InfoRow label="Middle name" value={name.middle_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="Phone"
|
||||
@@ -74,7 +67,7 @@ export default function MemberDetailTabs({ member }) {
|
||||
{member.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
<InfoRow label="Joined group" value={formatDate(member.UserGroupMember?.joined_at)} />
|
||||
<InfoRow label="Joined group" value={fmtDate(member.UserGroupMember?.joined_at)} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -18,14 +18,7 @@ const STATUS_LABEL = {
|
||||
not_started: "Not started",
|
||||
};
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("en-PH", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const map = {
|
||||
@@ -45,6 +38,7 @@ export default function TaskListDetail({ taskList }) {
|
||||
const tasks = taskList.tasks ?? [];
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
// ── Derived stats ───────────────────────────────────────────────────────
|
||||
const counts = useMemo(() => {
|
||||
@@ -151,8 +145,8 @@ export default function TaskListDetail({ taskList }) {
|
||||
|
||||
{/* Meta */}
|
||||
<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>Assigned: <span className="text-foreground">{formatDate(taskList.TaskListGroup?.assignedAt)}</span></p>
|
||||
<p>Created: <span className="text-foreground">{fmtDate(taskList.createdAt)}</span></p>
|
||||
<p>Assigned: <span className="text-foreground">{fmtDate(taskList.TaskListGroup?.assignedAt)}</span></p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -195,7 +189,7 @@ export default function TaskListDetail({ taskList }) {
|
||||
<StatusBadge status={task.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatDate(task.due_date)}
|
||||
{fmtDate(task.due_date)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
@@ -19,17 +19,11 @@ import { PieBreakdown } from "@/components/generic/Dashboard/PieBreakdown";
|
||||
import { BarBreakdown } from "@/components/generic/Dashboard/BarBreakdown";
|
||||
import TaskListDetail from "./TaskListDetail";
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("en-PH", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
export default function TaskListsTab({ taskLists = [] }) {
|
||||
const [selected, setSelected] = useState(null);
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
// PieBreakdown: overall task completion status across all lists
|
||||
const completionPieData = useMemo(() => {
|
||||
@@ -127,7 +121,7 @@ export default function TaskListsTab({ taskLists = [] }) {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatDate(tl.TaskListGroup?.assignedAt)}
|
||||
{fmtDate(tl.TaskListGroup?.assignedAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
|
||||
@@ -7,15 +7,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useStaffGroups } from "@/contexts/StaffGroupContext";
|
||||
import MembersTable from "../components/members/MembersTable";
|
||||
import TaskListsTab from "../components/TaskListsTab";
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("en-PH", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
function getInitials(name = "") {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
@@ -27,6 +19,7 @@ function getInitials(name = "") {
|
||||
export default function GroupDetailPage() {
|
||||
const { groupId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
const {
|
||||
fetchGroupById,
|
||||
@@ -128,8 +121,8 @@ export default function GroupDetailPage() {
|
||||
</div>
|
||||
|
||||
<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>Updated <span className="text-foreground">{formatDate(group.updatedAt)}</span></p>
|
||||
<p>Created <span className="text-foreground">{fmtDate(group.createdAt)}</span></p>
|
||||
<p>Updated <span className="text-foreground">{fmtDate(group.updatedAt)}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user