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
+ {course?.product?.is_active && ( + + )} + + + } + > +
+ {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={ <> - 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 = {} }) {
-
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={ <> - 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.

-
@@ -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,

-
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.

-
@@ -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,

-
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.

- @@ -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

-
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" && ( - )} 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.

- @@ -229,8 +229,8 @@ const Checkout = () => { is not available for purchase at the moment.

- 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 = () => { )} - -
- {/* 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 */} - - - {selectedCourse?.product?.is_active && ( - - )} - - - } - > -
- {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 ── */} - - - - - } - > -
- {(() => { - 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)} - · - - )} - - - {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) && ·} + + + )}
+ {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 && ( + )}
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 = () => {

-

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.

-
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 = () => {
@@ -269,7 +269,7 @@ const ViewPlan = () => { 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 = () => {

-

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",