diff --git a/src/modules/admin/pages/tiers/ViewPlan.jsx b/src/modules/admin/pages/tiers/ViewPlan.jsx
index ed1ebea..80b1365 100644
--- a/src/modules/admin/pages/tiers/ViewPlan.jsx
+++ b/src/modules/admin/pages/tiers/ViewPlan.jsx
@@ -2,7 +2,7 @@ import { useEffect, useState, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft, CreditCard, Pencil, Tag, BadgeCheck, BookOpen, Clock,
- ShieldCheck, Plus, Trash2, Loader2, Receipt,
+ ShieldCheck, Plus, Trash2, Loader2, Receipt, KeyRound, Users, Lock,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -481,6 +481,241 @@ function PaymentPolicyTab({ planId, plan }) {
);
}
+// ─── Tab: Access Rules ─────────────────────────────────────────────────────────
+// Configures plan_policies.access_rules — evaluateCourseAccess (utils/accessPolicy.util.js)
+// reads these instead of falling back to plain tier-rank comparison once any
+// rule exists here. Empty (default) = unchanged rank-comparison behavior.
+
+const RULE_TYPES = [
+ { value: "course_subscription_access", label: "Allowed subscription levels", icon: Tag,
+ description: "Only grant access to courses at these subscription levels." },
+ { value: "required_active_tier", label: "Required active tier", icon: KeyRound,
+ description: "User's active tier must be at least this rank." },
+ { value: "group_restriction", label: "Group restriction", icon: Users,
+ description: "User must belong to at least one of these groups." },
+];
+
+function ruleSummary(rule, tierCategories, groups) {
+ if (rule.type === "course_subscription_access") {
+ const names = (rule.levels ?? []).map((slug) => tierCategories.find((c) => c.slug === slug)?.name ?? slug);
+ return `Allowed levels: ${names.join(", ") || "—"}`;
+ }
+ if (rule.type === "required_active_tier") {
+ return `Requires active tier: ${tierCategories.find((c) => c.slug === rule.tier)?.name ?? rule.tier}`;
+ }
+ if (rule.type === "group_restriction") {
+ const names = (rule.group_ids ?? []).map((id) => groups.find((g) => String(g.group_id) === String(id))?.name ?? id);
+ return `Restricted to groups: ${names.join(", ") || "—"}`;
+ }
+ return rule.type;
+}
+
+function AccessRulesTab({ planId }) {
+ const [rules, setRules] = useState([]);
+ const [rulesLoading, setRulesLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [tierCategories, setTierCategories] = useState([]);
+ const [groups, setGroups] = useState([]);
+ const [showAdd, setShowAdd] = useState(false);
+ const [newType, setNewType] = useState("course_subscription_access");
+ const [newLevels, setNewLevels] = useState([]);
+ const [newTier, setNewTier] = useState("");
+ const [newGroupIds, setNewGroupIds] = useState([]);
+
+ useEffect(() => {
+ setRulesLoading(true);
+ api.get(`/admin/tier-policies/plans/${planId}/policy`)
+ .then(({ data }) => setRules(data.data?.access_rules ?? []))
+ .catch(() => {})
+ .finally(() => setRulesLoading(false));
+
+ api.get("/admin/tiers/categories").then(({ data }) => setTierCategories(data.data ?? [])).catch(() => {});
+ api.get("/admin/groups", { params: { limit: 100 } })
+ .then(({ data }) => setGroups(data.data?.data ?? []))
+ .catch(() => {});
+ }, [planId]);
+
+ const handleSave = async (next) => {
+ setSaving(true);
+ try {
+ await api.put(`/admin/tier-policies/plans/${planId}/policy`, { access_rules: next });
+ setRules(next);
+ toast("Access rules saved.");
+ } catch (err) {
+ toast(err?.response?.data?.message ?? "Could not save access rules.");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const resetAddForm = () => {
+ setShowAdd(false);
+ setNewType("course_subscription_access");
+ setNewLevels([]);
+ setNewTier("");
+ setNewGroupIds([]);
+ };
+
+ const handleAddRule = () => {
+ let rule;
+ if (newType === "course_subscription_access") {
+ if (!newLevels.length) { toast("Select at least one subscription level."); return; }
+ rule = { type: newType, levels: newLevels };
+ } else if (newType === "required_active_tier") {
+ if (!newTier) { toast("Select a required tier."); return; }
+ rule = { type: newType, tier: newTier };
+ } else {
+ if (!newGroupIds.length) { toast("Select at least one group."); return; }
+ rule = { type: newType, group_ids: newGroupIds.map(Number) };
+ }
+ handleSave([...rules, rule]);
+ resetAddForm();
+ };
+
+ const handleRemoveRule = (index) => {
+ handleSave(rules.filter((_, i) => i !== index));
+ };
+
+ if (rulesLoading) {
+ return (
+
+ {[...Array(2)].map((_, i) => )}
+
+ );
+ }
+
+ return (
+
+
+ {rules.length > 0 ? (
+
+ {rules.map((rule, i) => {
+ const meta = RULE_TYPES.find((t) => t.value === rule.type);
+ const Icon = meta?.icon ?? Lock;
+ return (
+
+
+
+ {ruleSummary(rule, tierCategories, groups)}
+
+
handleRemoveRule(i)}
+ >
+
+
+
+ );
+ })}
+
+ ) : (
+
+
+ No access rules configured — falls back to plain tier-rank comparison.
+
+ )}
+
+ {showAdd ? (
+
+
New Access Rule
+
+
+
Rule Type
+
+
+
+ {RULE_TYPES.map((t) => (
+ {t.label}
+ ))}
+
+
+
+ {RULE_TYPES.find((t) => t.value === newType)?.description}
+
+
+
+ {newType === "course_subscription_access" && (
+
+
Allowed Levels
+
+ {tierCategories.map((c) => (
+ setNewLevels((prev) =>
+ prev.includes(c.slug) ? prev.filter((s) => s !== c.slug) : [...prev, c.slug]
+ )}
+ >
+ {c.name}
+
+ ))}
+
+
+ )}
+
+ {newType === "required_active_tier" && (
+
+ Required Tier
+
+
+
+ {tierCategories.map((c) => (
+ {c.name}
+ ))}
+
+
+
+ )}
+
+ {newType === "group_restriction" && (
+
+
Groups
+
+ {groups.map((g) => (
+
setNewGroupIds((prev) =>
+ prev.includes(String(g.group_id))
+ ? prev.filter((id) => id !== String(g.group_id))
+ : [...prev, String(g.group_id)]
+ )}
+ >
+ {g.name}
+
+ ))}
+ {groups.length === 0 && (
+
No groups found.
+ )}
+
+
+ )}
+
+
+
Cancel
+
+ Add Rule
+
+
+
+ ) : (
+ setShowAdd(true)}>
+ Add Access Rule
+
+ )}
+
+
+ );
+}
+
// ─── Tab: Payments ─────────────────────────────────────────────────────────────
function PaymentsTab({ planId }) {
@@ -497,6 +732,7 @@ function PaymentsTab({ planId }) {
const TABS = [
{ key: "details", label: "Plan Details", icon: CreditCard },
+ { key: "access", label: "Access Rules", icon: Lock },
{ key: "policy", label: "Payment Policy", icon: ShieldCheck },
{ key: "payments", label: "Payments", icon: Receipt },
];
@@ -601,6 +837,9 @@ export default function ViewPlan() {
coursesLoading={coursesLoading}
/>
)}
+ {activeTab === "access" && (
+
+ )}
{activeTab === "policy" && (
)}
diff --git a/src/modules/admin/routes/AdminRoutes.jsx b/src/modules/admin/routes/AdminRoutes.jsx
index df90a57..31200e3 100644
--- a/src/modules/admin/routes/AdminRoutes.jsx
+++ b/src/modules/admin/routes/AdminRoutes.jsx
@@ -93,17 +93,17 @@ import AddCategory from '../pages/categories/AddCategory';
import EditCategory from '../pages/categories/EditCategory';
// Tiers
-import PlanList from '../pages/tiers/PlanList';
-import AddPlan from '../pages/tiers/AddPlan';
-import ViewPlan from '../pages/tiers/ViewPlan';
-import EditPlan from '../pages/tiers/EditPlan';
-import SystemBadges from '../pages/tiers/SystemBadges';
-import UserTierList from '../pages/tiers/UserTierList';
-import PaymentList from '../pages/tiers/PaymentList';
-import ViewPayment from '../pages/tiers/ViewPayment';
+import PlanList from '../pages/tiers/PlanList';
+import AddPlan from '../pages/tiers/AddPlan';
+import ViewPlan from '../pages/tiers/ViewPlan';
+import EditPlan from '../pages/tiers/EditPlan';
+import SystemBadges from '../pages/tiers/SystemBadges';
+import UserTierList from '../pages/tiers/UserTierList';
+import PaymentList from '../pages/tiers/PaymentList';
+import ViewPayment from '../pages/tiers/ViewPayment';
import TierCategories from '../pages/tiers/TierCategories';
import ArchivedPlanList from '../pages/tiers/ArchivedPlanList';
-import PaymentPolicy from '../pages/tiers/PaymentPolicy';
+import PaymentPolicy from '../pages/tiers/PaymentPolicy';
import { AddTierCategory, EditTierCategory } from '../pages/tiers/EditTierCategory';
import TaskSubmissions from '../pages/task_list/task/TaskCompletion'
import ViewTaskCompletion from '../pages/task_list/task/ViewTaskCompletion'
@@ -130,8 +130,9 @@ import EditNotificationTemplate from '../pages/notifications/EditNotificationTem
import ArchivedNotificationBroadcastList from '../pages/notifications/ArchivedNotificationBroadcastList'
// Activity
-import ActivityFeed from '../pages/activity/ActivityFeed'
+import ActivityFeed from '../pages/activity/ActivityFeed'
import UserActivityPage from '../pages/activity/UserActivityPage'
+import ResourceList from '../pages/resources/ResourceList'
@@ -186,6 +187,14 @@ export const AdminRoutes = {
],
},
+ {
+ path: 'resources',
+ element:
,
+ children: [
+ { index: true, element:
},
+ ],
+ },
+
// Courses
{
path: 'courses',
@@ -314,8 +323,8 @@ export const AdminRoutes = {
{ index: true, element:
},
{ path: 'add', element:
},
{ path: 'archived', element:
},
- { path: ':planId/view', element:
},
- { path: ':planId/edit', element:
},
+ { path: ':planId/view', element:
},
+ { path: ':planId/edit', element:
},
{ path: ':planId/payment-policy', element:
},
]
},
@@ -324,8 +333,8 @@ export const AdminRoutes = {
path: 'categories',
element:
,
children: [
- { index: true, element:
},
- { path: 'add', element:
},
+ { index: true, element:
},
+ { path: 'add', element:
},
{ path: ':id/edit', element:
},
],
},
@@ -364,14 +373,35 @@ export const AdminRoutes = {
path: 'achievements',
element:
,
children: [
- { index: true, element:
},
- { path: 'add', element:
},
+ { index: true, element:
},
+ { path: 'add', element:
},
{ path: ':id/edit', element:
},
]
},
- // Notifications
+ // Announcements (admin-authored broadcasts)
+ {
+ path: 'announcements',
+ element:
,
+ children: [
+ { index: true, element:
},
+ { path: 'archived', element:
},
+ { path: 'add', element:
},
+ { path: 'settings', element:
},
+ { path: ':broadcastId/view', element:
},
+ { path: ':broadcastId/edit', element:
},
+ ]
+ },
+ {
+ path: 'announcement-templates',
+ element:
,
+ children: [
+ { index: true, element:
},
+ { path: ':id/edit', element:
},
+ ]
+ },
+ // Backwards-compatible aliases (keep old URLs working)
{
path: 'notifications',
element:
,
@@ -388,7 +418,7 @@ export const AdminRoutes = {
path: 'notification-templates',
element:
,
children: [
- { index: true, element:
},
+ { index: true, element:
},
{ path: ':id/edit', element:
},
]
},
diff --git a/src/modules/client/components/LessonCard.jsx b/src/modules/client/components/LessonCard.jsx
new file mode 100644
index 0000000..e7c8a63
--- /dev/null
+++ b/src/modules/client/components/LessonCard.jsx
@@ -0,0 +1,85 @@
+// LessonCard — grid card for a standalone Lesson. Shared by LessonsList.jsx and
+// Dashboard.jsx's "Featured Lessons" section. Mirrors UnitCard.jsx; a Lesson has
+// no lesson_count/quiz_id of its own — shows unit_count instead (how many Units
+// it's attached to).
+
+import { Timer, LockIcon, Layers } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
+import { cn } from "@/lib/utils";
+
+function formatDuration(seconds = 0) {
+ if (!seconds) return null;
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ if (h && m) return `${h}h ${m}m`;
+ if (h) return `${h}h`;
+ return `${m}m`;
+}
+
+export const LessonCard = ({ lesson, onViewDetails }) => {
+ const locked = lesson.is_locked;
+ const duration = formatDuration(lesson.duration_seconds);
+ const unitCount = Number(lesson.unit_count ?? 0);
+
+ return (
+
onViewDetails(lesson)}
+ >
+
+ {locked ? (
+
+ Locked
+
+ ) : unitCount > 0 ? (
+ In {unitCount} unit{unitCount === 1 ? "" : "s"}
+ ) : (
+ Standalone
+ )}
+
+
+
+
+ {lesson.title}
+
+ {lesson.description && (
+
+ {lesson.description}
+
+ )}
+
+
+
+
+
+ {duration ?? "—"}
+
+
+ {locked && (
+
Upgrade to unlock
+ )}
+
+
+ );
+};
+
+export const LessonCardSkeleton = () => (
+
+);
diff --git a/src/modules/client/components/LessonUpsellModal.jsx b/src/modules/client/components/LessonUpsellModal.jsx
new file mode 100644
index 0000000..00dd688
--- /dev/null
+++ b/src/modules/client/components/LessonUpsellModal.jsx
@@ -0,0 +1,70 @@
+// LessonUpsellModal — shown when a learner clicks a locked standalone Lesson.
+// Mirrors UnitUpsellModal.jsx: a Lesson isn't independently purchasable, so this
+// lists every course that would unlock it (aggregated across all its attached
+// Units, since a Lesson can sit in more than one) plus a generic "View Plans"
+// fallback. Shared by LessonsList and Dashboard.
+
+import { useNavigate } from "react-router-dom";
+import { LockIcon, BookOpen } from "lucide-react";
+import ResponsiveModal from "@/components/generic/ResponsiveModal";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { resolveTierBadge } from "@/utils/tierBadge.util";
+
+export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap = {} }) {
+ const navigate = useNavigate();
+ const courses = lesson?.courses ?? [];
+
+ return (
+
+ onOpenChange(false)}>Close
+ { onOpenChange(false); navigate("/plans"); }}>
+ View Plans
+
+ >
+ }
+ >
+
+ {courses.length === 0 ? (
+
+ Upgrade your plan to access this content.
+
+ ) : (
+ courses.map((course) => {
+ const { label, cls } = resolveTierBadge(course.subscription, tierMap);
+ return (
+
+
+
+
+
{course.title}
+
+ {label}
+
+
+
+
{ onOpenChange(false); navigate(`/course/${course.course_id}`); }}
+ >
+ View Course
+
+
+ );
+ })
+ )}
+
+
+ );
+}
diff --git a/src/modules/client/components/LockedContentPanel.jsx b/src/modules/client/components/LockedContentPanel.jsx
new file mode 100644
index 0000000..c881657
--- /dev/null
+++ b/src/modules/client/components/LockedContentPanel.jsx
@@ -0,0 +1,36 @@
+// LockedContentPanel — full-page inline panel shown when a deep-link lands on
+// content the learner can't access (locked unit or a lesson under one).
+// Distinct from UnitUpsellModal (a dialog triggered from card grids) — this
+// renders in place of the page body itself. Shared by UnitDetails and
+// LessonDetails.
+
+import { useNavigate } from "react-router-dom";
+import { LockIcon, Zap } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+export default function LockedContentPanel({ course, tierMap = {} }) {
+ const navigate = useNavigate();
+ const tier = course?.subscription ? tierMap[course.subscription] : null;
+
+ return (
+
+
+
+
+
+
Premium / Exclusive Content
+
+ {course
+ ? `This content is part of "${course.title}"${tier?.name ? ` (${tier.name} plan)` : ""}. Upgrade your plan or view the course to unlock it.`
+ : "Upgrade your plan to access this content."}
+
+
+
+
navigate('/plans')} className="gap-1.5">
+ View Available Plans
+
+
Already subscribed? Your plan may not cover this tier.
+
+
+ );
+}
diff --git a/src/modules/client/components/PlanComparisonTable.jsx b/src/modules/client/components/PlanComparisonTable.jsx
new file mode 100644
index 0000000..42e37d2
--- /dev/null
+++ b/src/modules/client/components/PlanComparisonTable.jsx
@@ -0,0 +1,96 @@
+// PlanComparisonTable — side-by-side feature matrix, an alternate view next to
+// the card grid on PlanList.jsx. Rows are the union of every active plan's
+// admin-authored `features` list; a plan gets a checkmark for a row if that
+// exact feature text is present in its own list.
+
+import * as LucideIcons from "lucide-react";
+import { Check, Minus, Tag } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { resolveTierBadge } from "@/utils/tierBadge.util";
+
+export default function PlanComparisonTable({ plans, myTier, tierMap, fmtCurrency, onSelect }) {
+ const featureRows = [...new Set(
+ plans.flatMap((p) => (p.features ?? []).map((f) => f.text))
+ )];
+
+ return (
+
+
+
+
+ Plan
+ {plans.map((plan) => {
+ const { label, cls } = resolveTierBadge(plan.tier, tierMap);
+ const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
+ const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
+ return (
+
+
+ {label}
+ {plan.label}
+ {fmtCurrency(plan.price, plan.currency)}
+ {isCurrent && Current Plan }
+
+
+ );
+ })}
+
+
+
+
+ Courses included
+ {plans.map((plan) => (
+
+ {plan.course_count > 0 ? plan.course_count : "—"}
+
+ ))}
+
+ {featureRows.map((text, i) => (
+
+ {text}
+ {plans.map((plan) => {
+ const included = (plan.features ?? []).some((f) => f.text === text);
+ return (
+
+ {included
+ ?
+ :
+ }
+
+ );
+ })}
+
+ ))}
+ {featureRows.length === 0 && (
+
+
+ No features have been added to these plans yet.
+
+
+ )}
+
+
+
+
+ {plans.map((plan) => {
+ const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
+ return (
+
+ onSelect(plan)}
+ >
+ {isCurrent ? "Current" : !plan.is_active ? "Not Available" : "Select"}
+
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/src/modules/client/components/blocks/PassQuiz.jsx b/src/modules/client/components/blocks/PassQuiz.jsx
new file mode 100644
index 0000000..37a6ce7
--- /dev/null
+++ b/src/modules/client/components/blocks/PassQuiz.jsx
@@ -0,0 +1,239 @@
+import { useState, useEffect } from "react";
+import { Badge } from "@/components/ui/badge";
+import { ClipboardCheck, CheckCheck, SendHorizonal, Lock, Zap, Info, Tag } from "lucide-react";
+import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
+import ResponsiveModal from "@/components/generic/ResponsiveModal";
+import { Button } from "@/components/ui/button";
+import { useNavigate } from "react-router-dom";
+import api from "@/utils/api.util";
+import { useClientTiers } from "@/contexts/ClientTiersProvider";
+import { resolveTierBadge } from "@/utils/tierBadge.util";
+
+function TierBadge({ tier }) {
+ const { tierMap } = useClientTiers();
+ const { rank, label, cls } = resolveTierBadge(tier ?? 'free', tierMap);
+ return (
+
+ {rank > 0 ? : }
+ {label}
+
+ );
+}
+
+// ─── PassQuiz — task requirement block for pass_quiz type ─────────────────────
+// Mirrors ReadUnit.jsx's fetch-detail-and-navigate pattern. A quiz is always
+// unit-scoped; navigation goes into the course reader if the unit is attached
+// to a course, otherwise the standalone unit reader.
+const PassQuiz = ({ title = "Pass Quizzes", quizzes = [], groupId, taskListId, taskId }) => {
+ const navigate = useNavigate();
+ const [details, setDetails] = useState({});
+ const [locked, setLocked] = useState({});
+ const [lockedInfo, setLockedInfo] = useState({});
+ const [unavailable, setUnavailable] = useState({});
+ const [fetching, setFetching] = useState({});
+ const [selected, setSelected] = useState(null);
+
+ useEffect(() => {
+ quizzes.forEach(async (q) => {
+ if (!q.reference_id) return;
+ setFetching((prev) => ({ ...prev, [q.reference_id]: true }));
+ try {
+ const res = await api.get(`/client/courses/quiz/uuid/${q.reference_id}`);
+ const d = res.data?.data;
+ if (d) setDetails((prev) => ({ ...prev, [q.reference_id]: d }));
+ } catch (err) {
+ if (err?.response?.status === 403) {
+ setLocked((prev) => ({ ...prev, [q.reference_id]: true }));
+ const course = err.response?.data?.course;
+ if (course) setLockedInfo((prev) => ({ ...prev, [q.reference_id]: course }));
+ } else {
+ setUnavailable((prev) => ({ ...prev, [q.reference_id]: true }));
+ }
+ } finally {
+ setFetching((prev) => ({ ...prev, [q.reference_id]: false }));
+ }
+ });
+ }, []);
+
+ const goToQuiz = (info) => {
+ if (!info) return;
+ const taskCtx = taskId ? { has_task: true, groupId, taskListId, taskId } : undefined;
+ if (info.unit?.course?.course_id) {
+ navigate(`/course/${info.unit.course.course_id}/unit`, {
+ state: { quizUnitId: info.unit.unit_id, ...(taskCtx ? { taskCtx } : {}) },
+ });
+ } else if (info.unit?.uuid) {
+ navigate(`/units/${info.unit.uuid}/read`, { state: { quizId: true } });
+ }
+ };
+
+ if (!quizzes.length) return null;
+
+ const hasLocked = Object.values(locked).some(Boolean);
+
+ return (
+ <>
+
+
+
+
{title}
+ {quizzes.length}
+
+
+ {hasLocked && (
+
+
+
+
Subscription Required
+
+ To complete this activity, subscribe to one of our available tier plans.
+
+
+
navigate('/plans')}>
+ View Plans
+
+
+ )}
+
+
+
+ {quizzes.map((q) => {
+ const info = details[q.reference_id];
+ const courseInfo = lockedInfo[q.reference_id];
+ const isLocked = locked[q.reference_id];
+ const isUnavailable = unavailable[q.reference_id];
+ const isFetching = fetching[q.reference_id];
+ const passed = info?.has_passed || q.completed;
+
+ if (isLocked) {
+ return (
+
navigate('/plans')}
+ className="bg-card rounded-2xl border border-dashed p-4 flex flex-col gap-3 hover:bg-muted/60 hover:shadow-sm transition-all cursor-pointer group w-80 shrink-0 opacity-80"
+ >
+
+
{q.title}
+
+ Subscribe to {courseInfo?.title ?? 'this course'} to unlock this quiz.
+
+
+ { e.stopPropagation(); navigate('/plans'); }}>
+ Upgrade to unlock
+
+
+
+ );
+ }
+
+ if (isUnavailable) {
+ return (
+
+
+
+ Quiz
+
+ Unavailable
+
+
+
{q.title}
+
This quiz is no longer available.
+
+ );
+ }
+
+ return (
+
!isFetching && setSelected(q)}
+ className={`bg-card rounded-2xl border p-4 flex flex-col gap-3 transition-all w-80 shrink-0 ${
+ isFetching ? 'opacity-60 cursor-wait' : 'hover:bg-muted/60 hover:shadow-sm cursor-pointer group'
+ }`}
+ >
+
+
+
Quiz
+
+ {info?.unit?.course?.subscription
+ ?
+ : isFetching && Loading…
+ }
+
+
+ {info?.unit?.title && (
+
+ in {info.unit.title}
+
+ )}
+
{q.title}
+
+ {passed ? (
+
+ Passed
+
+ ) : (
+ Not Attempted
+ )}
+ {info?.passing_score && (
+ {info.passing_score}% to pass
+ )}
+
+
+ );
+ })}
+
+
+
+
+
+
!v && setSelected(null)}
+ title={selected?.title}
+ description="Quiz Info"
+ footer={
+ <>
+ setSelected(null)}>Cancel
+ goToQuiz(details[selected?.reference_id])}
+ disabled={!details[selected?.reference_id]}
+ >
+ Take Quiz
+
+ >
+ }
+ >
+ {selected && (() => {
+ const info = details[selected.reference_id];
+ return (
+
+ {(info?.has_passed || selected.completed) && (
+
+ Already Passed
+
+ )}
+ {info?.unit?.title && (
+
+ Unit:
+ {info.unit.title}
+
+ )}
+ {info?.passing_score && (
+
+ Passing score:
+ {info.passing_score}%
+
+ )}
+
+ );
+ })()}
+
+ >
+ );
+};
+
+export default PassQuiz;
diff --git a/src/modules/client/layout/ClientLayout.jsx b/src/modules/client/layout/ClientLayout.jsx
index 4018908..a853176 100644
--- a/src/modules/client/layout/ClientLayout.jsx
+++ b/src/modules/client/layout/ClientLayout.jsx
@@ -34,6 +34,7 @@ import { useEffect, useRef, useState } from "react"
import { AVATAR_COLORS } from "@/data/profile.data"
import { Badge } from "@/components/ui/badge"
import ClientNotificationBell from "@/components/generic/ClientNotificationBell"
+import StickyAnnouncementBar from "@/components/generic/StickyAnnouncementBar"
import { QRCodeCanvas } from "qrcode.react"
// ─── Refer / Invite dialog ────────────────────────────────────────────────────
@@ -142,6 +143,8 @@ function ClientNav() {
const navigate = useNavigate()
const { user, logout } = useAuth()
+ const navRef = useRef(null)
+
// Background fetches only — nav rendering never waits on these
const { achievements, getAchievements } = useProfile()
const { myTier, tierMap, tierCategories, getMyTier, getTierCategories } = useClientTiers()
@@ -159,6 +162,19 @@ function ClientNav() {
if (tierCategories.length === 0) getTierCategories();
}, [user]);
+ // Used by sticky overlays (e.g. StickyAnnouncementBar) to avoid overlapping the fixed header.
+ useEffect(() => {
+ if (!navRef.current) return
+ const update = () => {
+ document.documentElement.style.setProperty('--navbar-h', `${navRef.current.offsetHeight}px`)
+ }
+ update()
+
+ const ro = new ResizeObserver(update)
+ ro.observe(navRef.current)
+ return () => ro.disconnect()
+ }, [])
+
// ── Derive directly from auth user — same pattern as admin UserMenu ──────
// No extra fetch, no loading state, no flicker on reload.
const given = user?.personal_info?.name?.given_name ?? ""
@@ -219,7 +235,7 @@ function ClientNav() {
return (
<>
-
+
{/* Logo */}
@@ -345,6 +361,7 @@ const ClientLayout = () => {
+
diff --git a/src/modules/client/pages/CourseList.jsx b/src/modules/client/pages/CourseList.jsx
index d3ff238..ad13967 100644
--- a/src/modules/client/pages/CourseList.jsx
+++ b/src/modules/client/pages/CourseList.jsx
@@ -249,13 +249,17 @@ const CoursesList = () => {
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
/>
-
{ if (v === "units") navigate("/units"); }}>
+ {
+ if (v === "units") navigate("/units");
+ if (v === "lessons") navigate("/lessons");
+ }}>
Courses
Units
+ Lessons
diff --git a/src/modules/client/pages/Dashboard.jsx b/src/modules/client/pages/Dashboard.jsx
index ae5915c..9be63a3 100644
--- a/src/modules/client/pages/Dashboard.jsx
+++ b/src/modules/client/pages/Dashboard.jsx
@@ -19,6 +19,8 @@ import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import UnitUpsellModal from "../components/UnitUpsellModal";
import { UnitCard, UnitCardSkeleton } from "../components/UnitCard";
+import LessonUpsellModal from "../components/LessonUpsellModal";
+import { LessonCard, LessonCardSkeleton } from "../components/LessonCard";
import { resolveTierBadge } from "@/utils/tierBadge.util";
import { cn } from "@/lib/utils";
import { useGroup } from "@/contexts/ClientGroupContext";
@@ -208,7 +210,7 @@ const Client = () => {
const navigate = useNavigate();
const { state: navState } = useLocation();
const { courses, coursesLoading, getCourses } = useClientCourses();
- const { units, unitsLoading, getUnits } = useLibrary();
+ const { units, unitsLoading, getUnits, lessons, lessonsLoading, getLessons } = useLibrary();
const { myTier, getMyTier, tierMap } = useClientTiers();
const { groups, fetchGroups, loading: groupLoading } = useGroup();
const {
@@ -223,6 +225,9 @@ const Client = () => {
const [unitModalOpen, setUnitModalOpen] = useState(false);
const [selectedUnit, setSelectedUnit] = useState(null);
+ const [lessonModalOpen, setLessonModalOpen] = useState(false);
+ const [selectedLesson, setSelectedLesson] = useState(null);
+
const [popupOpen, setPopupOpen] = useState(false);
const userTier = myTier?.tier ?? "free";
@@ -243,6 +248,7 @@ const Client = () => {
useEffect(() => {
getCourses();
getUnits();
+ getLessons();
if (!myTier) getMyTier();
}, []);
@@ -262,6 +268,7 @@ const Client = () => {
// Show only first 3
const featuredCourses = courses.slice(0, 3);
const featuredUnits = units.slice(0, 3);
+ const featuredLessons = lessons.slice(0, 3);
const breadcrumbItems = [
{ label: "My Groups", icon: },
@@ -287,6 +294,15 @@ const Client = () => {
}
};
+ const handleViewLessonDetails = (lesson) => {
+ if (lesson.is_locked) {
+ setSelectedLesson(lesson);
+ setLessonModalOpen(true);
+ } else {
+ navigate(`/lessons/${lesson.uuid}`);
+ }
+ };
+
return (
@@ -378,6 +394,34 @@ const Client = () => {
)}
+ {/* ── Featured Lessons (first 3) ── */}
+
+
+
Lessons
+ navigate(`/lessons`)}>View All
+
+
+ {lessonsLoading ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ ) : featuredLessons.length === 0 ? (
+
No lessons available yet.
+ ) : (
+
+ {featuredLessons.map((lesson) => (
+
+ ))}
+
+ )}
+
+
@@ -437,6 +481,14 @@ const Client = () => {
unit={selectedUnit}
tierMap={tierMap}
/>
+
+ {/* ── Upsell Modal — only for locked lessons ── */}
+
);
};
diff --git a/src/modules/client/pages/LessonDetails.jsx b/src/modules/client/pages/LessonDetails.jsx
new file mode 100644
index 0000000..b617d38
--- /dev/null
+++ b/src/modules/client/pages/LessonDetails.jsx
@@ -0,0 +1,165 @@
+import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
+import { useParams, useNavigate } from "react-router-dom";
+import {
+ House, Timer, SendHorizonal, CheckCheck, ListChecks, ArrowRight, Layers, Hourglass,
+} from "lucide-react";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Button } from "@/components/ui/button";
+import { useEffect } from "react";
+import { useLibrary } from "@/contexts/ClientLibraryContext";
+import { useClientTiers } from "@/contexts/ClientTiersProvider";
+import { PageMeta } from "@/contexts/MetadataContext";
+import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function formatDuration(seconds = 0) {
+ if (!seconds) return null;
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ if (h > 0) return `${h}hr ${m}min`;
+ return `${m}min`;
+}
+
+// ─── Lesson Details ─────────────────────────────────────────────────────────
+
+const LessonDetails = () => {
+ const { uuid } = useParams();
+ const navigate = useNavigate();
+
+ const { getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson } = useLibrary();
+ const { tierMap, getTierCategories } = useClientTiers();
+
+ const hasCompleted = lesson?.status === "completed";
+ const unit = lesson?.unit ?? null;
+ const hasUnit = !!unit?.uuid;
+
+ useEffect(() => {
+ getTierCategories();
+ getLesson(uuid);
+ return () => resetLesson();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [uuid]);
+
+ const items = [
+ { label: "Home", icon: , to: `/dashboard` },
+ { label: "Lessons", to: `/lessons` },
+ ...(hasUnit ? [{ label: unit.title, to: `/units/${unit.uuid}` }] : []),
+ { label: lesson?.title ?? "Lesson" },
+ ];
+
+ // ── Deep-link to a lesson under a locked unit — inline blocked panel ────
+ if (unitBlocked) {
+ return ;
+ }
+
+ if (lessonLoading || !lesson) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ const handleStart = () => {
+ if (!hasUnit) return;
+ navigate(`/units/${unit.uuid}/read`, { state: { lessonId: lesson.lesson_id } });
+ };
+
+ return (
+
+
+
+
+
+
+ {/* Hero */}
+
+
+
+
+
+
{lesson.title}
+
{lesson.description ?? ""}
+ {lesson.duration_seconds > 0 && (
+
+
+ {formatDuration(lesson.duration_seconds)}
+
+ )}
+
+ {!hasUnit ? (
+
+
+ This lesson isn't part of a unit yet. Check back later.
+
+ ) : (
+
+ {hasCompleted
+ ? <> Start Again>
+ : <> Start Lesson>
+ }
+
+ )}
+
+
+
+
+
+
+ {/* Body */}
+
+
+
+
About this lesson
+
+
{lesson.description ?? ""}
+
+
+
+ {lesson.objectives?.length > 0 && (
+
+
+
+ Objectives
+
+
+ {lesson.objectives.map((obj) => (
+
+
+ {obj.text}
+
+ ))}
+
+
+ )}
+
+ {hasUnit && (
+
navigate(`/units/${unit.uuid}`)}
+ className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-fit"
+ >
+
+ Part of unit: {unit.title}
+
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default LessonDetails;
diff --git a/src/modules/client/pages/LessonsList.jsx b/src/modules/client/pages/LessonsList.jsx
new file mode 100644
index 0000000..7fbd2da
--- /dev/null
+++ b/src/modules/client/pages/LessonsList.jsx
@@ -0,0 +1,201 @@
+import { useEffect, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { House, ChevronLeft, ChevronRight, Layers } from "lucide-react";
+import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
+import { Input } from "@/components/ui/input";
+import {
+ Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
+} from "@/components/ui/select";
+import { Button } from "@/components/ui/button";
+import { useLibrary } from "@/contexts/ClientLibraryContext";
+import { useClientTiers } from "@/contexts/ClientTiersProvider";
+import LessonUpsellModal from "../components/LessonUpsellModal";
+import { LessonCard, LessonCardSkeleton } from "../components/LessonCard";
+import { PageMeta } from "@/contexts/MetadataContext";
+
+const ITEMS_PER_PAGE = 10;
+
+// ─── Pagination ───────────────────────────────────────────────────────────────
+
+const Pagination = ({ currentPage, totalPages, totalItems, itemsPerPage, onPageChange }) => {
+ const start = (currentPage - 1) * itemsPerPage + 1;
+ const end = Math.min(currentPage * itemsPerPage, totalItems);
+
+ const getPages = () => {
+ const pages = [];
+ if (totalPages <= 5) {
+ for (let i = 1; i <= totalPages; i++) pages.push(i);
+ } else {
+ pages.push(1);
+ if (currentPage > 3) pages.push("...");
+ for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) pages.push(i);
+ if (currentPage < totalPages - 2) pages.push("...");
+ pages.push(totalPages);
+ }
+ return pages;
+ };
+
+ return (
+
+
+ Showing {start}–{end} of{" "}
+ {totalItems} lessons
+
+
+ onPageChange(currentPage - 1)} disabled={currentPage === 1} variant="ghost" size="sm">
+
+
+ {getPages().map((page, i) =>
+ page === "..." ? (
+ ···
+ ) : (
+ onPageChange(page)}>
+ {page}
+
+ )
+ )}
+ onPageChange(currentPage + 1)} disabled={currentPage === totalPages} variant="ghost" size="sm">
+
+
+
+
+ );
+};
+
+// ─── Main Page ────────────────────────────────────────────────────────────────
+
+const LessonsList = () => {
+ const navigate = useNavigate();
+ const { lessons, lessonsLoading, getLessons } = useLibrary();
+ const { tierMap, getTierCategories } = useClientTiers();
+
+ const [currentPage, setCurrentPage] = useState(1);
+ const [search, setSearch] = useState("");
+ const [lockFilter, setLockFilter] = useState("All");
+ const [modalOpen, setModalOpen] = useState(false);
+ const [selectedLesson, setSelectedLesson] = useState(null);
+
+ useEffect(() => {
+ getLessons();
+ getTierCategories();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const filtered = useMemo(() =>
+ lessons
+ .filter((l) => {
+ const matchSearch = l.title.toLowerCase().includes(search.toLowerCase()) ||
+ (l.description ?? "").toLowerCase().includes(search.toLowerCase());
+ const matchLock = lockFilter === "All"
+ || (lockFilter === "Unlocked" && !l.is_locked)
+ || (lockFilter === "Locked" && l.is_locked);
+ return matchSearch && matchLock;
+ })
+ .sort((a, b) => a.title.localeCompare(b.title)),
+ [lessons, search, lockFilter]
+ );
+
+ const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
+ const paginated = filtered.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
+
+ const handleViewDetails = (lesson) => {
+ if (lesson.is_locked) {
+ setSelectedLesson(lesson);
+ setModalOpen(true);
+ } else {
+ navigate(`/lessons/${lesson.uuid}`);
+ }
+ };
+
+ const items = [
+ { label: "Home", icon: , to: `/dashboard` },
+ { label: "Lessons" },
+ ];
+
+ return (
+
+
+
+
+
+
+ {/* Search & Filters */}
+
+
+ { setSearch(e.target.value); setCurrentPage(1); }}
+ />
+ { setLockFilter(v); setCurrentPage(1); }}>
+
+
+
+
+ All Lessons
+ Unlocked
+ Locked
+
+
+ {
+ if (v === "courses") navigate("/course");
+ if (v === "units") navigate("/units");
+ }}>
+
+
+
+
+ Courses
+ Units
+ Lessons
+
+
+
+
+
+ {/* Lesson Grid */}
+ {lessonsLoading ? (
+
+ {Array.from({ length: 8 }).map((_, i) => )}
+
+ ) : paginated.length === 0 ? (
+
+ ) : (
+
+ {paginated.map((lesson) => (
+
+ ))}
+
+ )}
+
+ {!lessonsLoading && filtered.length > ITEMS_PER_PAGE && (
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default LessonsList;
diff --git a/src/modules/client/pages/PlanList.jsx b/src/modules/client/pages/PlanList.jsx
index 121541b..03a24ef 100644
--- a/src/modules/client/pages/PlanList.jsx
+++ b/src/modules/client/pages/PlanList.jsx
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
+import * as LucideIcons from "lucide-react";
import {
Card, CardContent, CardDescription,
CardFooter, CardHeader, CardTitle,
@@ -14,7 +15,7 @@ import {
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import {
BookOpen, Clock, Check,
- Tag, LockIcon, Zap, RotateCcw,
+ Tag, RotateCcw, LaptopMinimal, Table as TableIcon,
} from "lucide-react";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
@@ -24,6 +25,8 @@ import { PageMeta } from "@/contexts/MetadataContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
+import { resolveTierBadge } from "@/utils/tierBadge.util";
+import PlanComparisonTable from "../components/PlanComparisonTable";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -53,31 +56,6 @@ function formatCourseDuration(seconds = 0) {
return `${m}m`;
}
-// Badge styles per tier
-const TIER_STYLES = {
- free: {
- badge: "bg-gradient-to-r from-lime-400 to-lime-600 text-white",
- button: "default",
- icon: Tag,
- label: "Free",
- ring: "",
- },
- premium: {
- badge: "bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white",
- button: "default",
- icon: Zap,
- label: "Premium",
- ring: "ring-2 ring-fuchsia-400/40",
- },
- exclusive: {
- badge: "bg-gradient-to-r from-rose-500 to-red-600 text-white",
- button: "default",
- icon: LockIcon,
- label: "Exclusive",
- ring: "ring-2 ring-rose-400/40",
- },
-};
-
// ─── Skeleton ──────────────────────────────────────────────────────────────────
const PlanSkeleton = () => (
@@ -104,20 +82,22 @@ const PlanSkeleton = () => (
const PREVIEW_COURSE_LIMIT = 2;
-const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft }) => {
- const fmtPlanPrice = (p) => `${p.currency} ${Number(p.price).toFixed(2)}`;
+const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSecsLeft }) => {
+ const { fmtCurrency } = useDateFormat();
const [coursesOpen, setCoursesOpen] = useState(false);
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
- const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
- const Icon = style.icon;
+ const { label: tierLabel, cls: badgeCls, rank } = resolveTierBadge(plan.tier, tierMap);
+ const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
+ const ring = rank > 0 ? "ring-2 ring-primary/30" : "";
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
const duration = formatDuration(plan.duration_days, plan.duration_unit);
+ const features = plan.features ?? [];
const previewCourses = plan.courses?.slice(0, PREVIEW_COURSE_LIMIT) ?? [];
const extraCount = (plan.courses?.length ?? 0) - PREVIEW_COURSE_LIMIT;
return (
<>
-
+
{plan.label}
@@ -125,15 +105,15 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
{isCurrent && (
Current Plan
)}
-
+
- {style.label}
+ {tierLabel}