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. */}
{/* ── 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
- 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.
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.
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")}>
- 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,
+ {/* 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 && (
+
+ {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/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