From 2de6e584882ba160fcd67ad8dc92f300cff3c5d0 Mon Sep 17 00:00:00 2001
From: rgrgogu
Date: Sat, 15 Aug 2026 11:09:30 +0800
Subject: [PATCH] debug: assets picker
Signed-off-by: rgrgogu
---
src/components/generic/AssetPickerSheet.jsx | 92 ++++++---
.../generic/notificationDisplay.jsx | 6 +-
src/contexts/AdminAssetsContext.jsx | 178 ++++++++++++------
src/contexts/ClientCoursesContext.jsx | 1 +
.../client/components/CourseUpsellModal.jsx | 65 +++++++
.../client/components/LessonUpsellModal.jsx | 2 +-
.../client/components/LockedContentPanel.jsx | 2 +-
.../client/components/UnitUpsellModal.jsx | 2 +-
.../client/components/blocks/ReadCourse.jsx | 6 +-
.../client/components/blocks/ReadLesson.jsx | 6 +-
.../client/components/blocks/ReadUnit.jsx | 6 +-
src/modules/client/layout/ClientLayout.jsx | 4 +-
src/modules/client/pages/AccountSettings.jsx | 2 +-
src/modules/client/pages/Checkout.jsx | 14 +-
src/modules/client/pages/CourseDetails.jsx | 74 +++++---
src/modules/client/pages/CourseList.jsx | 66 +------
src/modules/client/pages/Dashboard.jsx | 92 +--------
src/modules/client/pages/LessonDetails.jsx | 83 ++++----
src/modules/client/pages/PlanList.jsx | 8 +-
src/modules/client/pages/UnitDetails.jsx | 84 ++++++---
src/modules/client/pages/UnitList.jsx | 6 +-
src/modules/client/pages/UnitReader.jsx | 6 +-
src/modules/client/pages/UnitsList.jsx | 19 +-
src/modules/client/pages/ViewPlan.jsx | 6 +-
src/modules/client/pages/ViewRequirement.jsx | 2 +-
src/modules/client/routes/ClientRoutes.jsx | 2 +-
src/utils/link.util.js | 6 +-
27 files changed, 457 insertions(+), 383 deletions(-)
create mode 100644 src/modules/client/components/CourseUpsellModal.jsx
diff --git a/src/components/generic/AssetPickerSheet.jsx b/src/components/generic/AssetPickerSheet.jsx
index 7acc905..67cee09 100644
--- a/src/components/generic/AssetPickerSheet.jsx
+++ b/src/components/generic/AssetPickerSheet.jsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, useCallback, useRef } from "react";
+import { useEffect, useState, useCallback, useRef, memo } from "react";
import { Search, CheckCircle2, SlidersHorizontal, X } from "lucide-react";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
@@ -8,7 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
-import { useAssets } from "@/contexts/AdminAssetsContext";
+import { useAssets, useMediaTokens } from "@/contexts/AdminAssetsContext";
import { formatPlayerTime } from "@/utils/format.util";
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
@@ -23,8 +23,13 @@ const EXT_OPTIONS = {
// ─── Asset Card ───────────────────────────────────────────────────────────────
// streamSrc is resolved at the sheet level (batch token request) — no per-card fetch.
+// memo()'d because a sibling AssetPickerSheet's mediaTokens update re-runs
+// this component's own function body (context change) even when none of
+// THIS card's actual props changed — memo + a stable onSelect (see
+// handleSelect's useCallback below) lets it bail out instead of re-rendering
+// every card in every mounted-but-untouched picker.
-function AssetCard({ asset, streamSrc, selected, onSelect }) {
+const AssetCard = memo(function AssetCard({ asset, streamSrc, selected, onSelect }) {
const directThumb = asset.thumbnail_url ?? asset.file_url;
const thumb = streamSrc ?? directThumb;
// duration comes straight off the asset row (ffprobe-derived at upload) —
@@ -69,7 +74,7 @@ function AssetCard({ asset, streamSrc, selected, onSelect }) {
)}
);
-}
+});
function EmptyState({ fileType }) {
return (
@@ -91,10 +96,20 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
// `open` prop toggles), that meant React remounted every hook from scratch
// on each open — wiping local state and forcing a full refetch every time,
// plus skipping the close transition. Visibility is controlled by
- // below instead, so state (and the caches in
- // AdminAssetsContext) survive across open/close toggles.
+ // below instead, so state survives across open/close
+ // toggles. Because this component now stays mounted for its parent's
+ // lifetime, a page with several media blocks keeps several instances
+ // mounted at once — assets/pagination/loading are therefore local state
+ // (below), not AdminAssetsContext state, so opening one picker doesn't
+ // re-render or stomp the list of every other mounted one. mediaTokens and
+ // the request-level TTL cache stay in context — they're pure per-asset /
+ // per-query caches, safe (and worth) sharing across instances.
- const { fetchAssets, assets, pagination, loading, mediaTokens, getMediaTokens } = useAssets();
+ const { fetchAssetsList } = useAssets();
+ // Separate context from useAssets() on purpose — see useMediaTokens's
+ // definition in AdminAssetsContext.jsx. Keeps this picker from
+ // re-rendering when the admin Assets table's unrelated list state changes.
+ const { mediaTokens, getMediaTokens } = useMediaTokens();
const [search, setSearch] = useState("");
const [activeExts, setActiveExts] = useState(new Set());
@@ -102,9 +117,22 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
const [selected, setSelected] = useState(null);
const [filterOpen, setFilterOpen] = useState(false);
+ // Local, per-instance list state — deliberately NOT shared context state.
+ // A page can mount many AssetPickerSheet instances at once (one per media
+ // block); sharing a single assets/pagination/loading slice meant opening
+ // one picker re-rendered and stomped the list of every other mounted one.
+ const [assets, setAssets] = useState([]);
+ const [pagination, setPagination] = useState({ page: 1, totalPages: 0, hasPrevPage: false, hasNextPage: false });
+ const [loading, setLoading] = useState(false);
+ // Tracks the follow-up batch token fetch separately from `loading` (the
+ // list fetch) — combined into `showSpinner` below so the grid never
+ // renders with placeholder "No preview" cards that then pop thumbnails
+ // in a beat later. One spinner, then everything appears already loaded.
+ const [tokensLoading, setTokensLoading] = useState(false);
+
const debounceRef = useRef(null);
const isFirstSearchRun = useRef(true);
- const LIMIT = 12;
+ const LIMIT = 10;
const extOptions = allowedExtensions ?? EXT_OPTIONS[fileType] ?? [];
@@ -125,8 +153,14 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
...(searchVal.trim() ? [{ id: "display_name", value: [searchVal.trim()] }] : []),
...(extFilterValue?.length ? [{ id: "extension", value: extFilterValue }] : []),
];
- fetchAssets({ page: pg, limit: LIMIT, filters });
- }, [fileType, fetchAssets, allowedExtensions]);
+ setLoading(true);
+ fetchAssetsList({ page: pg, limit: LIMIT, filters })
+ .then(({ assets: nextAssets, pagination: nextPagination }) => {
+ setAssets(nextAssets);
+ setPagination(nextPagination);
+ })
+ .finally(() => setLoading(false));
+ }, [fileType, fetchAssetsList, allowedExtensions]);
// ── Immediate fetch: on open, or when filters/page change while open ──────
// (fetchAssets itself is TTL-cached in AdminAssetsContext, so reopening
@@ -164,9 +198,12 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
if (!s3Ids.length) return;
- getMediaTokens(s3Ids).catch((err) => {
- console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message);
- });
+ setTokensLoading(true);
+ getMediaTokens(s3Ids)
+ .catch((err) => {
+ console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message);
+ })
+ .finally(() => setTokensLoading(false));
}, [assets, open, getMediaTokens]);
// ── Reset on close ────────────────────────────────────────────────────────
@@ -194,7 +231,10 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
setPage(1);
};
- const handleSelect = (asset) => {
+ // useCallback so AssetCard's memo() (above) actually has a stable prop to
+ // compare against — an inline function here would give every card a new
+ // onSelect reference on every render, defeating the memo entirely.
+ const handleSelect = useCallback((asset) => {
setSelected(asset.asset_id);
// Pass the resolved stream/presigned URL as a second arg so callers
// (e.g. badge image picker) can use the authenticated URL directly
@@ -206,13 +246,14 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
?? null;
onSelect(asset, resolvedUrl);
onOpenChange(false);
- };
+ }, [resolveStreamSrc, onSelect, onOpenChange]);
const label = fileType
? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s`
: "Files";
const hasActiveFilters = activeExts.size > 0;
+ const showSpinner = loading || tokensLoading;
return (
@@ -304,10 +345,15 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
{/* ── Grid ── */}
+ {/* showSpinner covers both the list fetch AND the follow-up batch
+ token fetch, so the grid only ever appears once every card
+ already has its thumbnail resolved — no placeholder-then-pop-in
+ flicker. Spinner fills the full remaining sheet height (not a
+ small fixed box) so it's centered in the whole visible area. */}
- {loading ? (
-
-
+ {showSpinner ? (
+
+
) : !assets.length ? (
@@ -327,7 +373,11 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
{/* ── Pagination ── */}
- {pagination.totalPages > 1 && (
+ {/* Always shown once there's at least one page of results (even a
+ single page) — Prev/Next self-disable via hasPrevPage/hasNextPage,
+ so a one-page list just reads "Page 1 of 1" with both disabled
+ rather than hiding the control entirely. */}
+ {pagination.totalPages > 0 && (
Page {pagination.page} of {pagination.totalPages}
@@ -335,7 +385,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
setPage((p) => p - 1)}
className="px-3 py-1 rounded border text-xs disabled:opacity-40 hover:bg-muted transition-colors"
>
@@ -343,7 +393,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
setPage((p) => p + 1)}
className="px-3 py-1 rounded border text-xs disabled:opacity-40 hover:bg-muted transition-colors"
>
diff --git a/src/components/generic/notificationDisplay.jsx b/src/components/generic/notificationDisplay.jsx
index 25f8630..a5b30a4 100644
--- a/src/components/generic/notificationDisplay.jsx
+++ b/src/components/generic/notificationDisplay.jsx
@@ -124,8 +124,8 @@ export function resolveNotificationLink(type, data) {
case "tier_expired":
return data.planId
- ? { label: "Renew plan", go: (navigate) => navigate(`/plans/view/${data.planId}`) }
- : { label: "View plans", go: (navigate) => navigate("/plans") };
+ ? { label: "Renew plan", go: (navigate) => navigate(`/subscriptions/view/${data.planId}`) }
+ : { label: "View plans", go: (navigate) => navigate("/subscriptions") };
case "announcement":
if (data.groupId && data.groupCode) {
@@ -135,7 +135,7 @@ export function resolveNotificationLink(type, data) {
return { label: "Go to course", go: (navigate) => goToCourse(navigate, data.targetId) };
}
if (data.targetType === "tier_plan" && data.targetId) {
- return { label: "View plan", go: (navigate) => navigate(`/plans/view/${data.targetId}`) };
+ return { label: "View plan", go: (navigate) => navigate(`/subscriptions/view/${data.targetId}`) };
}
if (data.targetType === "task_list" && data.groupId && data.targetId) {
return { label: "View task list", go: (navigate) => navigate(`/group/${data.groupId}/view/${data.targetId}`) };
diff --git a/src/contexts/AdminAssetsContext.jsx b/src/contexts/AdminAssetsContext.jsx
index 7673861..b528bde 100644
--- a/src/contexts/AdminAssetsContext.jsx
+++ b/src/contexts/AdminAssetsContext.jsx
@@ -1,16 +1,30 @@
-import { createContext, useCallback, useContext, useRef, useState } from "react";
+import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import api from "@/utils/api.util";
import { presignAssetUpload, uploadPresigned } from "@/utils/presignedUpload.util";
import { toast } from "sonner";
const AssetsContext = createContext(null);
+// Split from AssetsContext on purpose: mediaTokens is the one piece of state
+// every AssetPickerSheet instance needs (to resolve its own thumbnails), but
+// it has nothing to do with the table-page state (assets/pagination/
+// attributes/loading) that also lives in AssetsContext. Without the split,
+// a picker subscribing to useAssets() would re-render any time the admin
+// Assets table's list state changed too, even though it never reads it.
+const MediaTokensContext = createContext(null);
+
export function useAssets() {
const ctx = useContext(AssetsContext);
if (!ctx) throw new Error("useAssets must be used within an AssetsProvider");
return ctx;
}
+export function useMediaTokens() {
+ const ctx = useContext(MediaTokensContext);
+ if (!ctx) throw new Error("useMediaTokens must be used within an AssetsProvider");
+ return ctx;
+}
+
// ─── Initial States ────────────────────────────────────────────────────────────
const PAGINATION_INIT = {
page: 1,
@@ -77,50 +91,79 @@ export function AssetsProvider({ children }) {
}
}, []);
- // ─── GET /api/admin/assets ────────────────────────────────────────────────
- // Cached per (page, limit, filters, sort) for LIST_CACHE_TTL_MS so toggling
- // a picker like AssetPickerSheet open/closed doesn't re-hit Postgres for the
- // same query within the TTL window. Pass force: true to bypass the cache.
- const fetchAssets = useCallback(
- ({ page = 1, limit = 10, filters = [], sort = [], force = false } = {}) => {
+ // ─── GET /api/admin/assets (shared, cached) ──────────────────────────────
+ // Cached per (page, limit, filters, sort) for LIST_CACHE_TTL_MS so repeated
+ // callers (the table page, every mounted picker) don't re-hit Postgres for
+ // the same query within the TTL window. Pass force: true to bypass the
+ // cache. Returns the resolved { assets, pagination, attributes, raw } and
+ // seeds mediaTokens as a side effect — does NOT touch assets/pagination/
+ // attributes/loading state, so callers decide where the result lives.
+ const resolveAssetsQuery = useCallback(
+ async ({ page = 1, limit = 10, filters = [], sort = [], force = false } = {}) => {
const key = cacheKeyFor("assets", { page, limit, filters, sort });
const cached = listCacheRef.current.get(key);
if (!force && cached && Date.now() - cached.fetchedAt < LIST_CACHE_TTL_MS) {
- setAssets(cached.assets);
- setPagination(cached.pagination);
- setAttributes(cached.attributes);
seedMediaTokensFromRows(cached.assets);
- return Promise.resolve(cached.raw);
+ return cached;
}
- return request(async () => {
- const { data } = await api.get("/admin/assets", {
- params: {
- page, limit,
- filters: filters.length ? JSON.stringify(filters) : undefined,
- sort: sort.length ? JSON.stringify(sort) : undefined,
- },
- });
-
- const result = data?.data;
-
- setAssets(result?.data ?? []);
- setPagination(result?.pagination ?? PAGINATION_INIT);
- setAttributes(result.attributes);
- seedMediaTokensFromRows(result?.data);
-
- listCacheRef.current.set(key, {
- assets: result?.data ?? [],
- pagination: result?.pagination ?? PAGINATION_INIT,
- attributes: result.attributes,
- raw: data.data,
- fetchedAt: Date.now(),
- });
-
- return data.data;
+ const { data } = await api.get("/admin/assets", {
+ params: {
+ page, limit,
+ filters: filters.length ? JSON.stringify(filters) : undefined,
+ sort: sort.length ? JSON.stringify(sort) : undefined,
+ },
});
+
+ const result = data?.data;
+ const resolved = {
+ assets: result?.data ?? [],
+ pagination: result?.pagination ?? PAGINATION_INIT,
+ attributes: result.attributes,
+ raw: data.data,
+ fetchedAt: Date.now(),
+ };
+
+ seedMediaTokensFromRows(resolved.assets);
+ listCacheRef.current.set(key, resolved);
+
+ return resolved;
},
- [request]
+ []
+ );
+
+ // Shared-state variant — used by the admin Assets table page. Applies the
+ // resolved query directly onto the provider's assets/pagination/attributes/
+ // loading state.
+ const fetchAssets = useCallback(
+ (query = {}) =>
+ request(async () => {
+ const resolved = await resolveAssetsQuery(query);
+ setAssets(resolved.assets);
+ setPagination(resolved.pagination);
+ setAttributes(resolved.attributes);
+ return resolved.raw;
+ }),
+ [request, resolveAssetsQuery]
+ );
+
+ // Instance-local variant — used by AssetPickerSheet (and any other caller
+ // that needs its own independent list/pagination/loading rather than the
+ // single shared slice above). Multiple pickers can be mounted at once
+ // (one per media block on a page); sharing state here would mean opening
+ // one picker re-renders and stomps the list of every other mounted one.
+ const fetchAssetsList = useCallback(
+ async (query = {}) => {
+ try {
+ const resolved = await resolveAssetsQuery(query);
+ return { assets: resolved.assets, pagination: resolved.pagination };
+ } catch (err) {
+ const message = err?.response?.data?.message || err.message || "Something went wrong.";
+ toast(message);
+ return { assets: [], pagination: PAGINATION_INIT };
+ }
+ },
+ [resolveAssetsQuery]
);
// ─── POST /api/admin/media/tokens (batch) ────────────────────────────────
@@ -319,30 +362,43 @@ export function AssetsProvider({ children }) {
[request]
);
+ const value = useMemo(() => ({
+ assets,
+ attributes,
+ pagination,
+ selectedAsset,
+ loading,
+ setPagination,
+ setSelectedAsset,
+ fetchAssets,
+ fetchAssetsList,
+ fetchAsset,
+ fetchArchivedAssets,
+ updateAsset,
+ archiveAsset,
+ archiveAssets,
+ restoreAsset,
+ restoreAssets,
+ permanentlyDeleteAsset,
+ permanentlyDeleteAssets,
+ fetchAssetFieldValues
+ }), [
+ assets, attributes, pagination, selectedAsset, loading,
+ fetchAssets, fetchAssetsList, fetchAsset, fetchArchivedAssets,
+ updateAsset, archiveAsset, archiveAssets, restoreAsset, restoreAssets,
+ permanentlyDeleteAsset, permanentlyDeleteAssets, fetchAssetFieldValues,
+ ]);
+
+ const mediaTokensValue = useMemo(() => ({
+ mediaTokens,
+ getMediaTokens,
+ }), [mediaTokens, getMediaTokens]);
+
return (
-
- {children}
-
+
+
+ {children}
+
+
);
}
\ No newline at end of file
diff --git a/src/contexts/ClientCoursesContext.jsx b/src/contexts/ClientCoursesContext.jsx
index 9d6ae62..1c3168b 100644
--- a/src/contexts/ClientCoursesContext.jsx
+++ b/src/contexts/ClientCoursesContext.jsx
@@ -58,6 +58,7 @@ export function ClientCoursesProvider({ children }) {
} catch (err) {
if (err?.response?.status === 403) {
setCourseBlocked(true); // let the UI show an upgrade prompt
+ setCourse(err.response.data?.item ?? null); // trimmed shell — title/description/etc.
} else {
toast(err?.response?.data?.message ?? "Could not load course.");
}
diff --git a/src/modules/client/components/CourseUpsellModal.jsx b/src/modules/client/components/CourseUpsellModal.jsx
new file mode 100644
index 0000000..e834d19
--- /dev/null
+++ b/src/modules/client/components/CourseUpsellModal.jsx
@@ -0,0 +1,65 @@
+// CourseUpsellModal — shown when a learner reaches a locked Course's primary
+// action button (Proceed/Start Learning). Offers plan upgrade, or buying the
+// course individually when it has an active product. Shared by CourseList,
+// Dashboard, and CourseDetails.
+
+import { useNavigate } from "react-router-dom";
+import { LockIcon, Check, ShoppingCart } 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";
+import { useDateFormat } from "@/hooks/useDateFormat";
+
+export default function CourseUpsellModal({ open, onOpenChange, course, tierMap = {} }) {
+ const navigate = useNavigate();
+ const { fmtCurrency } = useDateFormat();
+
+ const slug = course?.subscription ?? "free";
+ const { rank, label, cls, panel } = resolveTierBadge(slug, tierMap);
+
+ return (
+
+ onOpenChange(false)}>Close
+ {course?.product?.is_active && (
+ { onOpenChange(false); navigate(`/course/${course.course_id}/checkout`); }}
+ >
+
+ Buy {fmtCurrency(course.product.price ?? 0, course.product.currency ?? "USD")}
+
+ )}
+ { onOpenChange(false); navigate("/subscriptions"); }}>
+ View Plans
+
+ >
+ }
+ >
+
+ {rank > 0 && (
+
+
+
+ {label}
+
+
+
+ Access to {label} content
+ Certificates & achievements
+
+
+ Upgrade to a {label} plan to unlock this course.
+
+
+ )}
+
+
+ );
+}
diff --git a/src/modules/client/components/LessonUpsellModal.jsx b/src/modules/client/components/LessonUpsellModal.jsx
index b24e4c7..40bad51 100644
--- a/src/modules/client/components/LessonUpsellModal.jsx
+++ b/src/modules/client/components/LessonUpsellModal.jsx
@@ -27,7 +27,7 @@ export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap
footer={
<>
onOpenChange(false)}>Close
- { onOpenChange(false); navigate("/plans"); }}>
+ { onOpenChange(false); navigate("/subscriptions"); }}>
View Plans
>
diff --git a/src/modules/client/components/LockedContentPanel.jsx b/src/modules/client/components/LockedContentPanel.jsx
index 1c85147..910e276 100644
--- a/src/modules/client/components/LockedContentPanel.jsx
+++ b/src/modules/client/components/LockedContentPanel.jsx
@@ -36,7 +36,7 @@ export default function LockedContentPanel({ course, item, tierMap = {} }) {
- navigate('/plans')} className="gap-1.5">
+ navigate('/subscriptions')} className="gap-1.5">
View Available Plans
diff --git a/src/modules/client/components/UnitUpsellModal.jsx b/src/modules/client/components/UnitUpsellModal.jsx
index 3914340..88d7874 100644
--- a/src/modules/client/components/UnitUpsellModal.jsx
+++ b/src/modules/client/components/UnitUpsellModal.jsx
@@ -25,7 +25,7 @@ export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {}
footer={
<>
onOpenChange(false)}>Close
-
{ onOpenChange(false); navigate("/plans"); }}>
+ { onOpenChange(false); navigate("/subscriptions"); }}>
View Plans
>
diff --git a/src/modules/client/components/blocks/ReadCourse.jsx b/src/modules/client/components/blocks/ReadCourse.jsx
index 00f7e77..947b5d9 100644
--- a/src/modules/client/components/blocks/ReadCourse.jsx
+++ b/src/modules/client/components/blocks/ReadCourse.jsx
@@ -99,7 +99,7 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
To complete this activity, subscribe to one of our available tier plans.
- navigate('/plans')}>
+ navigate('/subscriptions')}>
View Plans
@@ -125,7 +125,7 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
return (
navigate('/plans')}
+ onClick={() => navigate('/subscriptions')}
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-96 shrink-0 opacity-80"
>
@@ -144,7 +144,7 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
- { e.stopPropagation(); navigate('/plans'); }}>
+ { e.stopPropagation(); navigate('/subscriptions'); }}>
Upgrade to unlock
diff --git a/src/modules/client/components/blocks/ReadLesson.jsx b/src/modules/client/components/blocks/ReadLesson.jsx
index 65ab96b..ba99a43 100644
--- a/src/modules/client/components/blocks/ReadLesson.jsx
+++ b/src/modules/client/components/blocks/ReadLesson.jsx
@@ -69,7 +69,7 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
To complete this activity, subscribe to one of our available tier plans.
-
navigate('/plans')}>
+ navigate('/subscriptions')}>
View Plans
@@ -93,7 +93,7 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
return (
navigate('/plans')}
+ onClick={() => navigate('/subscriptions')}
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-96 shrink-0 opacity-80"
>
@@ -117,7 +117,7 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
- { e.stopPropagation(); navigate('/plans'); }}>
+ { e.stopPropagation(); navigate('/subscriptions'); }}>
Upgrade to unlock
diff --git a/src/modules/client/components/blocks/ReadUnit.jsx b/src/modules/client/components/blocks/ReadUnit.jsx
index b0de570..559683d 100644
--- a/src/modules/client/components/blocks/ReadUnit.jsx
+++ b/src/modules/client/components/blocks/ReadUnit.jsx
@@ -87,7 +87,7 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
To complete this activity, subscribe to one of our available tier plans.
- navigate('/plans')}>
+ navigate('/subscriptions')}>
View Plans
@@ -112,7 +112,7 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
return (
navigate('/plans')}
+ onClick={() => navigate('/subscriptions')}
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-96 shrink-0 opacity-80"
>
@@ -136,7 +136,7 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
- { e.stopPropagation(); navigate('/plans'); }}>
+ { e.stopPropagation(); navigate('/subscriptions'); }}>
Upgrade to unlock
diff --git a/src/modules/client/layout/ClientLayout.jsx b/src/modules/client/layout/ClientLayout.jsx
index c0eb802..d62f44f 100644
--- a/src/modules/client/layout/ClientLayout.jsx
+++ b/src/modules/client/layout/ClientLayout.jsx
@@ -361,8 +361,8 @@ function ClientNav() {
- navigate("/plans")}>
- Plans
+ navigate("/subscriptions")}>
+ Subscriptions
navigate("/profile")}>
Profile
diff --git a/src/modules/client/pages/AccountSettings.jsx b/src/modules/client/pages/AccountSettings.jsx
index 5910b26..e4a0eb2 100644
--- a/src/modules/client/pages/AccountSettings.jsx
+++ b/src/modules/client/pages/AccountSettings.jsx
@@ -169,7 +169,7 @@ function SubscriptionSection() {
Expires {expiresAt}
)}
{tier === "free" && (
- navigate("/plans")}>
+ navigate("/subscriptions")}>
Upgrade plan
)}
diff --git a/src/modules/client/pages/Checkout.jsx b/src/modules/client/pages/Checkout.jsx
index d8cd1c8..32f75d9 100644
--- a/src/modules/client/pages/Checkout.jsx
+++ b/src/modules/client/pages/Checkout.jsx
@@ -109,7 +109,7 @@ const Checkout = () => {
setCapturing(true);
captureOrder(returnToken).then((result) => {
if (result) {
- navigate("/plans", { replace: true });
+ navigate("/subscriptions", { replace: true });
} else {
setCapturing(false);
capturingRef.current = false;
@@ -123,7 +123,7 @@ const Checkout = () => {
const orderId = searchParams.get("token");
if (orderId) cancelOrder(orderId);
toast("PayPal checkout was cancelled.");
- navigate(`/plans/checkout?plan_id=${planId}`, { replace: true });
+ navigate(`/subscriptions/checkout?plan_id=${planId}`, { replace: true });
}, [wasCancelled]); // eslint-disable-line react-hooks/exhaustive-deps
// const personalInfo = user?.personal_info ?? {};
@@ -146,7 +146,7 @@ const Checkout = () => {
const breadcrumbItems = [
{ label: "Home", icon: , to: "/" },
- { label: "Plans", to: "/plans" },
+ { label: "Subscriptions", to: "/subscriptions" },
{ label: "Checkout" },
];
@@ -204,8 +204,8 @@ const Checkout = () => {
Select a subscription plan before continuing to checkout.
- navigate("/plans")}>
- Back to Plans
+ navigate("/subscriptions")}>
+ Back to Subscriptions
@@ -229,8 +229,8 @@ const Checkout = () => {
is not available for purchase at the moment.
- navigate("/plans")}>
- Back to Plans
+ navigate("/subscriptions")}>
+ Back to Subscriptions
diff --git a/src/modules/client/pages/CourseDetails.jsx b/src/modules/client/pages/CourseDetails.jsx
index 990bfae..625944f 100644
--- a/src/modules/client/pages/CourseDetails.jsx
+++ b/src/modules/client/pages/CourseDetails.jsx
@@ -26,12 +26,12 @@ import { useClientCourses } from "@/contexts/ClientCoursesContext";
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";
import { getTierColor } from "@/utils/tierColors";
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
import { Tags } from "lucide-react";
+import CourseUpsellModal from "../components/CourseUpsellModal";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -75,7 +75,7 @@ const useVisibleNodes = (refs, count) => {
// ─── Unit Accordion Block ─────────────────────────────────────────────────────
-const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle, isCompleted }) => {
+const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle, isCompleted, locked, onLockedClick }) => {
const navigate = useNavigate();
const quiz = unit.quiz ?? null;
@@ -129,7 +129,9 @@ const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle,
navigate(`/course/${courseId}/unit`, { state: { lessonId: lesson.lesson_id, unitId: unit.unit_id } })}
+ onClick={() => locked
+ ? onLockedClick?.()
+ : navigate(`/course/${courseId}/unit`, { state: { lessonId: lesson.lesson_id, unitId: unit.unit_id } })}
>
{isCompleted(lesson.uuid)
@@ -148,7 +150,9 @@ const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle,
{quiz && (
navigate(`/course/${courseId}/unit`, { state: { quizUnitId: unit.unit_id } })}
+ onClick={() => locked
+ ? onLockedClick?.()
+ : navigate(`/course/${courseId}/unit`, { state: { quizUnitId: unit.unit_id } })}
>
{quiz.has_passed
@@ -350,7 +354,7 @@ const CertCard = ({ courseTitle, courseLevel, badgeColor, badgeImageUrl, pending
// ─── Course Units (spine + cards) ─────────────────────────────────────────────
-const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, badgeColor, badgeImageUrl, onToggle, isCompleted, pendingCert, certificate, assessment, contentNotReady }) => {
+const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, badgeColor, badgeImageUrl, onToggle, isCompleted, pendingCert, certificate, assessment, contentNotReady, locked, onLockedClick }) => {
const wrapRef = useRef(null);
const cardRefs = useRef([]);
@@ -469,6 +473,8 @@ const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, badgeColo
courseId={courseId}
onToggle={measure}
isCompleted={isCompleted}
+ locked={locked}
+ onLockedClick={onLockedClick}
/>
);
}
@@ -520,6 +526,7 @@ const CourseDetails = () => {
const [tierMap, setTierMap] = useState({});
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
+ const [upsellOpen, setUpsellOpen] = useState(false);
useEffect(() => {
api.get("/client/tiers/categories")
.then(({ data }) => {
@@ -542,13 +549,20 @@ const CourseDetails = () => {
useEffect(() => {
getMyTier();
getCourse(courseId);
- fetchCourseProgress(courseId);
- fetchCourseProgressSummary(courseId);
getActiveAdvertisementList("course_details.banner");
return () => { resetCourse(); resetProgress(); setBadgeImageUrl(null); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courseId]);
+ // Progress is meaningless (and 403s) on a course the user can't access —
+ // only fetch it once getCourse has resolved to a real, unlocked course.
+ useEffect(() => {
+ if (!course || courseBlocked) return;
+ fetchCourseProgress(courseId);
+ fetchCourseProgressSummary(courseId);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [course, courseBlocked, courseId]);
+
const bannerAds = adLists["course_details.banner"] ?? [];
// Resolve badge image once course loads — issue a client stream token for
@@ -567,12 +581,6 @@ const CourseDetails = () => {
.catch(() => setBadgeImageUrl(course.badge_image_url ?? null));
}, [course?.badge_asset_id, course?.badge_image_url]);
- if (courseBlocked) {
- toast("You don't have access to this course. Upgrade your plan.");
- navigate("/course", { replace: true });
- return null;
- }
-
const items = [
{ label: "Home", icon: , to: `/dashboard` },
{ label: "Courses", to: `/course` },
@@ -631,13 +639,13 @@ const CourseDetails = () => {
)}
- navigate(`/course/${courseId}/unit`)}>
+ courseBlocked ? setUpsellOpen(true) : navigate(`/course/${courseId}/unit`)}>
{hasCompleted
? <> Start Again>
: <> Start Learning>
}
- navigate(`/course/${courseId}/unit`)}>
+ courseBlocked ? setUpsellOpen(true) : navigate(`/course/${courseId}/unit`)}>
{hasCompleted
? <> Start Again>
: <> Start Learning>
@@ -716,7 +724,7 @@ const CourseDetails = () => {
) : (
navigate(`/course/${courseId}/unit`)}
+ onClick={() => courseBlocked ? setUpsellOpen(true) : navigate(`/course/${courseId}/unit`)}
>
{hasCompleted
? <> Start Again>
@@ -743,19 +751,18 @@ const CourseDetails = () => {
- {/* Objectives */}
-
- {course?.objectives?.length > 0 && (
-
-
Learning Outcomes
-
- {course.objectives.map((obj) => (
- {obj.text}
- ))}
-
-
- )}
-
+ {/* Objectives — no wrapper rendered at all when empty, otherwise an
+ empty div still eats a gap-12 slot in the flex column below */}
+ {course?.objectives?.length > 0 && (
+
+
Learning Outcomes
+
+ {course.objectives.map((obj) => (
+ {obj.text}
+ ))}
+
+
+ )}
{/* Roles + Prerequisites */}
@@ -878,6 +885,8 @@ const CourseDetails = () => {
certificate={course.certificate ?? null}
assessment={course.assessment ?? null}
contentNotReady={contentNotReady}
+ locked={courseBlocked}
+ onLockedClick={() => setUpsellOpen(true)}
/>
>
)}
@@ -888,6 +897,13 @@ const CourseDetails = () => {
+
+
);
};
diff --git a/src/modules/client/pages/CourseList.jsx b/src/modules/client/pages/CourseList.jsx
index b2d8787..5fba72e 100644
--- a/src/modules/client/pages/CourseList.jsx
+++ b/src/modules/client/pages/CourseList.jsx
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
-import { House, Timer, ChevronLeft, ChevronRight, LockIcon, Tag, Tags, Check, ShoppingCart, Search } from "lucide-react";
+import { House, Timer, ChevronLeft, ChevronRight, LockIcon, Tag, Tags, Search } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Input } from "@/components/ui/input";
import {
@@ -9,12 +9,10 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
-import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { useClientCourses } from "@/contexts/ClientCoursesContext";
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";
import { Building2 } from "lucide-react";
@@ -163,7 +161,6 @@ const Pagination = ({ currentPage, totalPages, totalItems, itemsPerPage, onPageC
const CoursesList = () => {
const navigate = useNavigate();
const { courses, coursesLoading, getCourses } = useClientCourses();
- const { fmtCurrency } = useDateFormat();
const [tierCategories, setTierCategories] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
@@ -171,8 +168,6 @@ const CoursesList = () => {
const [search, setSearch] = useState("");
const [subFilter, setSubFilter] = useState("All");
const [categoryFilter, setCategoryFilter] = useState("All");
- const [modalOpen, setModalOpen] = useState(false);
- const [selectedCourse, setSelectedCourse] = useState(null);
const [allCategories, setAllCategories] = useState([]);
@@ -212,23 +207,13 @@ const CoursesList = () => {
const runSearch = () => { setSearch(searchInput); setCurrentPage(1); };
- const handleViewDetails = (course) => {
- if (course.is_locked) {
- setSelectedCourse(course);
- setModalOpen(true);
- } else {
- navigate(`/course/${course.course_id}`);
- }
- };
+ const handleViewDetails = (course) => navigate(`/course/${course.course_id}`);
const items = [
{ label: "Home", icon: , to: `/dashboard` },
{ label: "Courses" },
];
- // Upsell modal tier panel
- const upsellTier = selectedCourse ? tierMap[selectedCourse.subscription] : null;
-
return (
@@ -353,53 +338,6 @@ const CoursesList = () => {
)}
-
- {/* Upsell Modal */}
-
- setModalOpen(false)}>Close
- {selectedCourse?.product?.is_active && (
- { setModalOpen(false); navigate(`/course/${selectedCourse.course_id}/checkout`); }}
- >
-
- Buy {fmtCurrency(selectedCourse.product.price ?? 0, selectedCourse.product.currency ?? "USD")}
-
- )}
- { setModalOpen(false); navigate("/plans"); }}>
- View Plans
-
- >
- }
- >
-
- {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.
-
-
- );
- })()}
-
-
);
};
diff --git a/src/modules/client/pages/Dashboard.jsx b/src/modules/client/pages/Dashboard.jsx
index add53cf..eddd2a2 100644
--- a/src/modules/client/pages/Dashboard.jsx
+++ b/src/modules/client/pages/Dashboard.jsx
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Users, Timer,
- Tag, LockIcon, Check,
+ Tag, LockIcon,
} from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Badge } from "@/components/ui/badge";
@@ -13,11 +13,9 @@ import {
} 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 { 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";
@@ -39,13 +37,6 @@ function formatDuration(seconds = 0) {
return `${m}m`;
}
-// 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 }) => {
@@ -200,24 +191,16 @@ const Client = () => {
const { state: navState } = useLocation();
const { courses, coursesLoading, getCourses } = useClientCourses();
const { units, unitsLoading, getUnits, lessons, lessonsLoading, getLessons } = useLibrary();
- const { myTier, getMyTier, tierMap } = useClientTiers();
+ const { tierMap } = useClientTiers();
const { groups, fetchGroups, loading: groupLoading } = useGroup();
const {
adLists, listLoading, getActiveAdvertisementList,
handleAdCtaClick,
} = useClientAdvertisements();
- const [modalOpen, setModalOpen] = useState(false);
- const [selectedCourse, setSelectedCourse] = useState(null);
-
- const [unitModalOpen, setUnitModalOpen] = useState(false);
- const [selectedUnit, setSelectedUnit] = useState(null);
-
const [lessonModalOpen, setLessonModalOpen] = useState(false);
const [selectedLesson, setSelectedLesson] = useState(null);
- const userTier = myTier?.tier ?? "free";
-
const heroAds = adLists["dashboard.hero"] ?? [];
// Show welcome toast on first registration
@@ -245,7 +228,6 @@ const Client = () => {
getCourses();
getUnits();
getLessons();
- if (!myTier) getMyTier();
}, []);
useEffect(() => {
@@ -267,25 +249,11 @@ const Client = () => {
{ label: "My Groups", icon: },
];
- // ── Card click — mirrors CoursesList.jsx logic ────────────────────────────
- const handleViewDetails = (course) => {
- const accessible = canAccess(userTier, course.subscription, tierMap);
- if (!accessible) {
- setSelectedCourse(course);
- setModalOpen(true);
- } else {
- navigate(`/course/${course.course_id}`);
- }
- };
+ // ── Card click — always go to the detail page; locked items show their
+ // upgrade prompt there, on the primary action button ──────────────────────
+ const handleViewDetails = (course) => navigate(`/course/${course.course_id}`);
- const handleViewUnitDetails = (unit) => {
- if (unit.is_locked) {
- setSelectedUnit(unit);
- setUnitModalOpen(true);
- } else {
- navigate(`/units/${unit.uuid}`);
- }
- };
+ const handleViewUnitDetails = (unit) => navigate(`/units/${unit.uuid}`);
const handleViewLessonDetails = (lesson) => {
if (lesson.is_locked) {
@@ -420,54 +388,6 @@ const Client = () => {
- {/* ── Upsell Modal — only for locked courses ── */}
-
- setModalOpen(false)}>Close
- { setModalOpen(false); navigate("/plans"); }}>
- View Plans
-
- >
- }
- >
-
- {(() => {
- 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.
-
-
- );
- })()}
-
-
-
- {/* ── Upsell Modal — only for locked units ── */}
-
-
{/* ── Upsell Modal — only for locked lessons ── */}
{
const blockTypes = new Set((lesson?.blocks ?? []).map((b) => b.type));
const isVideoLesson = blockTypes.has("video") || blockTypes.has("text-video");
- // Admin-configured completion requirement (null when nothing's set — default behavior).
- const requirementDef = lesson?.completion?.type ? TYPE_DEFS[lesson.completion.type] : null;
- const RequirementIcon = requirementDef?.icon;
// read_all_content (or unconfigured/default) → scroll-to-bottom tracking, below.
// watch_percent / manual_complete → their own dedicated triggers — same dispatch
// pattern as UnitList.jsx / UnitReader.jsx, brought here for standalone lessons.
@@ -139,7 +136,7 @@ const LessonDetails = () => {
useEffect(() => {
if (!taskCtx?.has_task) return;
if (lessonCompletionType !== 'read_all_content') return;
- if (scrollProgress < 100 || !lesson?.uuid || hasCourse) return;
+ if (scrollProgress < 100 || !lesson?.uuid) return;
if (completedSessionRef.current.has(lesson.uuid)) return;
if (hasCompleted) return;
completedSessionRef.current.add(lesson.uuid);
@@ -160,14 +157,6 @@ const LessonDetails = () => {
await markComplete(lesson.uuid, unit?.uuid ?? null);
}, [lesson, unit, markComplete]);
- // ── Course-attached lessons hand off straight to the Unit reader — no
- // separate "Start Lesson" landing step in between.
- useEffect(() => {
- if (!lessonLoading && lesson && hasCourse && !contentNotReady) {
- navigate(`/units/${unit.uuid}/read`, { replace: true, state: { lessonId: lesson.lesson_id } });
- }
- }, [lessonLoading, lesson, hasCourse, contentNotReady, unit, navigate]);
-
const items = [
{ label: "Home", icon: , to: `/dashboard` },
{ label: "Lessons", to: `/lessons` },
@@ -187,7 +176,7 @@ const LessonDetails = () => {
);
}
- if (lessonLoading || !lesson || (hasCourse && !contentNotReady)) {
+ if (lessonLoading || !lesson) {
return (
@@ -205,7 +194,7 @@ const LessonDetails = () => {
tracked automatically" banner's promise), read_all_content lessons only,
fixed edge-to-edge under the navbar, same style as the Course > Unit >
Lesson reader's top bar. */}
- {taskCtx?.has_task && !hasCourse && !contentNotReady && lessonCompletionType === 'read_all_content' && (
+ {taskCtx?.has_task && !contentNotReady && lessonCompletionType === 'read_all_content' && (
{
{lesson.title}
{lesson.description ?? ""}
- {!hasCourse && (
-
-
- {formatDuration(lesson.duration_seconds) && (
- <>
- {formatDuration(lesson.duration_seconds)}
- ·
- >
- )}
-
- {isVideoLesson ? "Video lesson" : "Reading lesson"}
-
- {requirementDef && (
- <>
- ·
-
- {RequirementIcon && } {requirementDef.label}
-
- >
- )}
-
- {hasCompleted && (
-
-
- Success — you've completed this lesson.
-
+
+
+ {formatDuration(lesson.duration_seconds) && (
+ {formatDuration(lesson.duration_seconds)}
+ )}
+ {/* Reading/video type only means anything when there's an actual
+ task tracking this lesson — outside task mode there's nothing
+ being "completed", so hide it. The completion-requirement label
+ itself is dropped entirely — redundant with the lesson's own
+ working completion mechanism (scroll/watch/mark-complete). */}
+ {taskCtx?.has_task && (
+ <>
+ {formatDuration(lesson.duration_seconds) && · }
+
+ {isVideoLesson ? "Video lesson" : "Reading lesson"}
+
+ >
)}
+ {hasCompleted && (
+
+
+ Success — you've completed this lesson.
+
+ )}
+
+ {/* Optional — never forced. Lets a learner who wants the full
+ multi-lesson navigator (sidebar, quiz, up-next) opt into it,
+ instead of always being redirected there. */}
+ {hasUnit && (
+
navigate(`/units/${unit.uuid}/read`, { state: { lessonId: lesson.lesson_id } })}
+ >
+ Continue in "{unit.title}"
+
)}
diff --git a/src/modules/client/pages/PlanList.jsx b/src/modules/client/pages/PlanList.jsx
index 78d3bb7..ab2dd7a 100644
--- a/src/modules/client/pages/PlanList.jsx
+++ b/src/modules/client/pages/PlanList.jsx
@@ -321,9 +321,9 @@ export default function PlanList() {
return () => clearInterval(refundTimerRef.current);
}, [myTier?.active_tiers]);
- const handleViewPlan = (plan) => navigate(`/plans/view/${plan.plan_id}`);
+ const handleViewPlan = (plan) => navigate(`/subscriptions/view/${plan.plan_id}`);
- const handleSelectPlan = (plan) => navigate(`/plans/checkout?plan_id=${plan.plan_id}`);
+ const handleSelectPlan = (plan) => navigate(`/subscriptions/checkout?plan_id=${plan.plan_id}`);
const handleRefundClick = (plan) => setRefundPlan(plan);
@@ -369,7 +369,7 @@ export default function PlanList() {
return (
-
+
@@ -385,7 +385,7 @@ export default function PlanList() {
{/* Section Header */}
-
Available Plans
+
Available Subscriptions
Choose a subscription that matches your goals.
diff --git a/src/modules/client/pages/UnitDetails.jsx b/src/modules/client/pages/UnitDetails.jsx
index 03d0a54..db169b4 100644
--- a/src/modules/client/pages/UnitDetails.jsx
+++ b/src/modules/client/pages/UnitDetails.jsx
@@ -1,7 +1,7 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate } from "react-router-dom";
import {
- House, Timer, CheckCircle2, Check, CheckCheck, ClipboardList, Hourglass,
+ House, Timer, CheckCircle2, Check, CheckCheck, ClipboardList, Hourglass, Sparkles,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
@@ -9,11 +9,11 @@ import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { motion } from "framer-motion";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { PageMeta } from "@/contexts/MetadataContext";
-import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
+import UnitUpsellModal from "@/modules/client/components/UnitUpsellModal";
import { TYPE_DEFS } from "@/modules/admin/components/courses/completionRequirementTypes";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -26,6 +26,17 @@ function formatDuration(seconds = 0) {
return `${m}min`;
}
+// A unit under 5 lessons is assumed still being built out — nudge learners
+// that more is coming rather than let it read as a thin/finished unit.
+const ESTABLISHED_LESSON_COUNT = 5;
+
+const GrowingUnitNote = () => (
+
+
+ This unit is constantly improving — more lessons will be added soon.
+
+);
+
// ─── Lessons roadmap (single unit — lessons in order + trailing quiz row) ─────
const LessonsRoadmap = ({ unitDetail, currentLessonId, onLessonClick, onContinue, onQuizClick }) => {
@@ -122,8 +133,12 @@ const UnitDetails = () => {
const { getUnitDetail, unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo, resetUnitDetail } = useLibrary();
const { tierMap, getTierCategories } = useClientTiers();
+ const [upsellOpen, setUpsellOpen] = useState(false);
- const contentNotReady = !unitDetail?.duration_seconds;
+ // Locked units never get unitDetail (403'd) — fall back to the trimmed
+ // shell info so the header still renders title/description/duration.
+ const displayUnit = unitDetail ?? unitBlockedInfo?.item;
+ const contentNotReady = !displayUnit?.duration_seconds;
useEffect(() => {
getTierCategories();
@@ -135,20 +150,9 @@ const UnitDetails = () => {
const items = [
{ label: "Home", icon:
, to: `/dashboard` },
{ label: "Units", to: `/units` },
- { label: unitDetail?.title ?? "Unit" },
+ { label: displayUnit?.title ?? "Unit" },
];
- // ── Deep-link to a locked unit — inline blocked panel, not a redirect ────
- if (unitBlocked) {
- return (
-
- );
- }
-
if (unitDetailLoading) {
return (
@@ -164,8 +168,14 @@ const UnitDetails = () => {
const currentLesson = lessons.find((l) => l.status !== "completed") ?? null;
const progressPct = lessons.length > 0 ? Math.round((completedCount / lessons.length) * 100) : 0;
- // Admin-configured completion requirement for the unit itself (null when nothing's set).
- const requirementDef = unitDetail?.completion?.type ? TYPE_DEFS[unitDetail.completion.type] : null;
+ // Locked unit's own lessons/quiz — titles/durations only, a preview outline
+ // that mirrors the unlocked layout but every row opens the upsell modal.
+ const blockedLessons = unitBlockedInfo?.item?.lessons ?? [];
+ const blockedQuiz = unitBlockedInfo?.item?.quiz ?? null;
+
+ // Units always complete by passing their quiz now (registry.js hard-overrides
+ // this regardless of what's actually configured) — reflect that here too.
+ const requirementDef = TYPE_DEFS.pass_quiz;
const RequirementIcon = requirementDef?.icon;
const handleLessonClick = (lesson) => {
@@ -180,14 +190,14 @@ const UnitDetails = () => {
return (
-
+
Unit
-
{unitDetail?.title ?? "Unit"}
-
{unitDetail?.description ?? ""}
+
{displayUnit?.title ?? "Unit"}
+
{displayUnit?.description ?? ""}
{contentNotReady ? (
@@ -195,6 +205,26 @@ const UnitDetails = () => {
This unit is currently being prepared. Please check back later.
+ ) : unitBlocked ? (
+ <>
+
+ {blockedLessons.length} {blockedLessons.length === 1 ? "lesson" : "lessons"}
+ ·
+ {formatDuration(displayUnit.duration_seconds) ?? "—"} total
+
+
+
+
Lessons in this unit
+ setUpsellOpen(true)}
+ onContinue={() => setUpsellOpen(true)}
+ onQuizClick={() => setUpsellOpen(true)}
+ />
+ {blockedLessons.length > 0 && blockedLessons.length < ESTABLISHED_LESSON_COUNT && }
+
+ >
) : (
<>
@@ -231,10 +261,22 @@ const UnitDetails = () => {
onContinue={handleContinue}
onQuizClick={handleQuizClick}
/>
+ {lessons.length > 0 && lessons.length < ESTABLISHED_LESSON_COUNT && }
>
)}
+
+
);
};
diff --git a/src/modules/client/pages/UnitList.jsx b/src/modules/client/pages/UnitList.jsx
index 76f4bcc..044afe4 100644
--- a/src/modules/client/pages/UnitList.jsx
+++ b/src/modules/client/pages/UnitList.jsx
@@ -668,7 +668,9 @@ const UnitList = () => {
// ── "How to complete" help text — this lesson's / this unit's / this course's
// configured completion requirement (or the default implicit rule).
- const unitCompletionType = currentUnit?.completion?.type ?? 'read_all_content';
+ // Units always complete by passing their quiz now (registry.js hard-overrides
+ // this regardless of what's actually configured) — reflect that here too.
+ const unitCompletionType = 'pass_quiz';
const courseCompletionType = course?.completion?.type ?? 'read_all_content';
const lessonRequirementText = TYPE_DEFS[selectedLessonCompletionType]?.describe('lesson');
const unitRequirementText = TYPE_DEFS[unitCompletionType]?.describe('unit');
@@ -848,7 +850,7 @@ const UnitList = () => {
-
navigate('/plans')} className="gap-1.5">
+ navigate('/subscriptions')} className="gap-1.5">
View Available Plans
Already subscribed? Your plan may not cover this subscription.
diff --git a/src/modules/client/pages/UnitReader.jsx b/src/modules/client/pages/UnitReader.jsx
index 84a7919..07c0259 100644
--- a/src/modules/client/pages/UnitReader.jsx
+++ b/src/modules/client/pages/UnitReader.jsx
@@ -204,7 +204,9 @@ const UnitReader = () => {
// ── "How to complete" help text — explains this lesson's and this unit's
// configured completion requirement (or the default implicit rule).
- const unitCompletionType = unitDetail?.completion?.type ?? 'read_all_content';
+ // Units always complete by passing their quiz now (registry.js hard-overrides
+ // this regardless of what's actually configured) — reflect that here too.
+ const unitCompletionType = 'pass_quiz';
const lessonRequirementText = TYPE_DEFS[lessonCompletionType]?.describe('lesson');
const unitRequirementText = TYPE_DEFS[unitCompletionType]?.describe('unit');
@@ -390,7 +392,7 @@ const UnitReader = () => {
To access this unit, subscribe to one of our available tier plans.
-
navigate('/plans')} className="gap-1.5">
+ navigate('/subscriptions')} className="gap-1.5">
View Available Plans
diff --git a/src/modules/client/pages/UnitsList.jsx b/src/modules/client/pages/UnitsList.jsx
index 18ef6ed..ed7d4e7 100644
--- a/src/modules/client/pages/UnitsList.jsx
+++ b/src/modules/client/pages/UnitsList.jsx
@@ -9,7 +9,6 @@ import {
import { Button } from "@/components/ui/button";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
-import UnitUpsellModal from "../components/UnitUpsellModal";
import { UnitCard, UnitCardSkeleton } from "../components/UnitCard";
import { PageMeta } from "@/contexts/MetadataContext";
@@ -73,8 +72,6 @@ const UnitsList = () => {
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [lockFilter, setLockFilter] = useState("All");
- const [modalOpen, setModalOpen] = useState(false);
- const [selectedUnit, setSelectedUnit] = useState(null);
useEffect(() => {
getUnits();
@@ -101,14 +98,7 @@ const UnitsList = () => {
const runSearch = () => { setSearch(searchInput); setCurrentPage(1); };
- const handleViewDetails = (unit) => {
- if (unit.is_locked) {
- setSelectedUnit(unit);
- setModalOpen(true);
- } else {
- navigate(`/units/${unit.uuid}`);
- }
- };
+ const handleViewDetails = (unit) => navigate(`/units/${unit.uuid}`);
const items = [
{ label: "Home", icon:
, to: `/dashboard` },
@@ -197,13 +187,6 @@ const UnitsList = () => {
)}
-
-
);
};
diff --git a/src/modules/client/pages/ViewPlan.jsx b/src/modules/client/pages/ViewPlan.jsx
index 3a3ffc0..6bdbc91 100644
--- a/src/modules/client/pages/ViewPlan.jsx
+++ b/src/modules/client/pages/ViewPlan.jsx
@@ -111,10 +111,10 @@ const ViewPlan = () => {
navigate("/plans")}
+ onClick={() => navigate("/subscriptions")}
className="flex items-center gap-1.5 text-white/70 hover:text-white text-sm mb-8 transition-colors"
>
- Back to Plans
+ Back to Subscriptions
@@ -269,7 +269,7 @@ const ViewPlan = () => {
navigate(`/plans/checkout?plan_id=${plan.plan_id}`)}
+ onClick={() => navigate(`/subscriptions/checkout?plan_id=${plan.plan_id}`)}
>
Get {tierLabel} Plan — {fmtCurrency(plan.price, plan.currency)}
diff --git a/src/modules/client/pages/ViewRequirement.jsx b/src/modules/client/pages/ViewRequirement.jsx
index 11dd1d7..1c3307b 100644
--- a/src/modules/client/pages/ViewRequirement.jsx
+++ b/src/modules/client/pages/ViewRequirement.jsx
@@ -341,7 +341,7 @@ const LockedContent = () => {
-
navigate('/plans')} className="gap-1.5">
+ navigate('/subscriptions')} className="gap-1.5">
View Available Plans
Already subscribed? Your plan may not cover this subscription.
diff --git a/src/modules/client/routes/ClientRoutes.jsx b/src/modules/client/routes/ClientRoutes.jsx
index 15bcb62..3d4c0c0 100644
--- a/src/modules/client/routes/ClientRoutes.jsx
+++ b/src/modules/client/routes/ClientRoutes.jsx
@@ -71,7 +71,7 @@ export const ClientRoutes = {
{ path: 'notifications', element: },
{ path: 'ads/:uuid', element: },
{
- path: 'plans', element: ,
+ path: 'subscriptions', element: ,
children: [
// { index: true, element: },
{ index: true, element: },
diff --git a/src/utils/link.util.js b/src/utils/link.util.js
index 86ac082..5ee2a42 100644
--- a/src/utils/link.util.js
+++ b/src/utils/link.util.js
@@ -17,9 +17,9 @@ export const CLIENT_ROUTE_PATHS = [
"settings",
"notifications",
"ads/:uuid",
- "plans",
- "plans/view/:id",
- "plans/checkout",
+ "subscriptions",
+ "subscriptions/view/:id",
+ "subscriptions/checkout",
"course",
"course/:id",
"course/:id/unit",