From bac7168b1ea214a39642967932787e87330d4c4a Mon Sep 17 00:00:00 2001
From: Kenneth Obsequio
Date: Sun, 28 Jun 2026 11:30:01 +0800
Subject: [PATCH] push
pushy
Signed-off-by: Kenneth Obsequio
---
src/App.jsx | 13 +-
src/components/generic/AssetPickerSheet.jsx | 245 +++--
.../generic/ClientNotificationBell.jsx | 24 +-
.../generic/Dialogs/BanUserDialog.jsx | 230 +++++
.../generic/Dialogs/UnbanDialog.jsx | 93 ++
src/components/generic/NotificationBell.jsx | 15 +-
src/components/generic/Sheet/AddSheet.jsx | 57 +-
src/components/generic/Sheet/FilterSheet.jsx | 10 +-
src/components/generic/UserMenu.jsx | 64 +-
src/contexts/AdminTierCategoriesContext.jsx | 76 ++
src/contexts/AdminTierPoliciesContext.jsx | 60 ++
src/contexts/AdminTiersContext.jsx | 25 -
src/contexts/AdminUserContext.jsx | 83 ++
src/contexts/AuthContext.jsx | 3 +-
.../ClientCourseReadingProgressContext.jsx | 32 +-
src/contexts/ClientCoursesContext.jsx | 7 +
src/contexts/ClientTaskProgressContext.jsx | 24 +
src/contexts/ClientTiersProvider.jsx | 35 +
src/contexts/DateTimePreferenceContext.jsx | 32 +
src/hooks/useDateFormat.js | 37 +
.../courses/CourseReadingProgressList.jsx | 13 +-
.../admin/components/courses/UnitsTable.jsx | 2 +-
.../admin/components/tiers/CoursePicker.jsx | 146 +++
.../admin/components/tiers/PaymentsTable.jsx | 16 +-
.../admin/components/tiers/TierPlansTable.jsx | 40 +-
.../admin/components/users/UserTable.jsx | 78 +-
.../config/tiers/payments/columns.config.jsx | 59 +-
.../config/tiers/plans/toolbar.config.jsx | 13 +-
.../user_groups/view/rowActions.config.jsx | 19 +-
.../user_groups/view/selection.config.jsx | 30 +-
.../admin/config/users/rowActions.config.jsx | 33 +-
.../admin/config/users/selection.config.jsx | 35 +-
.../admin/pages/activity/ActivityFeed.jsx | 15 +-
.../admin/pages/activity/UserActivityPage.jsx | 7 +-
.../advertisements/AdvertisementList.jsx | 14 +-
.../advertisements/ViewAdvertisement.jsx | 17 +-
.../admin/pages/assets/ViewAudioAsset.jsx | 6 +-
.../admin/pages/assets/ViewDocumentAsset.jsx | 6 +-
.../admin/pages/assets/ViewImageAsset.jsx | 6 +-
.../admin/pages/assets/ViewVideoAsset.jsx | 6 +-
src/modules/admin/pages/courses/AddCourse.jsx | 18 +-
.../admin/pages/courses/CourseAssessment.jsx | 30 +-
.../admin/pages/courses/EditCourse.jsx | 24 +-
.../admin/pages/courses/ViewAssessment.jsx | 33 +-
.../admin/pages/courses/ViewCourse.jsx | 9 +-
.../pages/courses/lessons/LessonsList.jsx | 6 +-
.../pages/courses/lessons/ViewLesson.jsx | 9 +-
.../admin/pages/courses/units/EditUnit.jsx | 2 +-
.../units/{UnitQuiz.jsx => ModifyQuiz.jsx} | 32 +-
.../admin/pages/courses/units/UnitsList.jsx | 8 +-
.../pages/courses/units/ViewUnitQuiz.jsx | 21 +-
.../admin/pages/task_list/CreateTaskList.jsx | 2 +
.../admin/pages/task_list/ViewTaskList.jsx | 8 +-
.../admin/pages/task_list/task/EditTask.jsx | 2 +-
.../admin/pages/task_list/task/ViewTask.jsx | 8 +-
src/modules/admin/pages/tiers/AddPlan.jsx | 395 +++-----
src/modules/admin/pages/tiers/EditPlan.jsx | 179 +---
.../admin/pages/tiers/EditTierCategory.jsx | 353 +++++++
src/modules/admin/pages/tiers/PlanPolicy.jsx | 298 ++++++
.../admin/pages/tiers/SystemBadges.jsx | 222 +++++
.../admin/pages/tiers/TierCategories.jsx | 176 ++++
.../admin/pages/tiers/UserTierList.jsx | 59 +-
src/modules/admin/pages/tiers/ViewPayment.jsx | 25 +-
src/modules/admin/pages/tiers/ViewPlan.jsx | 76 +-
.../admin/pages/user_groups/ViewGroup.jsx | 220 ++++-
src/modules/admin/pages/users/ViewUser.jsx | 176 +++-
src/modules/admin/routes/AdminRoutes.jsx | 37 +-
src/modules/auth/components/LoginForm.jsx | 11 +
src/modules/auth/pages/OAuthCallback.jsx | 83 +-
src/modules/auth/routes/AuthRoutes.jsx | 2 +
.../components/blocks/CourseCompleteBlock.jsx | 11 +-
.../client/components/blocks/FileUpload.jsx | 67 +-
.../client/components/blocks/QuizBlock.jsx | 906 ++++++++++++++----
.../client/components/blocks/ReadCourse.jsx | 50 +-
.../client/components/blocks/ReadLesson.jsx | 25 +-
.../client/components/blocks/ReadUnit.jsx | 45 +-
.../client/components/blocks/VisitLink.jsx | 126 ++-
src/modules/client/layout/ClientLayout.jsx | 23 +-
src/modules/client/pages/AccountSettings.jsx | 91 +-
src/modules/client/pages/Checkout.jsx | 17 +-
src/modules/client/pages/CourseCheckout.jsx | 14 +-
src/modules/client/pages/CourseDetails.jsx | 35 +-
src/modules/client/pages/CourseList.jsx | 229 ++---
src/modules/client/pages/Dashboard.jsx | 283 +++---
src/modules/client/pages/MyAchievements.jsx | 6 +-
src/modules/client/pages/MyCertificates.jsx | 6 +-
src/modules/client/pages/PlanList.jsx | 98 +-
src/modules/client/pages/Profile.jsx | 124 ++-
src/modules/client/pages/UnitList.jsx | 453 +++++++--
src/modules/client/pages/ViewPlan.jsx | 14 +-
src/modules/client/pages/ViewRequirement.jsx | 710 ++++++++------
src/modules/client/pages/ViewTask.jsx | 16 +-
src/modules/public/pages/Suspended.jsx | 66 ++
.../staff/components/MemberDetailTabs.jsx | 15 +-
.../staff/components/TaskListDetail.jsx | 16 +-
src/modules/staff/components/TaskListsTab.jsx | 12 +-
src/modules/staff/pages/GroupDetailPage.jsx | 15 +-
src/utils/datetime.util.js | 87 ++
src/utils/tierBadge.util.js | 24 +
src/utils/tierColors.js | 100 ++
100 files changed, 5958 insertions(+), 1976 deletions(-)
create mode 100644 src/components/generic/Dialogs/BanUserDialog.jsx
create mode 100644 src/components/generic/Dialogs/UnbanDialog.jsx
create mode 100644 src/contexts/AdminTierCategoriesContext.jsx
create mode 100644 src/contexts/AdminTierPoliciesContext.jsx
create mode 100644 src/contexts/DateTimePreferenceContext.jsx
create mode 100644 src/hooks/useDateFormat.js
create mode 100644 src/modules/admin/components/tiers/CoursePicker.jsx
rename src/modules/admin/pages/courses/units/{UnitQuiz.jsx => ModifyQuiz.jsx} (95%)
create mode 100644 src/modules/admin/pages/tiers/EditTierCategory.jsx
create mode 100644 src/modules/admin/pages/tiers/PlanPolicy.jsx
create mode 100644 src/modules/admin/pages/tiers/SystemBadges.jsx
create mode 100644 src/modules/admin/pages/tiers/TierCategories.jsx
create mode 100644 src/modules/public/pages/Suspended.jsx
create mode 100644 src/utils/datetime.util.js
create mode 100644 src/utils/tierBadge.util.js
create mode 100644 src/utils/tierColors.js
diff --git a/src/App.jsx b/src/App.jsx
index d921180..2a5639f 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -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() {
-
-
-
-
-
+
+
+
+
+
+
+
);
diff --git a/src/components/generic/AssetPickerSheet.jsx b/src/components/generic/AssetPickerSheet.jsx
index 52442b8..b12f994 100644
--- a/src/components/generic/AssetPickerSheet.jsx
+++ b/src/components/generic/AssetPickerSheet.jsx
@@ -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 (
-
-
- No {fileType} assets found.
-
-
- );
-}
+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 (
{thumb ? (
-
+
) : (
No preview
@@ -48,6 +49,9 @@ function AssetCard({ asset, selected, onSelect }) {
{asset.display_name}
+ {asset.extension && (
+
{asset.extension}
+ )}
{selected && (
@@ -58,45 +62,119 @@ function AssetCard({ asset, selected, onSelect }) {
);
}
+function EmptyState({ fileType }) {
+ return (
+
+
No {fileType} assets found.
+
+ );
+}
+
+// ─── 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 (
@@ -114,13 +194,11 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
{/* ── Header ── */}
Select {label}
-
- Click an asset to attach it.
-
+ Click an asset to attach it.
- {/* ── Search ── */}
-
+ {/* ── Search + Filter ── */}
+
@@ -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"
/>
-
- {loading ? : "Search"}
-
+
+ {extOptions.length > 0 && (
+
+
+
+
+ {hasActiveFilters && (
+
+ {activeExts.size}
+
+ )}
+
+
+
+
+
File type
+ {hasActiveFilters && (
+
+ Clear
+
+ )}
+
+
+ {extOptions.map((ext) => (
+ 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}
+
+ ))}
+
+
+
+ )}
+
+ {hasActiveFilters && (
+
+ {[...activeExts].map((ext) => (
+
+ {ext}
+ toggleExt(ext)} className="ml-0.5 hover:opacity-70">
+
+
+
+ ))}
+
+ )}
{/* ── Grid ── */}
@@ -156,6 +290,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
@@ -194,4 +329,4 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
);
-}
\ No newline at end of file
+}
diff --git a/src/components/generic/ClientNotificationBell.jsx b/src/components/generic/ClientNotificationBell.jsx
index 63e9048..777ecfa 100644
--- a/src/components/generic/ClientNotificationBell.jsx
+++ b/src/components/generic/ClientNotificationBell.jsx
@@ -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() {
-
+
Notifications
{unseenCount > 0 && (
-
Mark all as read
-
+
)}
@@ -186,7 +178,7 @@ export default function ClientNotificationBell() {
{selected?.type}
- {selected ? formatDate(selected.createdAt) : ""}
+ {selected ? fmtDateTime(selected.createdAt) : ""}
diff --git a/src/components/generic/Dialogs/BanUserDialog.jsx b/src/components/generic/Dialogs/BanUserDialog.jsx
new file mode 100644
index 0000000..46fadef
--- /dev/null
+++ b/src/components/generic/Dialogs/BanUserDialog.jsx
@@ -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:
r.name} ... />
+ * Bulk:
+ *
+ * @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 (
+
+
+
+
+ Ban {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
+
+
+ You are about to ban{" "}
+ {displayName} .
+ They will be logged out immediately and blocked from accessing the platform.
+
+
+
+
+ {/* Reason */}
+
+
+ {/* Ban type */}
+
+ Ban Duration
+ setForm((f) => ({ ...f, ban_type: v, expires_at: "" }))}
+ className="flex gap-6"
+ >
+
+
+ Permanent
+
+
+
+ Temporary
+
+
+
+
+ {/* Expiry — shown only for temporary */}
+ {form.ban_type === "temporary" && (
+
+
+ Ban Until *
+
+
+
+
+
+ {form._date ? fmtDate(form._date) : "Pick a 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
+ />
+
+
+
+
+ Time
+
+ {
+ 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"
+ />
+
+
+
+
+
+
+
+
+
+
+ {errors.expires_at && (
+
{errors.expires_at}
+ )}
+
+ )}
+
+
+
+ handleOpenChange(false)} disabled={loading}>
+ Cancel
+
+
+ {loading && }
+ Ban {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
+
+
+
+
+ );
+}
diff --git a/src/components/generic/Dialogs/UnbanDialog.jsx b/src/components/generic/Dialogs/UnbanDialog.jsx
new file mode 100644
index 0000000..33a2ee5
--- /dev/null
+++ b/src/components/generic/Dialogs/UnbanDialog.jsx
@@ -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: r.name} ... />
+ * Bulk:
+ *
+ * @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 (
+
+
+
+ Unban {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
+
+ Remove the ban on{" "}
+ {displayName} .
+ They will regain access to the platform immediately.
+
+
+
+
+
+ Lift Reason (optional)
+
+
+
+
+ handleOpenChange(false)} disabled={loading}>
+ Cancel
+
+
+ {loading && }
+ Unban {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
+
+
+
+
+ );
+}
diff --git a/src/components/generic/NotificationBell.jsx b/src/components/generic/NotificationBell.jsx
index 15b474d..112ed8e 100644
--- a/src/components/generic/NotificationBell.jsx
+++ b/src/components/generic/NotificationBell.jsx
@@ -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() {
{selected?.type?.replace(/_/g, ' ')}
- {selected ? formatDate(selected.createdAt) : ""}
+ {selected ? fmtDateTime(selected.createdAt) : ""}
diff --git a/src/components/generic/Sheet/AddSheet.jsx b/src/components/generic/Sheet/AddSheet.jsx
index cd6264c..7ef0e15 100644
--- a/src/components/generic/Sheet/AddSheet.jsx
+++ b/src/components/generic/Sheet/AddSheet.jsx
@@ -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({
) : (
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 (
toggle(id)}
/>
-
+
{label}
{sub && (
{sub}
)}
+ {warning && (
+
+
+ Also in: {warning}
+
+ )}
);
@@ -197,17 +216,25 @@ export function AddSheet({
{/* Footer */}
-
-
- Cancel
-
-
- {selected.length > 0 ? `${submitLabel} (${selected.length})` : submitLabel}
-
+
+ {movingCount > 0 && (
+
+
+ {movingCount} user{movingCount > 1 ? 's' : ''} already belong{movingCount === 1 ? 's' : ''} to other group{movingCount > 1 ? 's' : ''}.
+
+ )}
+
+
+ Cancel
+
+
+ {selected.length > 0 ? `${submitLabel} (${selected.length})` : submitLabel}
+
+
diff --git a/src/components/generic/Sheet/FilterSheet.jsx b/src/components/generic/Sheet/FilterSheet.jsx
index 71da76e..17f4948 100644
--- a/src/components/generic/Sheet/FilterSheet.jsx
+++ b/src/components/generic/Sheet/FilterSheet.jsx
@@ -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
;
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)}
));
};
diff --git a/src/components/generic/UserMenu.jsx b/src/components/generic/UserMenu.jsx
index 06f0bf8..6b350fb 100644
--- a/src/components/generic/UserMenu.jsx
+++ b/src/components/generic/UserMenu.jsx
@@ -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 (
+
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'
+ }`}
+ >
+
+ {label}
+
+ {description}
+ {active && }
+
+ )
+}
+
// ─── 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 }) {
)}
+ {/* Date & Time */}
+ {tab === 'datetime' && (
+
+
+
Date & Time
+
+ Choose how dates and times are displayed across the platform.
+ Your locale format is taken from your browser's language setting.
+
+
+
+
+
Timezone Display
+
+
+
+
+
+
+
Preview
+
Date: {new Date().toLocaleDateString(navigator.language, { month: 'short', day: 'numeric', year: 'numeric', ...(timezone === 'UTC' ? { timeZone: 'UTC' } : {}) })}
+
Date & Time: {new Date().toLocaleString(navigator.language, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', ...(timezone === 'UTC' ? { timeZone: 'UTC' } : {}) })}
+ {timezone === 'UTC' &&
All times shown in UTC
}
+
+
+ )}
+
diff --git a/src/contexts/AdminTierCategoriesContext.jsx b/src/contexts/AdminTierCategoriesContext.jsx
new file mode 100644
index 0000000..804a637
--- /dev/null
+++ b/src/contexts/AdminTierCategoriesContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+}
diff --git a/src/contexts/AdminTierPoliciesContext.jsx b/src/contexts/AdminTierPoliciesContext.jsx
new file mode 100644
index 0000000..0a73952
--- /dev/null
+++ b/src/contexts/AdminTierPoliciesContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+}
diff --git a/src/contexts/AdminTiersContext.jsx b/src/contexts/AdminTiersContext.jsx
index d630399..a0abe9f 100644
--- a/src/contexts/AdminTiersContext.jsx
+++ b/src/contexts/AdminTiersContext.jsx
@@ -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 (
{
// Payment actions
fetchPayments, fetchPayment,
- // Course plans
- planCourses, fetchPlanCourses, syncPlanCourses,
}}>
{children}
diff --git a/src/contexts/AdminUserContext.jsx b/src/contexts/AdminUserContext.jsx
index 52d2fb7..d643f8d 100644
--- a/src/contexts/AdminUserContext.jsx
+++ b/src/contexts/AdminUserContext.jsx
@@ -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 (
{
fetchUserFieldValues,
fetchUserAchievements,
fetchActivity, fetchUserActivity,
+ banUser, unbanUser, bulkBanUsers, bulkUnbanUsers, fetchUserBans,
}}>
{children}
diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx
index f89e352..2bd58f3 100644
--- a/src/contexts/AuthContext.jsx
+++ b/src/contexts/AuthContext.jsx
@@ -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 }
}
}, [])
diff --git a/src/contexts/ClientCourseReadingProgressContext.jsx b/src/contexts/ClientCourseReadingProgressContext.jsx
index e5c5a0a..e35bc04 100644
--- a/src/contexts/ClientCourseReadingProgressContext.jsx
+++ b/src/contexts/ClientCourseReadingProgressContext.jsx
@@ -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 (
{children}
diff --git a/src/contexts/ClientCoursesContext.jsx b/src/contexts/ClientCoursesContext.jsx
index 04ab443..5c58c0c 100644
--- a/src/contexts/ClientCoursesContext.jsx
+++ b/src/contexts/ClientCoursesContext.jsx
@@ -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,
diff --git a/src/contexts/ClientTaskProgressContext.jsx b/src/contexts/ClientTaskProgressContext.jsx
index 0df1482..2bf767c 100644
--- a/src/contexts/ClientTaskProgressContext.jsx
+++ b/src/contexts/ClientTaskProgressContext.jsx
@@ -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,
}}>
diff --git a/src/contexts/ClientTiersProvider.jsx b/src/contexts/ClientTiersProvider.jsx
index 5fb544c..3ec7a0e 100644
--- a/src/contexts/ClientTiersProvider.jsx
+++ b/src/contexts/ClientTiersProvider.jsx
@@ -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,
diff --git a/src/contexts/DateTimePreferenceContext.jsx b/src/contexts/DateTimePreferenceContext.jsx
new file mode 100644
index 0000000..9353ae3
--- /dev/null
+++ b/src/contexts/DateTimePreferenceContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+export function useDateTimePreference() {
+ const ctx = useContext(DateTimePreferenceContext);
+ if (!ctx) throw new Error('useDateTimePreference must be used inside DateTimePreferenceProvider');
+ return ctx;
+}
diff --git a/src/hooks/useDateFormat.js b/src/hooks/useDateFormat.js
new file mode 100644
index 0000000..d7943d7
--- /dev/null
+++ b/src/hooks/useDateFormat.js
@@ -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()
+ * {fmtDateTime(row.createdAt)}
+ *
+ * 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,
+ };
+}
diff --git a/src/modules/admin/components/courses/CourseReadingProgressList.jsx b/src/modules/admin/components/courses/CourseReadingProgressList.jsx
index 4fb8b9f..b17ecdc 100644
--- a/src/modules/admin/components/courses/CourseReadingProgressList.jsx
+++ b/src/modules/admin/components/courses/CourseReadingProgressList.jsx
@@ -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 (
@@ -154,7 +154,7 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
{lesson.status === 'completed' && lesson.completed_at && (
- {new Date(lesson.completed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
+ {fmtDateShort(lesson.completed_at)}
)}
@@ -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 (
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`),
diff --git a/src/modules/admin/components/tiers/CoursePicker.jsx b/src/modules/admin/components/tiers/CoursePicker.jsx
new file mode 100644
index 0000000..c552653
--- /dev/null
+++ b/src/modules/admin/components/tiers/CoursePicker.jsx
@@ -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 of selected course_id strings
+ * onChange — (Set) => 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 (
+
+
+
+ {loading
+ ? "Loading courses…"
+ : `${courses.length} course${courses.length !== 1 ? "s" : ""} in this tier${selectedCount > 0 ? ` — ${selectedCount} selected` : ""}`
+ }
+
+
+
+
+ Check all
+
+
+
+ Reset
+
+
+
+
+ {loading ? (
+
+ {[...Array(3)].map((_, i) => )}
+
+ ) : courses.length === 0 ? (
+
+
+ No courses found with subscription "{subscription}" .
+
+ ) : (
+
+
+
+ {filtered.length === 0 ? (
+ No courses match your search.
+ ) : (
+
+ {filtered.map((course) => {
+ const id = String(course.course_id);
+ const checked = selectedIds.has(id);
+ return (
+ toggle(id)}
+ className="flex items-start gap-3 px-3 py-2.5 cursor-pointer"
+ >
+ toggle(id)}
+ className="mt-0.5 shrink-0"
+ onClick={(e) => e.stopPropagation()}
+ />
+
+ {course.title}
+ {course.description && (
+
+ {course.description}
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/modules/admin/components/tiers/PaymentsTable.jsx b/src/modules/admin/components/tiers/PaymentsTable.jsx
index 6382829..d0a31e5 100644
--- a/src/modules/admin/components/tiers/PaymentsTable.jsx
+++ b/src/modules/admin/components/tiers/PaymentsTable.jsx
@@ -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
diff --git a/src/modules/admin/components/tiers/TierPlansTable.jsx b/src/modules/admin/components/tiers/TierPlansTable.jsx
index d10e0c7..ebd52ce 100644
--- a/src/modules/admin/components/tiers/TierPlansTable.jsx
+++ b/src/modules/admin/components/tiers/TierPlansTable.jsx
@@ -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 && (
+
+
+
+ No tier categories defined.{" "}
+
+ Add a tier category
+ {" "}
+ before creating plans.
+
+
+ )}
[],
@@ -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 */}
+ !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 */}
+ !v && setBanIds(null)}
+ ids={banIds ?? []}
+ entityLabel="User"
+ onBan={(payload) => bulkBanUsers({ ids: banIds, ...payload })}
+ loading={loading}
+ onSuccess={handleBanSuccess}
+ />
+
+ {/* Single unban */}
+ !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 */}
+ !v && setUnbanIds(null)}
+ ids={unbanIds ?? []}
+ entityLabel="User"
+ onUnban={(payload) => bulkUnbanUsers({ ids: unbanIds, ...payload })}
+ loading={loading}
+ onSuccess={handleUnbanSuccess}
+ />
>
);
}
\ No newline at end of file
diff --git a/src/modules/admin/config/tiers/payments/columns.config.jsx b/src/modules/admin/config/tiers/payments/columns.config.jsx
index 6995bf2..efca555 100644
--- a/src/modules/admin/config/tiers/payments/columns.config.jsx
+++ b/src/modules/admin/config/tiers/payments/columns.config.jsx
@@ -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) => (
-
- {info.getValue()}
-
- ),
- amount: (info) => {
- const row = info.row.original;
- return (
-
- {row.currency} {Number(info.getValue()).toFixed(2)}
+export function buildDataColumns(attributes, rowActions, fmtDateTime = (v) => v ? new Date(v).toLocaleString() : "—", tierMap = {}) {
+ const cellOverrides = {
+ status: (info) => (
+
+ {info.getValue()}
+
+ ),
+ amount: (info) => {
+ const row = info.row.original;
+ return (
+
+ {row.currency} {Number(info.getValue()).toFixed(2)}
+
+ );
+ },
+ "plan.tier": (info) => {
+ const { cls, label } = resolveTierBadge(info.getValue(), tierMap);
+ return {label} ;
+ },
+ paid_at: (info) => (
+
+ {fmtDateTime(info.getValue())}
- );
- },
- "plan.tier": (info) => (
-
- {info.getValue()}
-
- ),
- paid_at: (info) => (
-
- {info.getValue() ? new Date(info.getValue()).toLocaleString() : "—"}
-
- ),
- "user.email": (info) => (
- {info.getValue() ?? "—"}
- ),
-
-};
+ ),
+ "user.email": (info) => (
+ {info.getValue() ?? "—"}
+ ),
+ };
-export function buildDataColumns(attributes, rowActions) {
const visibleAttributes = attributes.filter((a) => !a.hidden);
return [
buildSelectionColumn(),
diff --git a/src/modules/admin/config/tiers/plans/toolbar.config.jsx b/src/modules/admin/config/tiers/plans/toolbar.config.jsx
index 0bba19c..3e52976 100644
--- a/src/modules/admin/config/tiers/plans/toolbar.config.jsx
+++ b/src/modules/admin/config/tiers/plans/toolbar.config.jsx
@@ -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: ,
+ variant: "outline",
+ onClick: () => navigate("/admin/tiers/categories"),
+ },
{
key: "create",
type: "button",
label: "New Plan",
icon: ,
variant: "default",
- hidden: showArchived,
+ hidden: showArchived || !hasAvailableCategories,
onClick: () => navigate("/admin/tiers/plans/add"),
},
{
diff --git a/src/modules/admin/config/user_groups/view/rowActions.config.jsx b/src/modules/admin/config/user_groups/view/rowActions.config.jsx
index 9249f7b..a88aebc 100644
--- a/src/modules/admin/config/user_groups/view/rowActions.config.jsx
+++ b/src/modules/admin/config/user_groups/view/rowActions.config.jsx
@@ -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: ,
+ onClick: (row) => onAssign(row),
+ },
+ ];
+ }
+
return [
{
key: "remove",
diff --git a/src/modules/admin/config/user_groups/view/selection.config.jsx b/src/modules/admin/config/user_groups/view/selection.config.jsx
index 1a2f60f..2625a54 100644
--- a/src/modules/admin/config/user_groups/view/selection.config.jsx
+++ b/src/modules/admin/config/user_groups/view/selection.config.jsx
@@ -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: ,
+ onClick: (rows) => onAssignMembers(rows.map((r) => r.user_id)),
+ });
+ } else {
+ actions.push({
key: "remove-selected",
label: "Remove",
icon: ,
@@ -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;
}
\ No newline at end of file
diff --git a/src/modules/admin/config/users/rowActions.config.jsx b/src/modules/admin/config/users/rowActions.config.jsx
index ec93ab9..2794893 100644
--- a/src/modules/admin/config/users/rowActions.config.jsx
+++ b/src/modules/admin/config/users/rowActions.config.jsx
@@ -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: ,
+ 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: ,
+ onClick: (row) => onUnban(row),
+ hidden: (row) => !row.is_banned,
+ separator: true,
+ },
{
key: "archive",
label: "Archive",
className: "text-destructive focus:text-destructive",
icon: ,
- 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,
},
];
}
\ No newline at end of file
diff --git a/src/modules/admin/config/users/selection.config.jsx b/src/modules/admin/config/users/selection.config.jsx
index af85009..eb9738e 100644
--- a/src/modules/admin/config/users/selection.config.jsx
+++ b/src/modules/admin/config/users/selection.config.jsx
@@ -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: ,
+ 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: ,
+ 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"),
},
diff --git a/src/modules/admin/pages/activity/ActivityFeed.jsx b/src/modules/admin/pages/activity/ActivityFeed.jsx
index e488b5a..46e3b43 100644
--- a/src/modules/admin/pages/activity/ActivityFeed.jsx
+++ b/src/modules/admin/pages/activity/ActivityFeed.jsx
@@ -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: , 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 (
@@ -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 }) {
{timeAgo(ts)}
- {new Date(ts).toLocaleString("en-US", {
- month: "short", day: "numeric", year: "numeric",
- hour: "numeric", minute: "2-digit", second: "2-digit",
- })}
+ {fmtDateTime(ts)}
) : (
diff --git a/src/modules/admin/pages/activity/UserActivityPage.jsx b/src/modules/admin/pages/activity/UserActivityPage.jsx
index df934d0..9994626 100644
--- a/src/modules/admin/pages/activity/UserActivityPage.jsx
+++ b/src/modules/admin/pages/activity/UserActivityPage.jsx
@@ -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 }) {
{timeAgo(ts)}
- {new Date(ts).toLocaleString("en-US", {
- month: "short", day: "numeric", year: "numeric",
- hour: "numeric", minute: "2-digit", second: "2-digit",
- })}
+ {fmtDateTime(ts)}
) : (
diff --git a/src/modules/admin/pages/advertisements/AdvertisementList.jsx b/src/modules/admin/pages/advertisements/AdvertisementList.jsx
index 968453d..bf4d357 100644
--- a/src/modules/admin/pages/advertisements/AdvertisementList.jsx
+++ b/src/modules/admin/pages/advertisements/AdvertisementList.jsx
@@ -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 (
@@ -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;
}
\ No newline at end of file
diff --git a/src/modules/admin/pages/advertisements/ViewAdvertisement.jsx b/src/modules/admin/pages/advertisements/ViewAdvertisement.jsx
index f4b3a0f..5efd56f 100644
--- a/src/modules/admin/pages/advertisements/ViewAdvertisement.jsx
+++ b/src/modules/admin/pages/advertisements/ViewAdvertisement.jsx
@@ -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 ──────────────────────────────────── */}
- {formatDateTime(advertisement.start_date)}
- {formatDateTime(advertisement.end_date)}
+ {fmtDateTime(advertisement.start_date)}
+ {fmtDateTime(advertisement.end_date)}
{advertisement.order ?? 0}
{advertisement.is_active ? "Yes" : "No"}
@@ -191,9 +186,9 @@ export default function ViewAdvertisement() {
{advertisement.creator?.full_name || "—"}
- {formatDateTime(advertisement.createdAt)}
+ {fmtDateTime(advertisement.createdAt)}
{advertisement.updater?.full_name || "—"}
- {formatDateTime(advertisement.updatedAt)}
+ {fmtDateTime(advertisement.updatedAt)}
diff --git a/src/modules/admin/pages/assets/ViewAudioAsset.jsx b/src/modules/admin/pages/assets/ViewAudioAsset.jsx
index 2a52548..fde7903 100644
--- a/src/modules/admin/pages/assets/ViewAudioAsset.jsx
+++ b/src/modules/admin/pages/assets/ViewAudioAsset.jsx
@@ -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() {
Timestamps
-
-
+
+
{a.description && (
diff --git a/src/modules/admin/pages/assets/ViewDocumentAsset.jsx b/src/modules/admin/pages/assets/ViewDocumentAsset.jsx
index 0dfe010..83a0643 100644
--- a/src/modules/admin/pages/assets/ViewDocumentAsset.jsx
+++ b/src/modules/admin/pages/assets/ViewDocumentAsset.jsx
@@ -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() {
Timestamps
-
-
+
+
{a.description && (
diff --git a/src/modules/admin/pages/assets/ViewImageAsset.jsx b/src/modules/admin/pages/assets/ViewImageAsset.jsx
index 7a3e351..1263f0b 100644
--- a/src/modules/admin/pages/assets/ViewImageAsset.jsx
+++ b/src/modules/admin/pages/assets/ViewImageAsset.jsx
@@ -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() {
Timestamps
-
-
+
+
{a.description && (
diff --git a/src/modules/admin/pages/assets/ViewVideoAsset.jsx b/src/modules/admin/pages/assets/ViewVideoAsset.jsx
index 9c6c51f..b77efa9 100644
--- a/src/modules/admin/pages/assets/ViewVideoAsset.jsx
+++ b/src/modules/admin/pages/assets/ViewVideoAsset.jsx
@@ -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() {
Timestamps
-
-
+
+
{a.description && (
diff --git a/src/modules/admin/pages/courses/AddCourse.jsx b/src/modules/admin/pages/courses/AddCourse.jsx
index 030ada7..92fb1aa 100644
--- a/src/modules/admin/pages/courses/AddCourse.jsx
+++ b/src/modules/admin/pages/courses/AddCourse.jsx
@@ -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() {
- Free
- Premium
+ {tierCategories.map((c) => (
+
+ {c.name}
+
+ ))}
diff --git a/src/modules/admin/pages/courses/CourseAssessment.jsx b/src/modules/admin/pages/courses/CourseAssessment.jsx
index 175a318..32846fd 100644
--- a/src/modules/admin/pages/courses/CourseAssessment.jsx
+++ b/src/modules/admin/pages/courses/CourseAssessment.jsx
@@ -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
+
+
+ setShuffleQuestions(val)}
+ />
+
+ Shuffle question order for each attempt
+
+
{maxQuestions && parseInt(maxQuestions) < questions.length && (
diff --git a/src/modules/admin/pages/courses/EditCourse.jsx b/src/modules/admin/pages/courses/EditCourse.jsx
index 39a10bb..070c740 100644
--- a/src/modules/admin/pages/courses/EditCourse.jsx
+++ b/src/modules/admin/pages/courses/EditCourse.jsx
@@ -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() {
Subscription
- setValue("subscription", val, { shouldDirty: true })
- }
+ onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
- Free
- Premium
+ {tierCategories.map((c) => (
+
+ {c.name}
+
+ ))}
diff --git a/src/modules/admin/pages/courses/ViewAssessment.jsx b/src/modules/admin/pages/courses/ViewAssessment.jsx
index bee4e63..b5d4003 100644
--- a/src/modules/admin/pages/courses/ViewAssessment.jsx
+++ b/src/modules/admin/pages/courses/ViewAssessment.jsx
@@ -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 }) {
>
-
{row.full_name}
-
{row.email}
+
+
{row.full_name ?? "Unknown User"}
+ {row.deleted && (
+
+ Deleted
+
+ )}
+
+
{row.email ?? "—"}
{row.attempt_count}
@@ -128,7 +137,7 @@ function CompletionRow({ row }) {
)}
- {row.latest_at ? new Date(row.latest_at).toLocaleDateString() : "—"}
+ {row.latest_at ? fmtDate(row.latest_at) : "—"}
{open ? : }
@@ -158,7 +167,7 @@ function CompletionRow({ row }) {
? Pass
: Fail }
- {new Date(a.createdAt).toLocaleString()}
+ {fmtDateTime(a.createdAt)}
))}
@@ -235,6 +244,7 @@ function fmtDuration(secs) {
}
function SessionsTab({ sessions, loading }) {
+ const { fmtDateTime } = useDateFormat();
if (loading) return {[...Array(4)].map((_, i) => )}
;
if (!sessions) return (
@@ -274,8 +284,15 @@ function SessionsTab({ sessions, loading }) {
{rows.map((s) => (
- {s.full_name}
- {s.email}
+
+
{s.full_name ?? "Unknown User"}
+ {s.deleted && (
+
+ Deleted
+
+ )}
+
+ {s.email ?? "—"}
@@ -283,10 +300,10 @@ function SessionsTab({ sessions, loading }) {
- {new Date(s.started_at).toLocaleString()}
+ {fmtDateTime(s.started_at)}
- {s.expires_at ? new Date(s.expires_at).toLocaleString() : "—"}
+ {s.expires_at ? fmtDateTime(s.expires_at) : "—"}
{fmtDuration(s.time_spent_seconds)}
diff --git a/src/modules/admin/pages/courses/ViewCourse.jsx b/src/modules/admin/pages/courses/ViewCourse.jsx
index 3beb85a..508ff53 100644
--- a/src/modules/admin/pages/courses/ViewCourse.jsx
+++ b/src/modules/admin/pages/courses/ViewCourse.jsx
@@ -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() {
{course.creator?.full_name ?? course.createdBy ?? "—"}
{course.updater?.full_name ?? course.updatedBy ?? "—"}
- {course.createdAt ? new Date(course.createdAt).toLocaleString() : "—"}
+ {course.createdAt ? fmtDateTime(course.createdAt) : "—"}
- {course.updatedAt ? new Date(course.updatedAt).toLocaleString() : "—"}
+ {course.updatedAt ? fmtDateTime(course.updatedAt) : "—"}
diff --git a/src/modules/admin/pages/courses/lessons/LessonsList.jsx b/src/modules/admin/pages/courses/lessons/LessonsList.jsx
index 04c0ae2..f3c52e2 100644
--- a/src/modules/admin/pages/courses/lessons/LessonsList.jsx
+++ b/src/modules/admin/pages/courses/lessons/LessonsList.jsx
@@ -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() {
Created
- {unit?.createdAt ? new Date(unit.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
+ {unit?.createdAt ? fmtDate(unit.createdAt) : '—'}
Last Updated
- {unit?.updatedAt ? new Date(unit.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
+ {unit?.updatedAt ? fmtDate(unit.updatedAt) : '—'}
diff --git a/src/modules/admin/pages/courses/lessons/ViewLesson.jsx b/src/modules/admin/pages/courses/lessons/ViewLesson.jsx
index 5b386a5..76dd75c 100644
--- a/src/modules/admin/pages/courses/lessons/ViewLesson.jsx
+++ b/src/modules/admin/pages/courses/lessons/ViewLesson.jsx
@@ -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 (
@@ -83,11 +82,11 @@ export default function ViewLesson() {
Created
-
{formatDate(lesson?.createdAt)}
+
{fmtDate(lesson?.createdAt)}
Last Updated
-
{formatDate(lesson?.updatedAt)}
+
{fmtDate(lesson?.updatedAt)}
diff --git a/src/modules/admin/pages/courses/units/EditUnit.jsx b/src/modules/admin/pages/courses/units/EditUnit.jsx
index 6386bc6..4f13a24 100644
--- a/src/modules/admin/pages/courses/units/EditUnit.jsx
+++ b/src/modules/admin/pages/courses/units/EditUnit.jsx
@@ -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 (
diff --git a/src/modules/admin/pages/courses/units/UnitQuiz.jsx b/src/modules/admin/pages/courses/units/ModifyQuiz.jsx
similarity index 95%
rename from src/modules/admin/pages/courses/units/UnitQuiz.jsx
rename to src/modules/admin/pages/courses/units/ModifyQuiz.jsx
index 3db9ec1..d6b9a8e 100644
--- a/src/modules/admin/pages/courses/units/UnitQuiz.jsx
+++ b/src/modules/admin/pages/courses/units/ModifyQuiz.jsx
@@ -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
+
+
+ setShuffleQuestions(val)}
+ />
+
+ Shuffle question order for each attempt
+
+
{maxQuestions && parseInt(maxQuestions) < questions.length && (
diff --git a/src/modules/admin/pages/courses/units/UnitsList.jsx b/src/modules/admin/pages/courses/units/UnitsList.jsx
index 230d27c..b809ab2 100644
--- a/src/modules/admin/pages/courses/units/UnitsList.jsx
+++ b/src/modules/admin/pages/courses/units/UnitsList.jsx
@@ -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 (
-
+
@@ -81,13 +83,13 @@ export default function UnitsList() {
Created
- {course?.createdAt ? new Date(course.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
+ {course?.createdAt ? fmtDate(course.createdAt) : '—'}
Last Updated
- {course?.updatedAt ? new Date(course.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
+ {course?.updatedAt ? fmtDate(course.updatedAt) : '—'}
diff --git a/src/modules/admin/pages/courses/units/ViewUnitQuiz.jsx b/src/modules/admin/pages/courses/units/ViewUnitQuiz.jsx
index f32c05d..fc0a2e0 100644
--- a/src/modules/admin/pages/courses/units/ViewUnitQuiz.jsx
+++ b/src/modules/admin/pages/courses/units/ViewUnitQuiz.jsx
@@ -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 (
<>
-
{row.full_name}
-
{row.email}
+
+
{row.full_name ?? "Unknown User"}
+ {row.deleted && (
+
+ Deleted
+
+ )}
+
+
{row.email ?? "—"}
{row.attempt_count}
@@ -127,7 +136,7 @@ function CompletionRow({ row }) {
)}
- {row.latest_at ? new Date(row.latest_at).toLocaleDateString() : "—"}
+ {row.latest_at ? fmtDate(row.latest_at) : "—"}
{open ? : }
@@ -157,7 +166,7 @@ function CompletionRow({ row }) {
? Pass
: Fail }
- {new Date(a.createdAt).toLocaleString()}
+ {fmtDateTime(a.createdAt)}
))}
@@ -284,7 +293,7 @@ export default function ViewUnitQuiz() {
navigate(`/admin/courses/${courseId}/units/${unitId}/quiz`)}
+ onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz/edit`)}
>
Modify Quiz
@@ -321,7 +330,7 @@ export default function ViewUnitQuiz() {
No quiz has been created for this unit yet.
-
navigate(`/admin/courses/${courseId}/units/${unitId}/quiz`)}>
+ navigate(`/admin/courses/${courseId}/units/${unitId}/quiz/edit`)}>
Create Quiz
diff --git a/src/modules/admin/pages/task_list/CreateTaskList.jsx b/src/modules/admin/pages/task_list/CreateTaskList.jsx
index 0a2a159..c0a3b8d 100644
--- a/src/modules/admin/pages/task_list/CreateTaskList.jsx
+++ b/src/modules/admin/pages/task_list/CreateTaskList.jsx
@@ -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 (
diff --git a/src/modules/admin/pages/task_list/ViewTaskList.jsx b/src/modules/admin/pages/task_list/ViewTaskList.jsx
index 7511720..6a5dcc3 100644
--- a/src/modules/admin/pages/task_list/ViewTaskList.jsx
+++ b/src/modules/admin/pages/task_list/ViewTaskList.jsx
@@ -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() {
Deadline:{' '}
- {new Date(task.deadline).toLocaleDateString(undefined, {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- })}
+ {fmtDate(task.deadline)}
diff --git a/src/modules/admin/pages/task_list/task/EditTask.jsx b/src/modules/admin/pages/task_list/task/EditTask.jsx
index 66d9691..24c3150 100644
--- a/src/modules/admin/pages/task_list/task/EditTask.jsx
+++ b/src/modules/admin/pages/task_list/task/EditTask.jsx
@@ -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 (
diff --git a/src/modules/admin/pages/task_list/task/ViewTask.jsx b/src/modules/admin/pages/task_list/task/ViewTask.jsx
index 7b1d851..45e5fa8 100644
--- a/src/modules/admin/pages/task_list/task/ViewTask.jsx
+++ b/src/modules/admin/pages/task_list/task/ViewTask.jsx
@@ -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() {
Deadline:{' '}
- {new Date(task.deadline).toLocaleDateString(undefined, {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- })}
+ {fmtDate(task.deadline)}
diff --git a/src/modules/admin/pages/tiers/AddPlan.jsx b/src/modules/admin/pages/tiers/AddPlan.jsx
index cb2b6a2..eb0a222 100644
--- a/src/modules/admin/pages/tiers/AddPlan.jsx
+++ b/src/modules/admin/pages/tiers/AddPlan.jsx
@@ -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 {message}
;
+ if (!message) return null;
+ return {message}
;
}
function SectionCard({ title, children }) {
- return (
-
- {title && (
-
-
{title}
+ return (
+
+ {title &&
{title} }
+ {children}
- )}
- {children}
-
- );
-}
-
-// ─── 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 (
-
-
-
-
- {selected.length === 0
- ? Select courses…
- : {selected.length} course{selected.length !== 1 ? "s" : ""} selected
- }
-
-
-
-
-
-
-
-
-
-
-
- {courses.map((course) => {
- const checked = selectedSet.has(String(course.course_id));
- return (
- toggle(String(course.course_id))}
- className="gap-2"
- >
-
- {checked && }
-
-
-
{course.title}
- {course.level && (
-
{course.level}
- )}
-
- {course.course_code && (
- {course.course_code}
- )}
-
- );
- })}
-
-
-
-
-
-
- {/* Selected chips */}
- {selectedList.length > 0 && (
-
- {selectedList.map((course) => (
-
- {course.title}
- remove(course.course_id)}
- className="ml-0.5 rounded-full hover:bg-muted-foreground/20 p-0.5"
- >
-
-
-
- ))}
-
- )}
-
- );
}
// ─── 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 (
-
-
-
-
+ // Sync selected courses
+ if (selectedCourseIds.size > 0) {
+ await api.post(`/admin/tiers/plans/${result.plan_id}/courses`, {
+ course_ids: [...selectedCourseIds].map(Number),
+ }).catch(() => {});
+ }
-
-
, to: "/admin" },
- { label: "Plans", to: "/admin/tiers/plans" },
- { label: "Add Plan" },
- ]} />
-
+ navigate(`/admin/tiers/plans`);
+ };
-
-
navigate(-1)}>
-
-
-
-
Add Plan
-
Create a new premium or exclusive plan.
-
-
+ return (
+
- );
+
+ );
}
diff --git a/src/modules/admin/pages/tiers/EditPlan.jsx b/src/modules/admin/pages/tiers/EditPlan.jsx
index d80d8d8..572fd18 100644
--- a/src/modules/admin/pages/tiers/EditPlan.jsx
+++ b/src/modules/admin/pages/tiers/EditPlan.jsx
@@ -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 (
-
-
-
-
- {selected.length === 0
- ? Select courses…
- : {selected.length} course{selected.length !== 1 ? "s" : ""} selected
- }
-
-
-
-
-
-
-
-
-
-
-
- {courses.map((course) => {
- const checked = selectedSet.has(String(course.course_id));
- return (
- toggle(String(course.course_id))}
- className="gap-2"
- >
-
- {checked && }
-
-
-
{course.title}
- {course.level && (
-
{course.level}
- )}
-
- {course.course_code && (
- {course.course_code}
- )}
-
- );
- })}
-
-
-
-
-
-
- {selectedList.length > 0 && (
-
- {selectedList.map((course) => (
-
- {course.title}
- remove(course.course_id)}
- className="ml-0.5 rounded-full hover:bg-muted-foreground/20 p-0.5"
- >
-
-
-
- ))}
-
- )}
-
- );
-}
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() {
-
-
- Assign courses included in this plan. Changes are saved when you click Save Changes.
-
-
-
+ {plan?.tier && (
+
+
+ Select which {plan.tier} courses are included in this plan.
+
+
+
+ )}
navigate(-1)} disabled={loading}>Cancel
diff --git a/src/modules/admin/pages/tiers/EditTierCategory.jsx b/src/modules/admin/pages/tiers/EditTierCategory.jsx
new file mode 100644
index 0000000..ea85d84
--- /dev/null
+++ b/src/modules/admin/pages/tiers/EditTierCategory.jsx
@@ -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 (
+
+ {title &&
{title}
}
+ {children}
+
+ );
+}
+
+function FieldError({ message }) {
+ if (!message) return null;
+ return
{message}
;
+}
+
+function BadgePicker({ currentAsset, selectedAsset, onSelect, onClear }) {
+ const [open, setOpen] = useState(false);
+ const display = selectedAsset ?? currentAsset;
+
+ return (
+
+
+
+ {display?.file_url ? (
+
+ ) : (
+
+ )}
+
+
+ setOpen(true)}>
+
+ {display ? "Change image" : "Pick from assets"}
+
+ {display && (
+
+
+ Remove
+
+ )}
+
+
+ {display &&
{display.display_name}
}
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+
, to: "/admin" },
+ { label: "Tier Categories", to: "/admin/tiers/categories" },
+ { label: isAdd ? "Add Category" : (category?.name ?? "Edit") },
+ ]} />
+
+
+
+
navigate(-1)}>
+
+
+
+
{isAdd ? "Add Tier Category" : "Edit Tier Category"}
+
+ {isAdd ? "Define a new tier level for the platform." : "Update this tier category's details and badge."}
+
+
+
+
+
+
+ {/* Details */}
+
+ {isAdd && (
+
+
Slug *
+
setSlug(e.target.value.toLowerCase())}
+ placeholder="e.g. gold"
+ />
+
Lowercase, no spaces. Cannot be changed after creation.
+
+
+ )}
+
+
+ Name *
+ setName(e.target.value)} placeholder="e.g. Gold" />
+
+
+
+
+ Description
+ setDescription(e.target.value)} rows={2} placeholder="Optional short description." />
+
+
+
+
Rank (ordering)
+
setRank(e.target.value)} className="w-32" />
+
Must be greater than 0. Free is rank 0. Higher rank = higher access tier.
+ {!isLocked && Number(rank) <= 0 && (
+
Rank must be at least 1 — rank 0 is reserved for the Free (default) tier.
+ )}
+
+
+
+
Color
+
+ {TIER_COLOR_OPTIONS.map((opt) => {
+ const selected = color === opt.key;
+ return (
+ 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 && }
+ {opt.label}
+
+ );
+ })}
+
+
+
+ {name || "Preview"}
+
+
+
+
+ {!isLocked && (
+
+
+ Active
+
+ )}
+
+
+ {/* Badge */}
+
+
+ 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.
+
+
+ {/* Lucide icon picker */}
+
+
Icon (optional)
+
+ {/* None option */}
+ 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"
+ >
+
+
+ {BADGE_ICON_OPTIONS.map(({ name, icon: Icon }) => {
+ const selected = badgeIcon === name;
+ const cls = getTierColor(color).badge;
+ return (
+ 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"}`}
+ >
+
+
+ );
+ })}
+
+ {badgeIcon && (
+
Selected: {badgeIcon}
+ )}
+
+
+ {/* Image upload */}
+
+ Image (overrides icon)
+ { setSelectedAsset(a); setClearBadge(false); }}
+ onClear={() => { setSelectedAsset(null); setClearBadge(true); }}
+ />
+
+
+
+ Badge Label
+ setBadgeLabel(e.target.value)}
+ placeholder="e.g. Premium Member"
+ />
+
+
+
+ {/* Actions */}
+
+ navigate(-1)} disabled={loading}>Cancel
+
+ {loading && }
+ {isAdd ? "Create Category" : "Save Changes"}
+
+
+
+
+
+
+ );
+}
+
+function Wrapper({ isAdd }) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function AddTierCategory() { return
; }
+export function EditTierCategory() { return
; }
diff --git a/src/modules/admin/pages/tiers/PlanPolicy.jsx b/src/modules/admin/pages/tiers/PlanPolicy.jsx
new file mode 100644
index 0000000..03afe7b
--- /dev/null
+++ b/src/modules/admin/pages/tiers/PlanPolicy.jsx
@@ -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 (
+
+ {availableLevels.map((lvl) => (
+ 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}
+
+ ))}
+
+ );
+}
+
+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 (
+
onChange({ ...rule, tier: v })}>
+
+
+
+
+ {categories.map((c) => (
+ {c.name}
+ ))}
+
+
+ );
+}
+
+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 (
+
+ {groups.length === 0 &&
No groups found.
}
+ {groups.map((g) => {
+ const selected = (rule.group_ids ?? []).includes(Number(g.group_id));
+ return (
+
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 && {g.group_code} }
+
+ );
+ })}
+
+ );
+}
+
+function RuleCard({ rule, index, onChange, onRemove }) {
+ const def = RULE_DEFS[rule.type];
+ return (
+
+
+
+
{def?.label ?? rule.type}
+
{def?.description}
+
+
+
+
+
+ {rule.type === "course_subscription_access" &&
onChange(index, r)} />}
+ {rule.type === "required_active_tier" && onChange(index, r)} />}
+ {rule.type === "group_restriction" && onChange(index, r)} />}
+
+ );
+}
+
+function SectionCard({ icon: Icon, title, children }) {
+ return (
+
+
+ {Icon && }
+
{title}
+
+
+ {children}
+
+ );
+}
+
+// ─── 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 (
+
+
+
+
+
+
+
, to: "/admin" },
+ { label: "Plans", to: "/admin/tiers/plans" },
+ { label: plan?.label ?? `Plan #${planId}`, to: `/admin/tiers/plans/${planId}/view` },
+ { label: "Policy" },
+ ]} />
+
+
+
+
+
navigate(-1)}>
+
+
+
+
Access Policy
+
+ {planLoading ? : (plan?.label ?? `Plan #${planId}`)}
+
+
+
+
+ {loading ? "Saving…" : "Save Policy"}
+
+
+
+
+
+
+ {rules.length === 0 && (
+
+ No rules defined. Content access falls back to tier rank comparison.
+
+ )}
+
+
+ {rules.map((rule, i) => (
+ removeRule(i)}
+ />
+ ))}
+
+
+
+
Add Rule
+
+ {Object.entries(RULE_DEFS).map(([type, def]) => (
+
addRule(type)}
+ >
+
+ {def.label}
+
+ ))}
+
+
+
+
+ {plan && (
+
+ {plan.tier}
+ {plan.label}
+ ·
+ {plan.duration_days} days
+
+ )}
+
+
+
+
+
+ );
+}
+
+export default function PlanPolicy() {
+ return (
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/tiers/SystemBadges.jsx b/src/modules/admin/pages/tiers/SystemBadges.jsx
new file mode 100644
index 0000000..648f470
--- /dev/null
+++ b/src/modules/admin/pages/tiers/SystemBadges.jsx
@@ -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 (
+
+
+
+
{badge.label || badge.key}
+ {badge.key}
+
+
+
+ {/* ── Image picker ── */}
+
+
+ {displayAsset ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ setPickerOpen(true)}>
+
+ {displayAsset ? "Change image" : "Pick from assets"}
+
+ {displayAsset && (
+
+
+ Remove
+
+ )}
+
+
+ {displayAsset && (
+
{displayAsset.display_name}
+ )}
+
+
+
+ {/* ── Label / Description / Information ── */}
+
+
+ Label
+ setBadge((p) => ({ ...p, label: e.target.value }))} />
+
+
+ Description
+ setBadge((p) => ({ ...p, description: e.target.value }))} />
+
+
+ Information
+ setBadge((p) => ({ ...p, information: e.target.value }))} rows={2} />
+
+
+
+ {/* ── Active window ── */}
+
+
+
+
+ {loading ? "Saving…" : "Save Badge"}
+
+
+
+ );
+}
+
+// ─── 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:
, to: "/admin" },
+ { label: "Plans", to: "/admin/tiers/plans" },
+ { label: "System Badges" },
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+
System Badges
+
Special badges awarded outside of subscription plans.
+
+
+ {loading && systemBadges.length === 0 ? (
+
+ {[1, 2].map((i) => )}
+
+ ) : (
+
+ {merged.map((badge) => (
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
+
+export default function SystemBadges() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/tiers/TierCategories.jsx b/src/modules/admin/pages/tiers/TierCategories.jsx
new file mode 100644
index 0000000..9454eda
--- /dev/null
+++ b/src/modules/admin/pages/tiers/TierCategories.jsx
@@ -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 (
+
+
+
+ {badge?.file_url ? (
+
+ ) : (
+
+ )}
+
+
+
+
{cat.name}
+
{cat.slug}
+
rank {cat.rank}
+ {!cat.is_active &&
Inactive }
+ {cat.is_default && (
+
+ Default
+
+ )}
+
+ {cat.description && (
+
{cat.description}
+ )}
+ {cat.badge_label && (
+
+ Badge label: {cat.badge_label}
+
+ )}
+
+
+
+
onEdit(cat)}>
+
+
+ {!cat.is_default && (
+
onDelete(cat)}>
+
+
+ )}
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+
, to: "/admin" },
+ { label: "Tiers", to: "/admin/tiers/plans" },
+ { label: "Tier Categories" },
+ ]} />
+
+
+
+
+
Tier Categories
+
+ Define the tier levels available on the platform. Plans are built under each category.
+
+
+
navigate("/admin/tiers/categories/add")}>
+
+ Add Category
+
+
+
+
+
+
+ The Free category is the platform default and cannot be deleted or deactivated.
+ All new users start here automatically. You can still configure its badge.
+
+
+
+
+
+ {loading && !categories.length ? (
+
+ ) : !categories.length ? (
+
No tier categories found.
+ ) : (
+
+ {categories.map((cat) => (
+ navigate(`/admin/tiers/categories/${c.tier_category_id}/edit`)}
+ onDelete={(c) => setDeleteTarget(c)}
+ />
+ ))}
+
+ )}
+
+
+
+ {/* Delete confirmation dialog */}
+ { if (!open) setDeleteTarget(null); }}>
+
+
+ Delete Tier Category
+
+ Are you sure you want to delete{" "}
+ {deleteTarget?.name} ?
+ This action cannot be undone. Any plans linked to this category must be reassigned first.
+
+
+
+ setDeleteTarget(null)} disabled={deleting}>
+ Cancel
+
+
+ {deleting && }
+ Delete
+
+
+
+
+
+ );
+}
+
+export default function TierCategories() {
+ return (
+
+
+
+ );
+}
diff --git a/src/modules/admin/pages/tiers/UserTierList.jsx b/src/modules/admin/pages/tiers/UserTierList.jsx
index 6d18946..f4e30a5 100644
--- a/src/modules/admin/pages/tiers/UserTierList.jsx
+++ b/src/modules/admin/pages/tiers/UserTierList.jsx
@@ -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
{label} ;
+ };
+
const handleGrant = async () => {
if (!grantForm.plan_id) return;
setSubmitting(true);
@@ -118,12 +146,10 @@ export default function UserTierList() {
Current Tier
-
- {activeTier.tier}
-
+ {tierBadge(activeTier.tier)}
{activeTier.expires_at && (
- Expires {new Date(activeTier.expires_at).toLocaleDateString()}
+ Expires {fmtDate(activeTier.expires_at)}
)}
@@ -152,17 +178,17 @@ export default function UserTierList() {
- {t.tier}
+ {tierBadge(t.tier)}
{t.status}
#{t.tier_id}
- {t.starts_at ? new Date(t.starts_at).toLocaleString() : "—"}
- {t.expires_at ? new Date(t.expires_at).toLocaleString() : "Never"}
+ {t.starts_at ? fmtDateTime(t.starts_at) : "—"}
+ {t.expires_at ? fmtDateTime(t.expires_at) : "Never"}
{t.grantedByUser?.email ?? (t.granted_by ? `#${t.granted_by}` : "Self-serve")}
{t.revoked_at && (
- {new Date(t.revoked_at).toLocaleString()}
+ {fmtDateTime(t.revoked_at)}
)}
{t.notes &&
{t.notes}
}
@@ -191,8 +217,9 @@ export default function UserTierList() {
>
- Premium
- Exclusive
+ {grantableCategories.map((c) => (
+ {c.name}
+ ))}
diff --git a/src/modules/admin/pages/tiers/ViewPayment.jsx b/src/modules/admin/pages/tiers/ViewPayment.jsx
index 80e5181..3025dc3 100644
--- a/src/modules/admin/pages/tiers/ViewPayment.jsx
+++ b/src/modules/admin/pages/tiers/ViewPayment.jsx
@@ -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 && (
- {new Date(payload.cancelled_at).toLocaleString()}
+ {fmtDateTime(payload.cancelled_at)}
)}
@@ -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 (
@@ -174,10 +183,10 @@ export default function ViewPayment() {
)}
- {payment.paid_at ? new Date(payment.paid_at).toLocaleString() : "—"}
+ {payment.paid_at ? fmtDateTime(payment.paid_at) : "—"}
- {payment.createdAt ? new Date(payment.createdAt).toLocaleString() : "—"}
+ {payment.createdAt ? fmtDateTime(payment.createdAt) : "—"}
@@ -196,9 +205,7 @@ export default function ViewPayment() {
{payment.plan.label}
-
- {payment.plan.tier}
-
+ {(() => { const { cls, label } = resolveTierBadge(payment.plan.tier, tierMap); return {label} ; })()}
{payment.plan.duration_days} days
diff --git a/src/modules/admin/pages/tiers/ViewPlan.jsx b/src/modules/admin/pages/tiers/ViewPlan.jsx
index aa79244..5af2e7b 100644
--- a/src/modules/admin/pages/tiers/ViewPlan.jsx
+++ b/src/modules/admin/pages/tiers/ViewPlan.jsx
@@ -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() {
View plan information.
- navigate(`/admin/tiers/plans/${planId}/edit`)}
- disabled={loading}
- >
-
- Edit
-
+
+
navigate(`/admin/tiers/plans/${planId}/policy`)}
+ disabled={loading}
+ >
+
+ Policy
+
+
navigate(`/admin/tiers/plans/${planId}/edit`)}
+ disabled={loading}
+ >
+
+ Edit
+
+
{loading && !plan ? (
@@ -101,9 +118,7 @@ export default function ViewPlan() {
{plan.label}
-
- {plan.tier}
-
+ {(() => { const { cls, label } = resolveTierBadge(plan.tier, tierMap); return {label} ; })()}
{plan.duration_days} days
@@ -121,37 +136,14 @@ export default function ViewPlan() {
- {plan.createdAt ? new Date(plan.createdAt).toLocaleString() : "—"}
+ {plan.createdAt ? fmtDateTime(plan.createdAt) : "—"}
- {plan.updatedAt ? new Date(plan.updatedAt).toLocaleString() : "—"}
+ {plan.updatedAt ? fmtDateTime(plan.updatedAt) : "—"}
-
- {planCourses.length === 0 ? (
- No courses assigned to this plan.
- ) : (
-
- {planCourses.map((course) => (
-
-
-
-
-
{course.title}
- {course.course_code && (
-
{course.course_code}
- )}
-
-
-
{course.level ?? "—"}
-
- ))}
-
- )}
-
-
)}
diff --git a/src/modules/admin/pages/user_groups/ViewGroup.jsx b/src/modules/admin/pages/user_groups/ViewGroup.jsx
index 8b3885f..39515fe 100644
--- a/src/modules/admin/pages/user_groups/ViewGroup.jsx
+++ b/src/modules/admin/pages/user_groups/ViewGroup.jsx
@@ -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 (
+
+
+
+
+ Assign to Group
+
+
+ Select a group to assign{" "}
+ {userCount === 1 ? "this user" : `${userCount} users`} to.
+ They will be added to the selected group.
+
+
+
+
+
+ {/* ── Search input ── */}
+
+
+ setSearch(e.target.value)}
+ placeholder="Search groups..."
+ className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground py-0.5"
+ />
+ {search && (
+ setSearch('')}
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ >
+
+
+ )}
+
+
+ {/* ── Group list ── */}
+
+
+ {filtered.length === 0 ? (
+
+ {search ? `No results for "${search}".` : 'No groups available.'}
+
+ ) : (
+
+ {filtered.map((g) => {
+ const isSelected = selectedGroupId === g.group_id;
+ return (
+ 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 */}
+
+ {isSelected && (
+
+ )}
+
+
+ {/* Name */}
+
+ {g.name}
+
+
+ {/* Group code pill */}
+
+ {g.group_code}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
+ onOpenChange(false)}>Cancel
+ onAssign(selectedGroupId)}
+ >
+ Assign
+
+
+
+
+ );
+}
+
// ─── 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: , 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() {
- {/* ── Generate invite link button ── */}
- {group?.group_code && (
+ {/* ── Generate invite link button — hidden for NOGRP (system default group) ── */}
+ {group?.group_code && group.group_code !== 'NOGRP' && (
+ {/* ── Assign to group dialog (NOGRP members only) ───────────────────── */}
+
+
{/* ── Add member ───────────────────────────────────────────────────── */}
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 });
diff --git a/src/modules/admin/pages/users/ViewUser.jsx b/src/modules/admin/pages/users/ViewUser.jsx
index dc901be..b192ecf 100644
--- a/src/modules/admin/pages/users/ViewUser.jsx
+++ b/src/modules/admin/pages/users/ViewUser.jsx
@@ -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 (
+ {/* ─── Breadcrumb ──────────────────────────────────────────────────── */}
+
, to: "/admin" },
+ { label: "Users", to: "/admin/users" },
+ { label: name.full_name ?? user.email ?? "View User" },
+ ]} />
+
{/* ─── Header ──────────────────────────────────────────────────────── */}
-
navigate(`/admin/users`)}>
-
-
+
+
+
+ {(name.full_name ?? user.email ?? "?")
+ .split(" ").map((n) => n[0]).slice(0, 2).join("").toUpperCase()}
+
+
-
- {name.full_name ?? "—"}
-
+
+
+ {name.full_name ?? "—"}
+
+ {user.is_banned && (
+
+ Banned
+
+ )}
+
{user.email}
+
+ {user.is_banned ? (
+ setUnbanDialogOpen(true)}
+ >
+ Unban User
+
+ ) : user.is_active ? (
+ setBanDialogOpen(true)}
+ >
+ Ban User
+
+ ) : null}
+
{/* ─── Account Info ────────────────────────────────────────────────── */}
@@ -87,12 +135,20 @@ export default function ViewUser() {
+
+
+
+ {user.ban_expires_at && (
+
+ {fmtDateTime(user.ban_expires_at)}
+
+ )}
{/* ─── Personal Info ───────────────────────────────────────────────── */}
@@ -169,7 +225,7 @@ export default function ViewUser() {
{a.label}
{a.description}
- {new Date(a.granted_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
+ {fmtDate(a.granted_at)}
{isCert && (
@@ -244,10 +300,7 @@ export default function ViewUser() {
- {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)}
) : (
@@ -283,13 +336,106 @@ export default function ViewUser() {
)}
+ {/* ─── Ban History ─────────────────────────────────────────────────── */}
+
+
+ Ban History
+ {bans.length > 0 && (
+
+ {bans.length} record{bans.length !== 1 ? "s" : ""}
+
+ )}
+
+
+ {bansLoading ? (
+
+ {[...Array(2)].map((_, i) => (
+
+
+
+
+
+ ))}
+
+ ) : bans.length === 0 ? (
+
No bans on record.
+ ) : (
+
+ {bans.map((ban) => (
+
+
+
+ {ban.is_lifted ? "Lifted" : ban.ban_type === "permanent" ? "Permanent" : "Temporary"}
+
+ {ban.reason}
+
+ {fmtDate(ban.banned_at)}
+
+
+
+
+ Banned by{" "}
+
+ {ban.banner?.personal_info?.name?.full_name ?? ban.banner?.email ?? "—"}
+
+
+ {ban.ban_type === "temporary" && ban.expires_at && (
+
+ Until{" "}
+
+ {fmtDate(ban.expires_at)}
+
+
+ )}
+ {ban.is_lifted && ban.lifter && (
+
+ Lifted by{" "}
+
+ {ban.lifter?.personal_info?.name?.full_name ?? ban.lifter?.email ?? "—"}
+
+ {ban.lift_reason && <> — {ban.lift_reason}>}
+
+ )}
+
+
+ ))}
+
+ )}
+
+
{/* ─── Audit ───────────────────────────────────────────────────────── */}
- {user.createdAt ? new Date(user.createdAt).toLocaleString() : "—"}
- {user.updatedAt ? new Date(user.updatedAt).toLocaleString() : "—"}
- {user.deletedAt ? new Date(user.deletedAt).toLocaleString() : "—"}
+ {fmtDateTime(user.createdAt)}
+ {fmtDateTime(user.updatedAt)}
+ {fmtDateTime(user.deletedAt)}
+ {/* ─── Ban / Unban Dialogs ─────────────────────────────────────────── */}
+ u?.personal_info?.name?.full_name ?? u?.email}
+ onBan={(payload) => banUser(userId, payload)}
+ loading={loading}
+ onSuccess={() => { fetchUser(userId); fetchUserBans(userId); }}
+ />
+ u?.personal_info?.name?.full_name ?? u?.email}
+ onUnban={(payload) => unbanUser(userId, payload)}
+ loading={loading}
+ onSuccess={() => { fetchUser(userId); fetchUserBans(userId); }}
+ />
+
);
}
diff --git a/src/modules/admin/routes/AdminRoutes.jsx b/src/modules/admin/routes/AdminRoutes.jsx
index 41f6cf6..8eb6964 100644
--- a/src/modules/admin/routes/AdminRoutes.jsx
+++ b/src/modules/admin/routes/AdminRoutes.jsx
@@ -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: },
{ path: 'view/:userId', element: },
{ path: 'archived', element: },
+ { path: ':userId/activity', element: },
]
},
@@ -187,7 +191,7 @@ export const AdminRoutes = {
{ path: 'add', element: },
{ path: ':unitId/view', element: },
{ path: ':unitId/edit', element: },
- { path: ":unitId/quiz", element: },
+ { path: ":unitId/quiz/edit", element: },
{ path: ":unitId/quiz/view", element: },
// Lessons
@@ -248,10 +252,20 @@ export const AdminRoutes = {
children: [
{ index: true, element: },
{ path: 'add', element: },
- { path: ':planId/view', element: },
- { path: ':planId/edit', element: },
+ { path: ':planId/view', element: },
+ { path: ':planId/edit', element: },
]
},
+ { path: 'system-badges', element: },
+ {
+ path: 'categories',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: 'add', element: },
+ { path: ':id/edit', element: },
+ ],
+ },
{
path: 'users/:userId/tiers',
element: ,
@@ -283,7 +297,6 @@ export const AdminRoutes = {
// Activity Feed
{ path: 'activity', element: },
- { path: 'users/:userId/activity', element: },
// Add here
]
diff --git a/src/modules/auth/components/LoginForm.jsx b/src/modules/auth/components/LoginForm.jsx
index 037bc99..d4823db 100644
--- a/src/modules/auth/components/LoginForm.jsx
+++ b/src/modules/auth/components/LoginForm.jsx
@@ -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)
}
diff --git a/src/modules/auth/pages/OAuthCallback.jsx b/src/modules/auth/pages/OAuthCallback.jsx
index a9b1f4b..a631828 100644
--- a/src/modules/auth/pages/OAuthCallback.jsx
+++ b/src/modules/auth/pages/OAuthCallback.jsx
@@ -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 (
+
+
+
+
+
+
+
+
+
Account Suspended
+
+ Your account has been suspended and you cannot access the platform at this time.
+
+
+
+ {(reason || banType) && (
+
+ {reason && (
+
+ )}
+ {banType === 'temporary' && expiryText && (
+
+
Suspended Until
+
{expiryText}
+
+ )}
+ {banType === 'permanent' && (
+
+ )}
+
+ )}
+
+
+ If you believe this is a mistake, please reach out to your administrator for assistance.
+
+
+
+ Back to Login
+
+
+
+
+ )
+ }
+
if (error) {
+ const { icon: Icon, message } = ERROR_MAP[error] ?? { icon: AlertTriangle, message: 'An unexpected error occurred. Please try again.' }
return (
-
- {ERROR_MESSAGES[error] ?? 'An unexpected error occurred. Please try again.'}
-
-
+
+
+
+ {message}
+
Back to Login
diff --git a/src/modules/auth/routes/AuthRoutes.jsx b/src/modules/auth/routes/AuthRoutes.jsx
index aad2160..4c680c0 100644
--- a/src/modules/auth/routes/AuthRoutes.jsx
+++ b/src/modules/auth/routes/AuthRoutes.jsx
@@ -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:
},
{ path: "signup", element:
},
{ path: "auth/callback/google", element:
},
+ { path: "suspended", element:
},
]
},
],
diff --git a/src/modules/client/components/blocks/CourseCompleteBlock.jsx b/src/modules/client/components/blocks/CourseCompleteBlock.jsx
index 80c8614..ce03a0f 100644
--- a/src/modules/client/components/blocks/CourseCompleteBlock.jsx
+++ b/src/modules/client/components/blocks/CourseCompleteBlock.jsx
@@ -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.
+
+
+
+
Certificate Pending
+
+ Certificates are issued automatically every hour. Yours will be ready within the next hour — check your notifications.
+
+
+
);
diff --git a/src/modules/client/components/blocks/FileUpload.jsx b/src/modules/client/components/blocks/FileUpload.jsx
index ef6296c..55e0185 100644
--- a/src/modules/client/components/blocks/FileUpload.jsx
+++ b/src/modules/client/components/blocks/FileUpload.jsx
@@ -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 (
-
-
-
- Total size
-
-
- {formatBytes(usedBytes)} of {formatBytes(maxBytes)}
-
-
-
div]:bg-destructive",
- isWarn && "[&>div]:bg-amber-500"
- )}
- />
-
- );
-};
-
// ── FileUpload ────────────────────────────────────────────────────────────────
/**
* Standalone file upload UI — no modal, no footer buttons.
* Compose inside or any container.
*
* Props:
- * maxBytes {number} – total size cap (default: 500 MB)
* accept {string} – native 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 = ({
Drop files here or click to browse
Upload your work to submit with this task
- {derivedHint} · Max total: {formatBytes(maxBytes)}
+ {derivedHint}
)}
@@ -348,18 +305,6 @@ const FileUpload = ({
)}
{derivedHint}
-
-
-
- {isOverLimit && (
-
-
-
- Total file size exceeds the {formatBytes(maxBytes)} limit.
- Please remove some files before turning in.
-
-
- )}
)}
@@ -376,4 +321,4 @@ const FileUpload = ({
};
export default FileUpload;
-export { FileUpload, FileItem, FileIcon, StorageBar, formatBytes, iconForFile };
\ No newline at end of file
+export { FileUpload, FileItem, FileIcon, formatBytes, iconForFile };
\ No newline at end of file
diff --git a/src/modules/client/components/blocks/QuizBlock.jsx b/src/modules/client/components/blocks/QuizBlock.jsx
index dddef91..bbb1823 100644
--- a/src/modules/client/components/blocks/QuizBlock.jsx
+++ b/src/modules/client/components/blocks/QuizBlock.jsx
@@ -9,6 +9,7 @@ import {
Clock, AlertTriangle, Info,
} from "lucide-react";
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
+import { useDateFormat } from "@/hooks/useDateFormat";
function QuizSkeleton() {
return (
@@ -33,84 +34,99 @@ function formatTime(secs) {
return `${m}:${s}`;
}
+function isQuestionAnswered(answer) {
+ return Array.isArray(answer) ? answer.length > 0 : answer !== undefined;
+}
+
/**
* Props:
* quiz — assessment data including active_session, time_limit_minutes, etc.
* loading — true while fetch is in-flight
- * onStart — async () => { session_id, expires_at, remaining_seconds, draft_answers } — only for timed assessments
- * onDraft — (answers) => void — called every 25s to UPSERT draft + last_heartbeat_at
- * onRefreshSession — async () => { expires_at, remaining_seconds } — called when assessment_updated fires
+ * onStart — async () => { session_id, expires_at, remaining_seconds, draft_answers }
+ * onDraft — (answers) => void — called every 25s
+ * onRefreshSession — async () => { expires_at, remaining_seconds }
* onSubmit — async (answers, sessionId?) => result|null
- * onRetake — () => void — refetch so attempt stats refresh
+ * onRetake — () => void
+ * onActiveChange — (isActive: boolean) => void — fires when session starts/ends
* label — "Quiz" or "Assessment"
*/
-const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession, onSubmit, onRetake, label = "Quiz" }) => {
+const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession, onSubmit, onRetake, onActiveChange, label = "Quiz" }) => {
+ const { fmtDateTime } = useDateFormat();
const questions = quiz?.questions ?? [];
const total = questions.length;
// ── Core stage state ──────────────────────────────────────────────────────
- const [stage, setStage] = useState("intro"); // 'intro' | 'taking' | 'result'
+ const [stage, setStage] = useState("intro"); // 'intro' | 'taking' | 'review' | 'result'
const [currentIndex, setCurrentIndex] = useState(0);
- const [answers, setAnswers] = useState({});
- const [submitting, setSubmitting] = useState(false);
- const [starting, setStarting] = useState(false);
- const [result, setResult] = useState(null);
+ const [answers, setAnswers] = useState({});
+ const [submitting, setSubmitting] = useState(false);
+ const [starting, setStarting] = useState(false);
+ const [result, setResult] = useState(null);
+ const [reviewAttempted, setReviewAttempted] = useState(false);
// ── Timer state ───────────────────────────────────────────────────────────
- const [remainingSeconds, setRemainingSeconds] = useState(null); // null = no active countdown
+ const [remainingSeconds, setRemainingSeconds] = useState(null);
const [timeExpired, setTimeExpired] = useState(false);
- // ── Notifications (assessment_updated alert) ──────────────────────────────
+ // ── Notifications ─────────────────────────────────────────────────────────
const { notifications, fetchNotifications, accelerate, decelerate } = useClientNotifications();
const [assessmentUpdatedAlert, setAssessmentUpdatedAlert] = useState(false);
- const seenNotifRef = useRef(new Set()); // tracks notification_ids already shown
+ const seenNotifRef = useRef(new Set());
- // ── Session refs (stable across renders, no stale-closure issues) ─────────
- const sessionRef = useRef({ sessionId: null, expiresAt: null });
- const answersRef = useRef({});
- const timerFiredRef = useRef(false);
+ // ── Session refs ──────────────────────────────────────────────────────────
+ const sessionRef = useRef({ sessionId: null, expiresAt: null });
+ const answersRef = useRef({});
+ const timerFiredRef = useRef(false);
- // Keep answersRef in sync with state so the timer's auto-submit closure reads fresh data
useEffect(() => { answersRef.current = answers; }, [answers]);
- // Reset all state when the quiz/assessment changes
+ // Notify parent when an active session starts or ends
+ useEffect(() => {
+ onActiveChange?.(stage === "taking" || stage === "review");
+ }, [stage]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Always deactivate when component unmounts (user navigated away mid-session)
+ useEffect(() => () => { onActiveChange?.(false); }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
useEffect(() => {
setStage("intro");
- setCurrentIndex(0);
setResult(null);
setRemainingSeconds(null);
setTimeExpired(false);
+ setReviewAttempted(false);
sessionRef.current = { sessionId: null, expiresAt: null };
timerFiredRef.current = false;
- // Pre-load any saved draft so the student can see their progress on the intro screen
const draft = quiz?.active_session?.draft_answers ?? {};
- setAnswers(Object.keys(draft).length > 0 ? draft : {});
- answersRef.current = draft;
+ const hasDraft = Object.keys(draft).length > 0;
+ setAnswers(hasDraft ? draft : {});
+ answersRef.current = hasDraft ? draft : {};
+ if (hasDraft) {
+ const qs = quiz?.questions ?? [];
+ const firstUnanswered = qs.findIndex(q => !isQuestionAnswered(draft[q.question_id]));
+ setCurrentIndex(firstUnanswered > -1 ? firstUnanswered : 0);
+ } else {
+ setCurrentIndex(0);
+ }
}, [quiz?.quiz_id]);
- // ── Speed up notification polling while mid-assessment ───────────────────
useEffect(() => {
if (stage !== "taking") { decelerate(); return; }
accelerate();
- fetchNotifications(); // immediate refresh when stage starts
+ fetchNotifications();
return () => decelerate();
}, [stage]); // eslint-disable-line react-hooks/exhaustive-deps
- // ── Watch for assessment_updated notifications ────────────────────────────
useEffect(() => {
if (stage !== "taking") return;
notifications.forEach((n) => {
if (n.type === "assessment" && n.title === "Assessment Updated" && !seenNotifRef.current.has(n.notification_id)) {
seenNotifRef.current.add(n.notification_id);
setAssessmentUpdatedAlert(true);
- // Refresh the session timer — admin may have changed time_limit_minutes
if (onRefreshSession) {
onRefreshSession().then(updated => {
if (updated?.remaining_seconds != null) {
setRemainingSeconds(updated.remaining_seconds);
- sessionRef.current.expiresAt = updated.expires_at
- ? new Date(updated.expires_at)
- : null;
+ sessionRef.current.expiresAt = updated.expires_at ? new Date(updated.expires_at) : null;
}
});
}
@@ -118,82 +134,71 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
});
}, [notifications, stage]); // eslint-disable-line react-hooks/exhaustive-deps
- // ── Draft auto-save: immediate on enter, then every 25s ──────────────────
- // The immediate call sets last_heartbeat_at right away so the crash-resume
- // freeze logic always has a reference point even if the tab closes within seconds.
useEffect(() => {
if (stage !== "taking" || !onDraft) return;
- onDraft(answersRef.current); // immediate — seeds last_heartbeat_at now
+ onDraft(answersRef.current);
const id = setInterval(() => onDraft(answersRef.current), 25_000);
return () => clearInterval(id);
}, [stage, onDraft]); // eslint-disable-line react-hooks/exhaustive-deps
- // ── Timer tick (only active while stage === 'taking' and expiresAt is set) ─
+ // Save immediately on every answer change — ensures no answer is lost if the tab closes
+ // before the 25s interval fires. Guards on stage and non-empty answers to skip resets.
+ useEffect(() => {
+ if (stage !== "taking" || !onDraft || Object.keys(answers).length === 0) return;
+ onDraft(answers);
+ }, [answers]); // eslint-disable-line react-hooks/exhaustive-deps
+
useEffect(() => {
if (stage !== "taking" || !sessionRef.current.expiresAt) return;
-
timerFiredRef.current = false;
-
const tick = () => {
const secs = Math.max(0, Math.ceil((sessionRef.current.expiresAt.getTime() - Date.now()) / 1000));
setRemainingSeconds(secs);
-
if (secs <= 0 && !timerFiredRef.current) {
timerFiredRef.current = true;
setTimeExpired(true);
clearInterval(id);
}
};
-
- tick(); // immediate first tick so UI shows correct time right away
+ tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
- }, [stage]); // intentionally only stage — expiresAt is a ref
+ }, [stage]);
- // ── Auto-submit when time runs out ────────────────────────────────────────
useEffect(() => {
if (!timeExpired || submitting) return;
-
const autoSubmit = async () => {
setSubmitting(true);
const res = await onSubmit?.(answersRef.current, sessionRef.current.sessionId);
setSubmitting(false);
- if (res) {
- setResult(res);
- setStage("result");
- }
+ if (res) { setResult(res); setStage("result"); }
};
-
autoSubmit();
}, [timeExpired]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Handlers ─────────────────────────────────────────────────────────────
- const handleStart = useCallback(async (resuming = false) => {
+ const handleStart = useCallback(async () => {
const isAssessment = label === "Assessment";
const hasTimeLimit = isAssessment && (quiz?.time_limit_minutes ?? 0) > 0;
- // Always create/resume a session for assessments (timed or not) so admin
- // can see in-progress records and draft answers are preserved on crash.
if (isAssessment && onStart) {
setStarting(true);
const session = await onStart();
setStarting(false);
- if (!session) return; // onStart already toasted the error
-
+ if (!session) return;
sessionRef.current = {
sessionId: session.session_id,
expiresAt: session.expires_at ? new Date(session.expires_at) : null,
};
- if (hasTimeLimit && session.remaining_seconds != null) {
- setRemainingSeconds(session.remaining_seconds);
- }
- // Restore draft answers saved before the crash/close
+ if (hasTimeLimit && session.remaining_seconds != null) setRemainingSeconds(session.remaining_seconds);
if (session.draft_answers && Object.keys(session.draft_answers).length > 0) {
setAnswers(session.draft_answers);
answersRef.current = session.draft_answers;
+ const qs = quiz?.questions ?? [];
+ const firstUnanswered = qs.findIndex(q => !isQuestionAnswered(session.draft_answers[q.question_id]));
+ setCurrentIndex(firstUnanswered > -1 ? firstUnanswered : 0);
}
}
-
setStage("taking");
}, [label, quiz?.time_limit_minutes, onStart]);
@@ -203,17 +208,24 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
setResult(null);
setRemainingSeconds(null);
setTimeExpired(false);
+ setReviewAttempted(false);
sessionRef.current = { sessionId: null, expiresAt: null };
timerFiredRef.current = false;
setStage("intro");
onRetake?.();
};
+ const handleSubmit = useCallback(async () => {
+ setSubmitting(true);
+ const res = await onSubmit?.(answers, sessionRef.current.sessionId);
+ setSubmitting(false);
+ if (res) { setResult(res); setStage("result"); }
+ }, [answers, onSubmit]);
+
const handleOptionClick = (optionId) => {
const question = questions[currentIndex];
const isMulti = question.type === "multi_select";
const multiLimit = isMulti ? (question.correct_count ?? null) : null;
-
setAnswers((prev) => {
if (!isMulti) return { ...prev, [question.question_id]: optionId };
const current = prev[question.question_id] ?? [];
@@ -225,26 +237,27 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
});
};
- const handleNext = async () => {
- if (isLast) {
- setSubmitting(true);
- const res = await onSubmit?.(answers, sessionRef.current.sessionId);
- setSubmitting(false);
- if (res) {
- setResult(res);
- setStage("result");
- }
- return;
- }
+ const handleGoToReview = () => {
+ setReviewAttempted(true);
+ const allAnswered = questions.every(q => isQuestionAnswered(answers[q.question_id]));
+ if (!allAnswered) return;
+ setStage("review");
+ };
+
+ const handleNext = () => {
+ if (isLast) { handleGoToReview(); return; }
setCurrentIndex((i) => Math.min(i + 1, total - 1));
};
const handlePrev = () => {
- if (isFirst) { setStage("intro"); return; }
+ if (isFirst) {
+ if (label !== "Assessment") setStage("intro");
+ return;
+ }
setCurrentIndex((i) => Math.max(i - 1, 0));
};
- // ── Empty / loading guards ────────────────────────────────────────────────
+ // ── Guards ────────────────────────────────────────────────────────────────
if (!quiz && !loading) {
return (
@@ -252,9 +265,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
);
}
-
if (loading) return ;
-
if (!total) {
return (
@@ -266,14 +277,159 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
// ── Derived values ────────────────────────────────────────────────────────
const isAssessment = label === "Assessment";
const hasTimeLimit = isAssessment && (quiz?.time_limit_minutes ?? 0) > 0;
- const activeSession = isAssessment ? (quiz?.active_session ?? null) : null; // pre-existing in_progress from server
+ const activeSession = quiz?.active_session ?? null;
- // ── Intro screen ─────────────────────────────────────────────────────────
- if (stage === "intro") {
- const attempts = quiz.attempt_count ?? 0;
+ // ── ASSESSMENT INTRO ──────────────────────────────────────────────────────
+ if (stage === "intro" && isAssessment) {
+ const attempts = quiz.attempt_count ?? 0;
const attemptsRemaining = quiz.attempts_remaining ?? null;
- const cooldownUntil = quiz.cooldown_until ? new Date(quiz.cooldown_until) : null;
- const canAttempt = quiz.can_attempt ?? true;
+ const cooldownUntil = quiz.cooldown_until ? new Date(quiz.cooldown_until) : null;
+ const canAttempt = quiz.can_attempt ?? true;
+
+ const attemptsDisplay = attemptsRemaining !== null && quiz.max_attempts
+ ? `${attemptsRemaining} / ${quiz.max_attempts}`
+ : attemptsRemaining !== null
+ ? `${attemptsRemaining}`
+ : attempts > 0 ? `${attempts}` : "—";
+
+ return (
+
+ {/* Header */}
+
+
+ {label}
+
+
{quiz.title || label}
+ {quiz.description && (
+
{quiz.description}
+ )}
+ {quiz.is_required && !quiz.description && (
+
+ Required to complete this course.
+
+ )}
+
+
+ {/* Stats grid */}
+
+
+
+
+ {hasTimeLimit ? `${quiz.time_limit_minutes} min` : "—"}
+
+
Time limit
+
+
+
{quiz.passing_score}%
+
To pass
+
+
+
{attemptsDisplay}
+
Attempts left
+
+
+
+ {/* Status banners */}
+ {quiz.has_passed && (
+
+
+ You've already passed this assessment
+
+
+ Best score: {quiz.best_attempt?.score}%
+ {quiz.best_attempt?.passing_score != null && (
+ · passing score {quiz.best_attempt.passing_score}%
+ )}
+
+
+ )}
+ {!canAttempt && cooldownUntil && (
+
+
You're on cooldown
+
You can retake this assessment after {fmtDateTime(cooldownUntil)}
+
+ )}
+ {!canAttempt && !cooldownUntil && attemptsRemaining === 0 && (
+
+
No attempts remaining
+
+ {quiz.window_reset_at
+ ? `You can try again after ${fmtDateTime(quiz.window_reset_at)}`
+ : "You've used all available attempts for this assessment"}
+
+
+ )}
+ {activeSession && canAttempt && (
+
+
+ Session in progress
+
+
+ You have an unfinished attempt.{" "}
+ {activeSession.remaining_seconds != null
+ ? `${formatTime(activeSession.remaining_seconds)} remaining — resume before time runs out.`
+ : "Resume where you left off."}
+
+
+ )}
+
+ {/* Before you begin */}
+
+
Before you begin
+
+ {hasTimeLimit && (
+
+
+
+ The timer starts the moment you begin and{" "}
+ cannot be paused . Your assessment auto-submits when time runs out.
+
+
+ )}
+
+
+ Answer each question before moving to the next. You can return to any answered question to change your answer.
+
+
+
+
+ Your progress is saved automatically — you can safely resume if you lose connection.
+
+
+
+
+ Review all answers on the summary screen before final submission.
+
+
+
+
+
+ {starting
+ ? "Starting…"
+ : activeSession
+ ? "Resume Assessment"
+ : attempts > 0
+ ? "Retake Assessment"
+ : "Begin assessment"}
+
+
+ );
+ }
+
+ // ── QUIZ INTRO ────────────────────────────────────────────────────────────
+ if (stage === "intro") {
+ const attempts = quiz.attempt_count ?? 0;
+ const attemptsRemaining = quiz.attempts_remaining ?? null;
+ const cooldownUntil = quiz.cooldown_until ? new Date(quiz.cooldown_until) : null;
+ const canAttempt = quiz.can_attempt ?? true;
+ const hasActiveSession = !!quiz.active_session;
return (
@@ -282,71 +438,27 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
{quiz.title || label}
{quiz.is_required && (
- Required to complete this {label === "Assessment" ? "course" : "unit"}
-
- )}
- {label === "Assessment" && quiz.max_attempts && quiz.cooldown_hours && (
-
- {quiz.max_attempts} failed attempt{quiz.max_attempts !== 1 ? "s" : ""} → {quiz.cooldown_hours}h cooldown
-
- )}
- {hasTimeLimit && (
-
-
- {quiz.time_limit_minutes} minute time limit
+ Required to complete this unit
)}
- {quiz.has_passed && (
+ {quiz.has_passed && !hasActiveSession && (
-
- You've already passed this {label.toLowerCase()}
-
-
- Best score: {quiz.best_attempt?.score}%
- {quiz.best_attempt?.passing_score != null && (
- · passing score {quiz.best_attempt.passing_score}%
- )}
-
- {quiz.best_attempt?.attempt_number != null && (
-
Passed on attempt #{quiz.best_attempt.attempt_number}
- )}
+
You've already passed this quiz
+
Best score: {quiz.best_attempt?.score}%
)}
- {!canAttempt && cooldownUntil && (
-
-
You're on cooldown
-
- You can retake this {label.toLowerCase()} after {cooldownUntil.toLocaleString()}
-
-
- )}
-
- {!canAttempt && !cooldownUntil && attemptsRemaining === 0 && (
-
-
No attempts remaining
-
- {quiz.window_reset_at
- ? `You can try again after ${new Date(quiz.window_reset_at).toLocaleString()}`
- : `You've used all available attempts for this ${label.toLowerCase()}`}
-
-
- )}
-
- {/* Resume banner — shown when the server reports an active in_progress session */}
- {activeSession && canAttempt && (
-
-
- Session in progress
-
-
- You have an unfinished attempt.{" "}
- {activeSession.remaining_seconds != null
- ? `${formatTime(activeSession.remaining_seconds)} remaining — resume before time runs out.`
- : "Resume where you left off."}
-
+ {hasActiveSession && (
+
+
+
+
You have an unfinished session
+
+ You left this quiz before completing it. Resume to continue from where you left off.
+
+
)}
@@ -355,19 +467,6 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
{total}
Question{total === 1 ? "" : "s"}
- {(attemptsRemaining !== null || attempts > 0) && (
- <>
-
-
-
- {attemptsRemaining !== null ? attemptsRemaining : attempts}
-
-
- {attemptsRemaining !== null ? "Attempts Left" : `Attempt${attempts === 1 ? "" : "s"}`}
-
-
- >
- )}
{quiz.passing_score}%
@@ -378,30 +477,22 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
handleStart(!!activeSession)}
+ onClick={handleStart}
disabled={!canAttempt || starting}
>
- {starting
- ? "Starting…"
- : activeSession
- ? "Resume Assessment"
- : attempts > 0
- ? `Retake ${label}`
- : `Start ${label}`}
+ {starting ? "Starting…" : hasActiveSession ? "Resume Quiz" : attempts > 0 ? "Retake Quiz" : "Start Quiz"}
);
}
- // ── Results view ─────────────────────────────────────────────────────────
+ // ── RESULT ────────────────────────────────────────────────────────────────
if (stage === "result" && result) {
return (
-
- {result.passed ? "You passed!" : "You did not pass"}
-
+
{result.passed ? "You passed!" : "You did not pass"}
{result.score}%
{result.earned_points} / {result.total_points} points · passing score {result.passing_score}%
@@ -410,7 +501,6 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
Attempt #{result.attempt_number}
)}
-
{!result.passed && (
Retake {label}
@@ -420,7 +510,201 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
);
}
- // ── Question stepper ─────────────────────────────────────────────────────
+ // ── ASSESSMENT REVIEW ─────────────────────────────────────────────────────
+ if (stage === "review" && isAssessment) {
+ const answeredCount = questions.filter(q => isQuestionAnswered(answers[q.question_id])).length;
+ const unansweredCount = total - answeredCount;
+
+ return (
+
+
+
Final Step
+
Review your answers
+
+ Once you submit, your answers are final and your score is calculated.
+
+
+
+ {/* Stats row */}
+
+
+
{answeredCount}
+
Answered
+
+
+
0 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground"}`}>
+ {unansweredCount}
+
+
Unanswered
+
+
+
+ {remainingSeconds !== null ? formatTime(remainingSeconds) : "—"}
+
+
Time left
+
+
+
+ {/* Unanswered warning */}
+ {unansweredCount > 0 && (
+
+
+
+ {unansweredCount} question{unansweredCount !== 1 ? "s are" : " is"} still unanswered. {" "}
+ You can still submit. Tap a highlighted number below to jump back.
+
+
+ )}
+
+ {/* All questions grid */}
+
+
All questions
+
+ {questions.map((q, i) => {
+ const answered = isQuestionAnswered(answers[q.question_id]);
+ return (
+ { setCurrentIndex(i); setStage("taking"); }}
+ className={`aspect-square rounded-lg text-sm font-medium transition-colors
+ ${answered
+ ? "bg-foreground text-background hover:opacity-80"
+ : "border-2 border-amber-400 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10"
+ }`}
+ >
+ {i + 1}
+
+ );
+ })}
+
+
+
+ Answered
+
+
+ Unanswered
+
+
+
+
+
+ setStage("taking")} disabled={submitting}>
+
+ Back to assessment
+
+
+ {submitting ? "Submitting…" : "Submit assessment"}
+
+
+
+ );
+ }
+
+ // ── QUIZ REVIEW ───────────────────────────────────────────────────────────
+ if (stage === "review") {
+ return (
+
+
+
+ {quiz.title || label}
+
+
+
+ {/* Question pills */}
+
+ {questions.map((q, i) => {
+ const answered = isQuestionAnswered(answers[q.question_id]);
+ return (
+ { setCurrentIndex(i); setStage("taking"); }}
+ className={`size-9 rounded-full text-sm font-semibold transition-colors
+ ${answered
+ ? "bg-foreground text-background hover:opacity-80"
+ : "bg-muted text-muted-foreground border border-border hover:bg-muted/80"
+ }`}
+ >
+ {i + 1}
+
+ );
+ })}
+
+
+
+
Review your answers
+
+ Check your responses before submitting. Tap any question to go back and change your answer.
+
+
+
+
+ {questions.map((q, i) => {
+ const ans = answers[q.question_id];
+ const answered = isQuestionAnswered(ans);
+ const isMultiQ = q.type === "multi_select";
+
+ let answerLabel = null;
+ if (answered) {
+ if (isMultiQ && Array.isArray(ans)) {
+ answerLabel = ans
+ .map(id => {
+ const idx = (q.options ?? []).findIndex(o => o.option_id === id);
+ return idx >= 0 ? String.fromCharCode(65 + idx) : null;
+ })
+ .filter(Boolean)
+ .join(", ");
+ } else {
+ const idx = (q.options ?? []).findIndex(o => o.option_id === ans);
+ if (idx >= 0) {
+ answerLabel = `${String.fromCharCode(65 + idx)}. ${q.options[idx].text}`;
+ }
+ }
+ }
+
+ return (
+
{ setCurrentIndex(i); setStage("taking"); }}
+ className="w-full flex items-center gap-4 rounded-xl border bg-card p-4 text-left hover:bg-muted/30 transition-colors"
+ >
+
+ {i + 1}
+
+
+
{q.question}
+ {answerLabel && (
+
{answerLabel}
+ )}
+
+
+ {answered ? "Answered" : "Not answered"}
+
+
+ );
+ })}
+
+
+
+ setStage("taking")} disabled={submitting}>
+
+ Back to questions
+
+
+ {submitting ? "Submitting…" : "Submit quiz"}
+
+
+
+ );
+ }
+
+ // ── QUESTION STEPPER (shared derived values) ──────────────────────────────
const question = questions[currentIndex];
const isFirst = currentIndex === 0;
const isLast = currentIndex === total - 1;
@@ -428,11 +712,12 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
const selected = answers[question.question_id];
const progress = Math.round(((currentIndex + 1) / total) * 100);
- const multiLimit = isMulti ? (question.correct_count ?? null) : null;
- const selectedCount = isMulti ? (selected ?? []).length : 0;
- const limitReached = multiLimit !== null && selectedCount >= multiLimit;
+ const multiLimit = isMulti ? (question.correct_count ?? null) : null;
+ const selectedCount = isMulti ? (selected ?? []).length : 0;
+ const limitReached = multiLimit !== null && selectedCount >= multiLimit;
+ const isCurrentAnswered = isQuestionAnswered(selected);
+ const unansweredCount = questions.filter(q => !isQuestionAnswered(answers[q.question_id])).length;
- // Timer color: red < 60s, amber < 5min, default otherwise
const timerColor = remainingSeconds !== null
? remainingSeconds <= 60
? "text-red-500 dark:text-red-400"
@@ -441,36 +726,211 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
: "text-muted-foreground"
: null;
+ // ── ASSESSMENT TAKING (2-column layout) ───────────────────────────────────
+ if (isAssessment) {
+ const answeredCount = questions.filter(q => isQuestionAnswered(answers[q.question_id])).length;
+
+ const optionRows = (question.options ?? []).map((option, i) => {
+ const letter = String.fromCharCode(65 + i);
+ const isSelected = isMulti
+ ? (selected ?? []).includes(option.option_id)
+ : selected === option.option_id;
+ const isDisabled = (isMulti && limitReached && !isSelected) || (timeExpired && submitting);
+ const Icon = isMulti
+ ? (isSelected ? CheckSquare2 : Square)
+ : (isSelected ? CheckCircle2 : Circle);
+
+ return (
+
handleOptionClick(option.option_id)}
+ disabled={isDisabled}
+ className={`flex w-full items-center gap-3 rounded-lg border px-3 py-3 text-left text-sm transition-colors sm:text-base
+ ${isSelected ? "border-primary bg-primary/5" : "border-border"}
+ ${isDisabled ? "opacity-40 cursor-not-allowed" : "hover:bg-muted-foreground/5"}`}
+ >
+
+ {letter}
+ {option.text}
+
+ );
+ });
+
+ return (
+
+ {/* Assessment updated alert */}
+ {assessmentUpdatedAlert && (
+
+
+
+
Assessment Updated
+
+ Your administrator has made changes to this assessment. Your current session and saved answers are unaffected.
+
+
+
setAssessmentUpdatedAlert(false)}
+ className="text-blue-400 hover:text-blue-600 shrink-0 text-xs leading-none mt-0.5"
+ >✕
+
+ )}
+
+
+ {/* ── Left: question ── */}
+
+ {/* Question header */}
+
+ Question {currentIndex + 1} of {total}
+
+ {isMulti ? "Multiple select" : "Multiple choice"}
+
+
+
+ {/* Question card */}
+
+
+
+ {currentIndex + 1}. {question.question}
+
+
+ {isMulti
+ ? multiLimit
+ ? `Select ${multiLimit} answers`
+ : "Select all that apply"
+ : "Select one answer"}
+
+ {isMulti && multiLimit !== null && (
+
+ {limitReached
+ ? `${selectedCount} / ${multiLimit} selected — limit reached`
+ : `${selectedCount} / ${multiLimit} selected`}
+
+ )}
+
+
{optionRows}
+
+
+ {timeExpired && submitting && (
+
+ Time's up — submitting your answers…
+
+ )}
+
+ {/* Navigation */}
+
+
+
+ Previous
+
+
+ {submitting ? "Submitting…" : isLast ? "Review & submit" : "Next"}
+ {!submitting && }
+
+
+
+
+ {/* ── Right: sidebar ── */}
+
+ {/* Timer */}
+ {remainingSeconds !== null && (
+
+
Time Remaining
+
+ {timeExpired ? "00:00" : formatTime(remainingSeconds)}
+
+
+ )}
+
+ {/* Progress panel */}
+
+
+ Progress
+ {answeredCount} / {total} answered
+
+
+ {/* Number grid */}
+
+ {questions.map((q, i) => {
+ const ans = answers[q.question_id];
+ const answered = isQuestionAnswered(ans);
+ const isCurrent = i === currentIndex;
+ const canJump = answered || isCurrent;
+ return (
+ canJump && setCurrentIndex(i)}
+ disabled={(timeExpired && submitting)}
+ className={`aspect-square rounded text-xs font-medium transition-colors
+ ${isCurrent
+ ? "border-2 border-foreground bg-background text-foreground"
+ : answered
+ ? "bg-foreground text-background hover:opacity-80"
+ : "border border-border bg-background text-muted-foreground cursor-default"
+ }`}
+ >
+ {i + 1}
+
+ );
+ })}
+
+
+ {/* Legend */}
+
+
+
+ Answered
+
+
+
+ Not answered
+
+
+
+ Current
+
+
+
+ {/* Auto-save indicator */}
+
+
+ All answers saved automatically
+
+
+ {/* Review button */}
+
+ Review & submit
+
+ {reviewAttempted && unansweredCount > 0 && (
+
+ Answer all {total} questions first — {unansweredCount} remaining.
+
+ )}
+
+
+
+
+ );
+ }
+
+ // ── QUIZ TAKING (1-column with pills) ─────────────────────────────────────
return (
- {/* ── Assessment updated alert banner ── */}
- {assessmentUpdatedAlert && (
-
-
-
-
Assessment Updated
-
- Your administrator has made changes to this assessment. Your current session and saved answers are unaffected — continue as normal.
-
-
-
setAssessmentUpdatedAlert(false)}
- className="text-blue-400 hover:text-blue-600 shrink-0 text-xs leading-none mt-0.5"
- >✕
-
- )}
-
+ {/* Header: title + progress */}
- {quiz.title &&
{quiz.title} }
- {remainingSeconds !== null && (
-
- {timeExpired && submitting
- ? <> Time's up>
- : <> {formatTime(remainingSeconds)}>
- }
-
- )}
+ {quiz.title &&
{quiz.title} }
Question {currentIndex + 1} of {total}
@@ -481,12 +941,40 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
+ {/* Question number pills */}
+
+ {questions.map((q, i) => {
+ const ans = answers[q.question_id];
+ const answered = isQuestionAnswered(ans);
+ const isCurrent = i === currentIndex;
+ const canJump = answered || isCurrent;
+ return (
+ canJump && setCurrentIndex(i)}
+ disabled={timeExpired && submitting}
+ className={`size-9 rounded-full text-sm font-semibold transition-colors
+ ${isCurrent
+ ? "border-2 border-primary bg-background text-foreground"
+ : answered
+ ? "bg-foreground text-background hover:opacity-80"
+ : "bg-muted text-muted-foreground border border-border cursor-default"
+ }`}
+ >
+ {i + 1}
+
+ );
+ })}
+
+
{timeExpired && submitting && (
Time's up — submitting your answers…
)}
+ {/* Question card */}
@@ -502,7 +990,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
{(question.options ?? []).map((option, i) => {
- const letter = String.fromCharCode(65 + i);
+ const letter = String.fromCharCode(65 + i);
const isSelected = isMulti
? (selected ?? []).includes(option.option_id)
: selected === option.option_id;
@@ -522,7 +1010,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
${isDisabled ? "opacity-40 cursor-not-allowed" : "hover:bg-muted-foreground/5"}`}
>
- {letter}.
+ {letter}
{option.text}
);
@@ -530,15 +1018,23 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
-
-
-
- Previous
-
-
- {submitting ? "Submitting…" : isLast ? "Submit" : "Next"}
- {!isLast && !submitting && }
-
+ {/* Navigation */}
+
+
+
+
+ Previous
+
+
+ {submitting ? "Submitting…" : isLast ? "Review answers" : "Next"}
+ {!submitting && }
+
+
+ {reviewAttempted && unansweredCount > 0 && (
+
+ Answer all {total} questions first — {unansweredCount} remaining.
+
+ )}
);
diff --git a/src/modules/client/components/blocks/ReadCourse.jsx b/src/modules/client/components/blocks/ReadCourse.jsx
index 5b19a43..7f10e9e 100644
--- a/src/modules/client/components/blocks/ReadCourse.jsx
+++ b/src/modules/client/components/blocks/ReadCourse.jsx
@@ -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
Premium;
- if (tier === 'exclusive')
- return
Exclusive;
- if (!locked)
- return
Free;
- return null;
+ const { tierMap } = useClientTiers();
+ const { rank, label, cls } = resolveTierBadge(tier ?? 'free', tierMap);
+ if (rank === 0 && !locked) return null;
+ return (
+
+ {rank > 0 ? : }
+ {label}
+
+ );
}
-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 = [] }) => {
-
+ {
+ 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}
@@ -223,7 +241,19 @@ const ReadCourse = ({ title = "Read Course", courses = [] }) => {
setSelected(null)}>Cancel
{
- 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}
>
diff --git a/src/modules/client/components/blocks/ReadLesson.jsx b/src/modules/client/components/blocks/ReadLesson.jsx
index de43c15..f6c23c9 100644
--- a/src/modules/client/components/blocks/ReadLesson.jsx
+++ b/src/modules/client/components/blocks/ReadLesson.jsx
@@ -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 Premium ;
- if (tier === 'exclusive')
- return Exclusive ;
- return null;
+ const { tierMap } = useClientTiers();
+ const { rank, label, cls } = resolveTierBadge(tier ?? 'free', tierMap);
+ return (
+
+ {rank > 0 ? : }
+ {label}
+
+ );
}
const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId, taskId }) => {
@@ -156,6 +161,12 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
Lesson
+
+ {info?.unit?.course?.subscription
+ ?
+ : isFetching && Loading…
+ }
+
{/* Course breadcrumb */}
@@ -166,7 +177,7 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
)}
-
+
{lesson.title}
diff --git a/src/modules/client/components/blocks/ReadUnit.jsx b/src/modules/client/components/blocks/ReadUnit.jsx
index 5f9dd91..f6bfe8b 100644
--- a/src/modules/client/components/blocks/ReadUnit.jsx
+++ b/src/modules/client/components/blocks/ReadUnit.jsx
@@ -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 Premium ;
- if (tier === 'exclusive')
- return Exclusive ;
- return null;
+ const { tierMap } = useClientTiers();
+ const { rank, label, cls } = resolveTierBadge(tier ?? 'free', tierMap);
+ return (
+
+ {rank > 0 ? : }
+ {label}
+
+ );
}
const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskId }) => {
@@ -172,10 +177,36 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
Unit
+
+ {info?.course?.subscription
+ ?
+ : isFetching && Loading…
+ }
+
+ {info?.course?.title && (
+
+ from {info.course.title}
+
+ )}
+
-
+ {
+ 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}
diff --git a/src/modules/client/components/blocks/VisitLink.jsx b/src/modules/client/components/blocks/VisitLink.jsx
index 602bc3e..5fd7cb5 100644
--- a/src/modules/client/components/blocks/VisitLink.jsx
+++ b/src/modules/client/components/blocks/VisitLink.jsx
@@ -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 (
<>
{loading ? (
-
- ) : (
+
+ ) : displayImage ? (
{
- 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'; }}
/>
+ ) : (
+
+
{ e.currentTarget.style.display = 'none'; }}
+ />
+
{domain}
+
)}
@@ -89,9 +111,8 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
{visited ? (
-
-
- Visited
+ setViewModalOpen(true)}>
+ Visited
) : (
setModalOpen(true)}>
@@ -101,16 +122,15 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
+ {/* Turn-in modal — first visit */}
- setModalOpen(false)}>
- Cancel
-
+ setModalOpen(false)}>Cancel
{submitting ? "Submitting…" : "Turn In"}
@@ -121,12 +141,52 @@ const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
{link.url}
-
- Open Link
+ Open Link
+
+ {/* View modal — after visited, with Resubmit */}
+
+ setViewModalOpen(false)}>Close
+
+
+ Open Link
+
+
+
+ {unsubmitting ? "Removing…" : "Unsubmit"}
+
+ >
+ }
+ >
+
+ {displayImage ? (
+
{ e.currentTarget.style.display = 'none'; }}
+ />
+ ) : (
+
+
{ e.currentTarget.style.display = 'none'; }} />
+
{domain}
+
+ )}
+
+ Already submitted — you can unsubmit if needed.
+
+
{link.url}
+
+
>
);
};
@@ -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}
/>
))}
diff --git a/src/modules/client/layout/ClientLayout.jsx b/src/modules/client/layout/ClientLayout.jsx
index b9a10f5..e1a14a6 100644
--- a/src/modules/client/layout/ClientLayout.jsx
+++ b/src/modules/client/layout/ClientLayout.jsx
@@ -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() {
-
- {role.label}
-
+ {tierBadge && (
+
+ {tierBadge.label}
+
+ )}
diff --git a/src/modules/client/pages/AccountSettings.jsx b/src/modules/client/pages/AccountSettings.jsx
index ff07979..c4a532a 100644
--- a/src/modules/client/pages/AccountSettings.jsx
+++ b/src/modules/client/pages/AccountSettings.jsx
@@ -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 (
@@ -192,11 +202,11 @@ function SubscriptionSection() {
{payments.map((p) => (
- {new Date(p.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
+ {fmtDate(p.createdAt)}
{p.plan?.tier ?? "—"}
- {p.currency} {Number(p.amount ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}
+ {p.currency} {fmtNumber(p.amount ?? 0)}
@@ -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 (
+ <>
+
+
+
Delete account
+
+ Permanently remove your account and all associated data. This action cannot be undone.
+
+
+
setOpen(true)}
+ >
+
+ Delete account
+
+
+
+
+
+
+ Delete your account?
+
+ This will permanently delete your account and sign you out of all sessions.
+ Your data cannot be recovered after deletion.
+
+
+
+ Cancel
+
+ {loading ? "Deleting…" : "Yes, delete my account"}
+
+
+
+
+ >
+ );
+}
+
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function AccountSettings() {
@@ -292,6 +367,10 @@ export default function AccountSettings() {
+
+
);
diff --git a/src/modules/client/pages/Checkout.jsx b/src/modules/client/pages/Checkout.jsx
index 173c0ec..3992a22 100644
--- a/src/modules/client/pages/Checkout.jsx
+++ b/src/modules/client/pages/Checkout.jsx
@@ -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 = () => {
- {formatPrice(plan.price, plan.currency)}
+ {fmtCurrency(plan.price, plan.currency)}
@@ -312,12 +309,12 @@ const Checkout = () => {
Plan Price
- {formatPrice(subtotal, plan.currency)}
+ {fmtCurrency(subtotal, plan.currency)}
{isPromoApplied && (
Promo Discount (PHIL10)
- -{formatPrice(discount, plan.currency)}
+ -{fmtCurrency(discount, plan.currency)}
)}
@@ -348,7 +345,7 @@ const Checkout = () => {
Total
- {formatPrice(total, plan.currency)}
+ {fmtCurrency(total, plan.currency)}
{isCurrent ? (
@@ -366,7 +363,7 @@ const Checkout = () => {
?
:
}
- Pay {formatPrice(total, plan.currency)} with PayPal
+ Pay {fmtCurrency(total, plan.currency)} with PayPal
)}
diff --git a/src/modules/client/pages/CourseCheckout.jsx b/src/modules/client/pages/CourseCheckout.jsx
index 0d3557e..6088d93 100644
--- a/src/modules/client/pages/CourseCheckout.jsx
+++ b/src/modules/client/pages/CourseCheckout.jsx
@@ -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() {
Course Price
- {formatPrice(product.price, product.currency)}
+ {fmtCurrency(product.price, product.currency)}
Total
- {formatPrice(product.price, product.currency)}
+ {fmtCurrency(product.price, product.currency)}
{course?.has_purchased ? (
@@ -206,7 +202,7 @@ export default function CourseCheckout() {
?
:
}
- Pay {formatPrice(product.price, product.currency)} with PayPal
+ Pay {fmtCurrency(product.price, product.currency)} with PayPal
)}
diff --git a/src/modules/client/pages/CourseDetails.jsx b/src/modules/client/pages/CourseDetails.jsx
index 50ee118..d214052 100644
--- a/src/modules/client/pages/CourseDetails.jsx
+++ b/src/modules/client/pages/CourseDetails.jsx
@@ -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 = () => {
- {course?.plan_tier && course.plan_tier !== "free" ? (
-
- {course.plan_tier.charAt(0).toUpperCase() + course.plan_tier.slice(1)}
-
- ) : (
- Free
- )}
+ {(() => {
+ const slug = course?.plan_tier ?? course?.subscription ?? "free";
+ const { label, cls } = resolveTierBadge(slug, tierMap);
+ return {label} ;
+ })()}
{course?.title ?? "Course Title"}
{course?.description ?? ""}
diff --git a/src/modules/client/pages/CourseList.jsx b/src/modules/client/pages/CourseList.jsx
index c131cd8..b95d3bb 100644
--- a/src/modules/client/pages/CourseList.jsx
+++ b/src/modules/client/pages/CourseList.jsx
@@ -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 (
{
onClick={() => onViewDetails(course)}
>
- {type === "free" && (
-
- Free
-
- )}
- {type === "premium" && !locked && (
-
- Premium
-
- )}
- {type === "premium" && locked && (
-
- Premium
-
- )}
- {type === "exclusive" && !locked && (
-
- Exclusive
-
- )}
- {type === "exclusive" && locked && (
-
- Exclusive
-
- )}
+
+ {locked ? : }
+ {label}
+
{course.level && (
{course.level.charAt(0).toUpperCase() + course.level.slice(1)}
)}
@@ -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 === "..." ? (
···
) : (
-
onPageChange(page)}
- >
+ onPageChange(page)}>
{page}
)
@@ -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 (
@@ -282,39 +252,43 @@ const CoursesList = () => {
Advanced
+
{ setSubFilter(v); setCurrentPage(1); }}>
All
- Free
- Premium
+ {tierCategories.map((cat) => (
+
+ {cat.name}
+
+ ))}
- {/* Category chips */}
+ {/* Product category chips */}
{allCategories.length > 0 && (
-
- { setCategoryFilter("All"); setCurrentPage(1); }}
- >
- All
-
- {allCategories.map((cat) => (
- { setCategoryFilter(String(cat.id)); setCurrentPage(1); }}
- >
- {cat.name}
-
- ))}
-
+
+ { setCategoryFilter("All"); setCurrentPage(1); }}
+ >
+ All
+
+ {allCategories.map((cat) => (
+ { setCategoryFilter(String(cat.id)); setCurrentPage(1); }}
+ >
+ {cat.name}
+
+ ))}
+
)}
{/* Course Grid */}
@@ -332,7 +306,12 @@ const CoursesList = () => {
) : (
{paginated.map((course) => (
-
+
))}
)}
@@ -349,7 +328,7 @@ const CoursesList = () => {
- {/* Upsell Modal — only for locked courses */}
+ {/* Upsell Modal */}
{
<>
setModalOpen(false)}>Close
{selectedCourse?.product?.is_active && (
- { setModalOpen(false); navigate(`/course/${selectedCourse.course_id}/checkout`); }}
- >
-
- Buy {new Intl.NumberFormat("en-US", { style: "currency", currency: selectedCourse.product.currency ?? "USD" }).format(selectedCourse.product.price ?? 0)}
-
+ { setModalOpen(false); navigate(`/course/${selectedCourse.course_id}/checkout`); }}
+ >
+
+ Buy {fmtCurrency(selectedCourse.product.price ?? 0, selectedCourse.product.currency ?? "USD")}
+
)}
{ setModalOpen(false); navigate("/plans"); }}>
View Plans
@@ -374,47 +353,29 @@ const CoursesList = () => {
}
>
- {(selectedCourse?.plan_tier ?? "free") === "premium" && (
-
-
-
- Premium
-
-
-
- Lifetime access
- Downloadable resources
- Certificate of completion
-
-
- Upgrade to a Premium plan to unlock this course and all other premium content.
-
-
- )}
- {(selectedCourse?.plan_tier ?? "free") === "exclusive" && (
-
-
-
- Exclusive
-
-
-
-
- This is an exclusive course
-
-
- Only available to members with exclusive access
+ {upsellTier && !upsellTier.is_default && (() => {
+ const { cls, panel } = resolveTierBadge(selectedCourse?.subscription ?? "", tierMap);
+ return (
+
+
+
+ {upsellTier.name}
+
+
+
+ Access to {upsellTier.name} content
+ Certificates & achievements
+
+
+ Upgrade to a {upsellTier.name} plan to unlock this course.
-
- Exclusive courses are granted through special programs, partnerships, or limited enrollment offerings.
-
-
- )}
+ );
+ })()}
);
};
-export default CoursesList;
\ No newline at end of file
+export default CoursesList;
diff --git a/src/modules/client/pages/Dashboard.jsx b/src/modules/client/pages/Dashboard.jsx
index 4809584..51158a0 100644
--- a/src/modules/client/pages/Dashboard.jsx
+++ b/src/modules/client/pages/Dashboard.jsx
@@ -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 (
{
onClick={() => onViewDetails(course)}
>
- {type === "free" && (
-
- Free
-
- )}
- {type === "premium" && (
-
- {locked ? : } Premium
-
- )}
- {type === "exclusive" && (
-
- Exclusive
-
- )}
+
+ {rank > 0 || locked ? : }
+ {label}
+
{course.level && (
{course.level.charAt(0).toUpperCase() + course.level.slice(1)}
@@ -127,20 +121,88 @@ const CourseCardSkeleton = () => (
);
+// ─── Groups Table ─────────────────────────────────────────────────────────────
+
+const GroupsTable = ({ groups, onView }) => (
+
+
+
+
+ #
+ Group Name
+ Code
+ Description
+
+
+
+
+ {groups.map((g, i) => {
+ const isDefault = g.group_code === 'NOGRP';
+ return (
+
+
+ {i + 1}
+
+ {g.name}
+
+ {g.group_code}
+
+
+ {isDefault
+ ? Awaiting assignment by admin
+ : (g.description ?? — )
+ }
+
+
+ onView(g)}>
+ View
+
+
+
+ );
+ })}
+
+
+
+);
+
+const GroupsTableSkeleton = () => (
+
+
+
+
+ #
+ Group Name
+ Code
+ Description
+
+
+
+
+ {[1, 2].map((i) => (
+
+
+
+
+
+
+
+ ))}
+
+
+
+);
+
// ─── 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:
, to: `` },
- { label: "Statistics" },
+ { label: "My Groups", icon:
},
];
// ── 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 (
@@ -260,52 +283,26 @@ const Client = () => {
)}
- {/* ── Group affiliated ── */}
-
-
-
-
- {groupLoading ? (
-
- ) : (
-
{myGroup?.name ?? 'No Group'}
- )}
- myGroup && navigate(`/group/${myGroup.group_id}`)}
- disabled={!myGroup}
- >
- View
-
-
-
-
-
-
-
-
-
Completed Tasks
-
- {groupLoading ? (
-
- ) : (
-
{completedCount}
- )}
-
-
-
-
-
-
-
Due soon
-
- {groupLoading ? (
-
- ) : (
-
{dueSoonCount}
- )}
-
-
+ {/* ── My Groups ── */}
+
+
+
+ {!groupLoading && groups.length > 0 && (
+
+ {groups.length} group{groups.length !== 1 ? 's' : ''}
+
+ )}
+ {groupLoading ? (
+
+ ) : groups.length === 0 ? (
+
You are not assigned to any group.
+ ) : (
+
navigate(`/group/${g.group_id}`)}
+ />
+ )}
{/* ── Featured Courses (first 3) ── */}
@@ -363,43 +360,27 @@ const Client = () => {
}
>
- {(selectedCourse?.plan_tier ?? "free") === "premium" && (
-
-
-
- Premium
-
-
-
- Lifetime access
- Downloadable resources
- Certificate of completion
-
-
- Upgrade to a Premium plan to unlock this course and all other premium content.
-
-
- )}
- {(selectedCourse?.plan_tier ?? "free") === "exclusive" && (
-
-
-
- Exclusive
-
-
-
-
- This is an exclusive course
-
-
- 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 (
+
+
+
+ {label}
+
+
+
+ Access to {label} content
+ Certificates & achievements
+
+
+ Upgrade to a {label} plan to unlock this course.
-
- Exclusive courses are granted through special programs, partnerships, or limited enrollment offerings.
-
-
- )}
+ );
+ })()}
diff --git a/src/modules/client/pages/MyAchievements.jsx b/src/modules/client/pages/MyAchievements.jsx
index 4676d3a..28edba6 100644
--- a/src/modules/client/pages/MyAchievements.jsx
+++ b/src/modules/client/pages/MyAchievements.jsx
@@ -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() {
{item.description}
{item.granted_at && (
- {new Date(item.granted_at).toLocaleDateString("en-US", {
- month: "long", day: "numeric", year: "numeric",
- })}
+ {fmtDate(item.granted_at)}
)}
diff --git a/src/modules/client/pages/MyCertificates.jsx b/src/modules/client/pages/MyCertificates.jsx
index 80543ac..11c690c 100644
--- a/src/modules/client/pages/MyCertificates.jsx
+++ b/src/modules/client/pages/MyCertificates.jsx
@@ -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);
diff --git a/src/modules/client/pages/PlanList.jsx b/src/modules/client/pages/PlanList.jsx
index 799a423..812d55a 100644
--- a/src/modules/client/pages/PlanList.jsx
+++ b/src/modules/client/pages/PlanList.jsx
@@ -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 }) => {
- {formatPrice(plan.price, plan.currency)}
+ {fmtCurrency(plan.price, plan.currency)}
{duration && (
/ {duration}
@@ -170,15 +172,16 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund }) => {
>
View Details
- {isCurrent ? (
+ {isCurrent && refundSecsLeft > 0 ? (
onRefund(plan)}
>
- Refund
+
+ Refund ({formatCountdown(refundSecsLeft)})
- ) : (
+ ) : !isCurrent ? (
{
>
{plan.tier === "free" ? "Current" : `Get ${style.label}`}
- )}
+ ) : null}
);
@@ -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() {
{/* Advertisement Banner */}
-
+ {/*
@@ -251,10 +273,10 @@ export default function PlanList() {
-
+ */}
{/* Section Header */}
-
+
Available Plans
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() {
{refundLoading ? "Processing..." : "Confirm Refund"}
@@ -322,30 +345,41 @@ export default function PlanList() {
Refund amount
- {refundPlan ? formatPrice(refundPlan.price, refundPlan.currency) : "—"}
+ {refundPlan ? fmtCurrency(refundPlan.price, refundPlan.currency) : "—"}
{myTier?.expires_at && (
Access until
- {new Date(myTier.expires_at).toLocaleDateString("en-US", {
- month: "long", day: "numeric", year: "numeric",
- })}
+ {fmtDate(myTier.expires_at)}
)}
+
+ Refund window
+ {refundSecsLeft > 0 ? (
+
+ {formatCountdown(refundSecsLeft)} remaining
+
+ ) : (
+ Expired
+ )}
+
-
- Your refund will be processed through PayPal. You will retain access to your current plan until{" "}
-
- {myTier?.expires_at
- ? new Date(myTier.expires_at).toLocaleDateString("en-US", {
- month: "long", day: "numeric", year: "numeric",
- })
- : "the end of the billing period"}
- .
-
+ {refundSecsLeft > 0 ? (
+
+ Your refund will be processed through PayPal.{" "}
+
+ Access will be revoked immediately
+ {" "}
+ and your account will be downgraded to Free.
+
+ ) : (
+
+ The 5-minute refund window has expired. Refunds are no longer available for this payment.
+
+ )}
diff --git a/src/modules/client/pages/Profile.jsx b/src/modules/client/pages/Profile.jsx
index bf3b4a1..4e9c137 100644
--- a/src/modules/client/pages/Profile.jsx
+++ b/src/modules/client/pages/Profile.jsx
@@ -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
;
+ if (badge?.icon) {
+ const Icon = LucideIcons[badge.icon];
+ if (!Icon) return null;
+ const swatch = getTierColor(badge.colorKey ?? "green").swatch;
+ return
;
+ }
+ 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 = () => {
{ setSelectedBadge(getEarlyAccessBadge()); setBadgeOpen(true); }}
/>
-
{EARLY_ACCESS_BADGE.label}
+
{earlyAccessBadge.label}
)}
@@ -276,7 +316,7 @@ const ProfilePage = () => {
{ setSelectedBadge(getBadgeWithDate("premium_first_time", "premium")); setBadgeOpen(true); }}
/>
@@ -291,7 +331,7 @@ const ProfilePage = () => {
{ setSelectedBadge(getBadgeWithDate("exclusive_first_time", "exclusive")); setBadgeOpen(true); }}
/>
@@ -305,12 +345,9 @@ const ProfilePage = () => {
{userRank === 0 && (
- { setSelectedBadge(tierBadge); setBadgeOpen(true); }}
- />
+ { setSelectedBadge(tierBadge); setBadgeOpen(true); }}>
+
+
{tierBadge.label}
@@ -320,12 +357,9 @@ const ProfilePage = () => {
{userRank === 1 && !hasPremiumBadge && (
- { setSelectedBadge(getActiveTierBadgeWithDate()); setBadgeOpen(true); }}
- />
+ { setSelectedBadge(getActiveTierBadgeWithDate()); setBadgeOpen(true); }}>
+
+
{tierBadge.label}
@@ -363,8 +397,8 @@ const ProfilePage = () => {
}
>
- {selectedBadge?.src && (
-
+ {(selectedBadge?.src || selectedBadge?.icon) && (
+
)}
{selectedBadge?.label}
@@ -530,9 +564,7 @@ const ProfilePage = () => {
Member since
- {profile?.createdAt
- ? new Date(profile.createdAt).toLocaleDateString("en-US", { month: "long", year: "numeric" })
- : "—"}
+ {profile?.createdAt ? fmtDate(profile.createdAt) : "—"}
@@ -542,9 +574,7 @@ const ProfilePage = () => {
) : (
- {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)}`}
)}
@@ -638,9 +668,7 @@ const ProfilePage = () => {
{item.description}
{item.granted_at && (
- {new Date(item.granted_at).toLocaleDateString("en-US", {
- month: "long", day: "numeric", year: "numeric",
- })}
+ {fmtDate(item.granted_at)}
)}
diff --git a/src/modules/client/pages/UnitList.jsx b/src/modules/client/pages/UnitList.jsx
index eb572c1..48fc183 100644
--- a/src/modules/client/pages/UnitList.jsx
+++ b/src/modules/client/pages/UnitList.jsx
@@ -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 (
+
+
+
+
+
+ Complete Previous {previousQuizzes.length > 1 ? "Quizzes" : "Quiz"} First
+
+
+ You must pass the required {previousQuizzes.length > 1 ? "quizzes" : "quiz"} before you can take this one.
+
+
+ {useModal ? (
+ <>
+
{passed} of {previousQuizzes.length} quizzes passed
+
setOpen(true)}>
+
+ View Required Quizzes
+
+
+
+ setOpen(false)}
+ />
+
+
+ >
+ ) : (
+
+
+
+ )}
+
+ );
+};
// ─── 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,
}) => (
@@ -162,24 +220,31 @@ const SidebarContent = ({
);
})}
- {unit.quiz && (
- 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
- ?
- :
- }
- {unit.quiz.title || "Quiz"}
-
- )}
+ {unit.quiz && (() => {
+ const quizLocked = isQuizLocked?.(unit);
+ return (
+ 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
+ ?
+ : quizLocked
+ ?
+ :
+ }
+ {unit.quiz.title || "Quiz"}
+
+ );
+ })()}
@@ -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 (
<>
+
+ {/* ── Session-guard dialog — shown when user tries to navigate away mid-quiz ── */}
+ !open && handleCancelNav()}
+ title={`Leave ${selectedAssessment ? "Assessment" : "Quiz"}?`}
+ description=""
+ footer={
+ <>
+
+ Stay
+
+
+ Leave Anyway
+
+ >
+ }
+ >
+
+
+ You have an ongoing {selectedAssessment ? "assessment" : "quiz"} session in progress.
+ Leaving now will not submit your answers — your session will remain open and the administrator can see it.
+
+ {selectedAssessment && (
+
+ Your assessment timer will keep counting while you're away.
+
+ )}
+
+
+
+ {/* ── Task-mode banner ─────────────────────────────────────────── */}
+ {taskCtx?.has_task && (
+
+
+
+ {course?.is_completed
+ ? 'Course complete — tracking finished'
+ : 'Task mode — reading progress is being tracked automatically'
+ }
+
+
+ )}
+
{/* ── Up next floating button (lesson only — quizzes/assessments have their own bottom controls) ── */}
{scrollProgress >= 100 && nextContent && !selectedQuizId && !selectedAssessment && (
@@ -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}
/>
@@ -599,7 +861,7 @@ const UnitList = () => {
{/* ── Desktop sidebar ── */}
{desktopSidebarOpen && (
-
+
{
)}
{/* ── Main content ── */}
-
+
{selectedCompletion ? (
@@ -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 ? (
-
{
- const result = await submitUnitQuiz(courseId, selectedUnitId, selectedQuizId, answers);
- await getCourse(courseId);
- return result;
- }}
- onRetake={() => getUnitQuiz(courseId, selectedUnitId)}
- />
+ lockedQuizUnitIds.has(selectedUnitId) ? (
+
+ ) : (
+ {
+ const result = await submitUnitQuiz(courseId, selectedUnitId, selectedQuizId, answers);
+ await getCourse(courseId);
+ return result;
+ }}
+ onRetake={() => getUnitQuiz(courseId, selectedUnitId)}
+ onActiveChange={setQuizActive}
+ />
+ )
) : (
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 = () => {
{plan.label}
- {formatPrice(plan.price, plan.currency)}
+ {fmtCurrency(plan.price, plan.currency)}
{duration && (
@@ -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)}
)}
diff --git a/src/modules/client/pages/ViewRequirement.jsx b/src/modules/client/pages/ViewRequirement.jsx
index 16aaf37..e8a6291 100644
--- a/src/modules/client/pages/ViewRequirement.jsx
+++ b/src/modules/client/pages/ViewRequirement.jsx
@@ -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 (
+
+ {label}
+
+ );
+};
// ─── 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 (
-
-
- Requirements
-
- g.type)}
- className="space-y-1"
- >
+
+
+
+
+ Requirements
+
+ {groups.length === 1 && (
+
+ {TYPE_LABEL[groups[0].type]}
+
+ )}
+
+
{groups.map(({ type, items }) => (
-
-
- {TYPE_LABEL[type]}
-
-
-
- {items.map((req) => {
- const Icon = TYPE_ICON[req.type] ?? FileText;
- const isActive = selection?.reqId === req.requirement_id;
+
+ {/* Section heading — hidden when only one type is visible (entry-scoped) */}
+ {groups.length > 1 && (
+
+ {TYPE_LABEL[type]}
+
+ )}
- 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 (
+
+ {/* Requirement button */}
+
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
+ ?
+ :
+ }
+
+ {req.reference_label ?? req.type}
+
+
+
+
+ {/* Breadcrumb — always visible below the button */}
+ {type === 'read_unit' && meta?.courseTitle && (
+
+ from {meta.courseTitle}
+
+ )}
+ {type === 'read_lesson' && (meta?.unitTitle || meta?.courseTitle) && (
+
+ {[meta.unitTitle, meta.courseTitle].filter(Boolean).join(' › ')}
+
+ )}
+
+ {/* 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 (
-
- {/* Unit header row (non-clickable — navigates via lessons) */}
-
- {done
- ?
- :
- }
-
-
- {req.reference_label ?? 'Unit'}
-
-
-
- {/* Lessons sub-list */}
+
{loading && (
-
+
)}
{lessons.map((lesson, i) => {
- const lessonActive =
- isActive && selection?.lessonUuid === lesson.uuid;
+ const active = selection?.lessonUuid === lesson.uuid;
return (
-
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'
}`}
>
-
- {i + 1}
-
+ {i + 1}
{lesson.title}
-
+
);
})}
-
+
);
- }
+ })()}
- // ── read_course / read_lesson ─────────────────────────
- const done = isCompleted(req.requirement_id, req.reference_id);
- return (
-
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
- ?
- :
- }
-
- {req.reference_label ?? req.type}
-
- );
- })}
-
-
-
+ {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 (
+
+ {loading && (
+
+
+
+
+ )}
+ {units.map((unit) => (
+
+
+
+ {unit.title}
+
+ {(unit.lessons ?? []).map((lesson, lIdx) => {
+ const active = selection?.lessonUuid === lesson.uuid;
+ return (
+
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'
+ }`}
+ >
+ {lIdx + 1}
+ {lesson.title}
+
+ );
+ })}
+ {unit.quiz && (
+
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"
+ >
+
+ {unit.quiz.title || 'Quiz'}
+ {unit.quiz.has_passed
+ ?
+ :
+ }
+
+ )}
+
+ ))}
+ {assessment && (
+
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"
+ >
+
+ {assessment.title || 'Final Assessment'}
+ {assessment.has_passed
+ ?
+ :
+ }
+
+ )}
+
+ );
+ })()}
+
+ );
+ })}
+
))}
-
-
+
);
};
-// ─── 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 ;
- if (locked) return ;
+// ─── Course overview (before first lesson selected) ───────────────────────────
+const CourseOverview = ({ meta }) => {
+ const { tierMap } = useClientTiers();
return (
-
-
-
- {info?.subscription && {info.subscription} }
- {info?.level && {info.level} }
-
-
{req.reference_label ?? 'Course'}
-
- {info?.description && (
-
{info.description}
- )}
+
+
+ {meta?.subscription && (() => {
+ const { rank, label, cls } = resolveTierBadge(meta.subscription, tierMap);
+ return (
+
+ {rank > 0 ? : }
+ {label}
+
+ );
+ })()}
+ {meta?.level && {meta.level} }
+
{meta?.title ?? 'Course'}
+ {meta?.description &&
{meta.description}
}
+
Select a lesson from the sidebar to begin reading.
+
);
};
-// ─── 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
;
+ return
;
+};
+
+// ─── 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
;
};
-// ─── Loading skeleton ─────────────────────────────────────────────────────────
+// ─── Skeletons / locked ───────────────────────────────────────────────────────
const ContentSkeleton = () => (
@@ -225,7 +327,6 @@ const ContentSkeleton = () => (
);
-// ─── Locked content placeholder ───────────────────────────────────────────────
const LockedContent = () => {
const navigate = useNavigate();
return (
@@ -236,16 +337,14 @@ const LockedContent = () => {
Premium / Exclusive Content
- 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.
navigate('/plans')} className="gap-1.5">
View Available Plans
-
- Already subscribed? Your plan may not cover this tier.
-
+
Already subscribed? Your plan may not cover this tier.
);
@@ -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 (
<>
- {/* ── Floating "up next" (within unit lessons) ─────────────────── */}
+
+ {/* ── Floating "up next" ────────────────────────────────────────── */}
{scrollPct >= 100 && nextLesson && (
{
{/* ── Desktop sidebar ───────────────────────────────────────────── */}
{desktopOpen && (
-
+
)}
{/* ── Main content ──────────────────────────────────────────────── */}
-
+
{loading ? (
@@ -548,48 +731,35 @@ const ViewRequirement = () => {
) : (
- {/* Content per type */}
{selectedReq.type === 'read_course' && (
-
+ lockedReqs.has(selectedReq.requirement_id) ?
:
+ selection?.lessonUuid ?
:
+ courseLoadingMap[selectedReq.requirement_id] ?
:
+
)}
{selectedReq.type === 'read_unit' && (
- lockedReqs.has(selectedReq.requirement_id)
- ?
- : selectedLesson
- ?
- :
+ lockedReqs.has(selectedReq.requirement_id) ?
:
+ selectedLesson ?
:
+
)}
{selectedReq.type === 'read_lesson' && (
-
+
+ setReferenceMetaMap((p) => ({ ...p, [reqId]: meta }))
+ }
+ />
)}
- {/* Turn-in footer — hidden for locked requirements */}
- {!lockedReqs.has(selectedReq.requirement_id) && (
-
- {selectedDone ? (
- <>
-
- You have completed this requirement.
-
-
-
- Mark as not done
-
- >
- ) : nextLesson ? (
-
- Continue reading all lessons to complete this requirement.
-
+ {/* Turn-in footer — only shown while not yet completed */}
+ {!lockedReqs.has(selectedReq.requirement_id) && !selectedDone && (
+
+ {nextLesson ? (
+
Continue reading all lessons to complete this requirement.
) : !canMarkDone ? (
-
- Scroll to the end to complete this requirement.
-
+
Scroll to the end to complete this requirement.
) : (
Turning in…
diff --git a/src/modules/client/pages/ViewTask.jsx b/src/modules/client/pages/ViewTask.jsx
index 46bccd4..41853f5 100644
--- a/src/modules/client/pages/ViewTask.jsx
+++ b/src/modules/client/pages/ViewTask.jsx
@@ -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}
/>
)}
diff --git a/src/modules/public/pages/Suspended.jsx b/src/modules/public/pages/Suspended.jsx
new file mode 100644
index 0000000..b4b83b3
--- /dev/null
+++ b/src/modules/public/pages/Suspended.jsx
@@ -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 (
+
+
+
+
+
+
+
+
+
Account Suspended
+
+ Your account has been suspended and you cannot access the platform at this time.
+
+
+
+ {(reason || expiryText) && (
+
+ {reason && (
+
+ )}
+ {banType === 'temporary' && expiryText && (
+
+
Suspended Until
+
{expiryText}
+
+ )}
+ {banType === 'permanent' && (
+
+ )}
+
+ )}
+
+
+ If you believe this is a mistake, please contact your administrator.
+
+
+
+ Back to Login
+
+
+
+
+ )
+}
diff --git a/src/modules/staff/components/MemberDetailTabs.jsx b/src/modules/staff/components/MemberDetailTabs.jsx
index aaab631..3a6dddd 100644
--- a/src/modules/staff/components/MemberDetailTabs.jsx
+++ b/src/modules/staff/components/MemberDetailTabs.jsx
@@ -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 }) {
-
+
-
+
diff --git a/src/modules/staff/components/TaskListDetail.jsx b/src/modules/staff/components/TaskListDetail.jsx
index 943a6a1..dee7427 100644
--- a/src/modules/staff/components/TaskListDetail.jsx
+++ b/src/modules/staff/components/TaskListDetail.jsx
@@ -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 */}
-
Created: {formatDate(taskList.createdAt)}
-
Assigned: {formatDate(taskList.TaskListGroup?.assignedAt)}
+
Created: {fmtDate(taskList.createdAt)}
+
Assigned: {fmtDate(taskList.TaskListGroup?.assignedAt)}
@@ -195,7 +189,7 @@ export default function TaskListDetail({ taskList }) {
- {formatDate(task.due_date)}
+ {fmtDate(task.due_date)}
))
diff --git a/src/modules/staff/components/TaskListsTab.jsx b/src/modules/staff/components/TaskListsTab.jsx
index 39e9686..2188d1e 100644
--- a/src/modules/staff/components/TaskListsTab.jsx
+++ b/src/modules/staff/components/TaskListsTab.jsx
@@ -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 = [] }) {
- {formatDate(tl.TaskListGroup?.assignedAt)}
+ {fmtDate(tl.TaskListGroup?.assignedAt)}
-
Created {formatDate(group.createdAt)}
-
Updated {formatDate(group.updatedAt)}
+
Created {fmtDate(group.createdAt)}
+
Updated {fmtDate(group.updatedAt)}
diff --git a/src/utils/datetime.util.js b/src/utils/datetime.util.js
new file mode 100644
index 0000000..428583f
--- /dev/null
+++ b/src/utils/datetime.util.js
@@ -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 });
+}
diff --git a/src/utils/tierBadge.util.js b/src/utils/tierBadge.util.js
new file mode 100644
index 0000000..5921b5d
--- /dev/null
+++ b/src/utils/tierBadge.util.js
@@ -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;
+}
diff --git a/src/utils/tierColors.js b/src/utils/tierColors.js
new file mode 100644
index 0000000..801a20b
--- /dev/null
+++ b/src/utils/tierColors.js
@@ -0,0 +1,100 @@
+/**
+ * Official Tailwind color palette for tier categories.
+ *
+ * Each key is stored in tier_categories.color (DB).
+ * badge — classes applied to
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;
+}