@@ -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.
+
+
+
+
+
+
+
+
+ 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 === "..." ? (
···
) : (
-
- {/* 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;
+}
|