debug: assets picker

Signed-off-by: rgrgogu <obsequio.rus@gmail.com>
This commit is contained in:
rgrgogu
2026-08-15 11:09:30 +08:00
parent 8174987382
commit 2de6e58488
27 changed files with 457 additions and 383 deletions
+70 -20
View File
@@ -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 }) {
)}
</button>
);
}
});
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 <Sheet> close transition. Visibility is controlled by
// <Sheet open={open}> below instead, so state (and the caches in
// AdminAssetsContext) survive across open/close toggles.
// <Sheet open={open}> 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) => {
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
@@ -304,10 +345,15 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
</div>
{/* ── 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. */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="flex items-center justify-center h-48">
<Spinner className="h-5 w-5" />
{showSpinner ? (
<div className="flex items-center justify-center h-full min-h-[24rem]">
<Spinner className="h-6 w-6" />
</div>
) : !assets.length ? (
<EmptyState fileType={fileType ?? "asset"} />
@@ -327,7 +373,11 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
</div>
{/* ── 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 && (
<div className="flex items-center justify-between px-6 py-3 border-t text-sm">
<span className="text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
@@ -335,7 +385,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
<div className="flex gap-2">
<button
type="button"
disabled={!pagination.hasPrevPage || loading}
disabled={!pagination.hasPrevPage || showSpinner}
onClick={() => 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
</button>
<button
type="button"
disabled={!pagination.hasNextPage || loading}
disabled={!pagination.hasNextPage || showSpinner}
onClick={() => setPage((p) => p + 1)}
className="px-3 py-1 rounded border text-xs disabled:opacity-40 hover:bg-muted transition-colors"
>
@@ -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}`) };
+84 -28
View File
@@ -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,23 +91,22 @@ 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,
@@ -103,24 +116,54 @@ export function AssetsProvider({ children }) {
});
const result = data?.data;
setAssets(result?.data ?? []);
setPagination(result?.pagination ?? PAGINATION_INIT);
setAttributes(result.attributes);
seedMediaTokensFromRows(result?.data);
listCacheRef.current.set(key, {
const resolved = {
assets: result?.data ?? [],
pagination: result?.pagination ?? PAGINATION_INIT,
attributes: result.attributes,
raw: data.data,
fetchedAt: Date.now(),
});
};
return data.data;
});
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,18 +362,16 @@ export function AssetsProvider({ children }) {
[request]
);
return (
<AssetsContext.Provider value={{
const value = useMemo(() => ({
assets,
attributes,
pagination,
selectedAsset,
loading,
mediaTokens,
getMediaTokens,
setPagination,
setSelectedAsset,
fetchAssets,
fetchAssetsList,
fetchAsset,
fetchArchivedAssets,
updateAsset,
@@ -341,8 +382,23 @@ export function AssetsProvider({ children }) {
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 (
<MediaTokensContext.Provider value={mediaTokensValue}>
<AssetsContext.Provider value={value}>
{children}
</AssetsContext.Provider>
</MediaTokensContext.Provider>
);
}
+1
View File
@@ -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.");
}
@@ -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 (
<ResponsiveModal
open={open}
onOpenChange={onOpenChange}
title={course?.title ?? "Course Details"}
description={course?.product ? "Purchase this course or upgrade your plan." : "Upgrade your plan to access this course."}
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
{course?.product?.is_active && (
<Button
variant="outline"
onClick={() => { onOpenChange(false); navigate(`/course/${course.course_id}/checkout`); }}
>
<ShoppingCart className="size-4" />
Buy {fmtCurrency(course.product.price ?? 0, course.product.currency ?? "USD")}
</Button>
)}
<Button onClick={() => { onOpenChange(false); navigate("/subscriptions"); }}>
<LockIcon /> View Plans
</Button>
</>
}
>
<div className="space-y-6 py-2">
{rank > 0 && (
<div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}>
<div className="flex items-center gap-3 mb-3">
<Badge className={cls}>
<LockIcon className="size-3" /> {label}
</Badge>
</div>
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4">
<li className="flex items-center gap-2"><Check /> Access to {label} content</li>
<li className="flex items-center gap-2"><Check /> Certificates & achievements</li>
</ul>
<p className="text-sm text-muted-foreground">
Upgrade to a <span className="font-medium">{label}</span> plan to unlock this course.
</p>
</div>
)}
</div>
</ResponsiveModal>
);
}
@@ -27,7 +27,7 @@ export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
<Button onClick={() => { onOpenChange(false); navigate("/plans"); }}>
<Button onClick={() => { onOpenChange(false); navigate("/subscriptions"); }}>
<LockIcon /> View Plans
</Button>
</>
@@ -36,7 +36,7 @@ export default function LockedContentPanel({ course, item, tierMap = {} }) {
</div>
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
<Button onClick={() => navigate('/plans')} className="gap-1.5">
<Button onClick={() => navigate('/subscriptions')} className="gap-1.5">
<Zap className="size-4" /> View Available Plans
</Button>
</div>
@@ -25,7 +25,7 @@ export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {}
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
<Button onClick={() => { onOpenChange(false); navigate("/plans"); }}>
<Button onClick={() => { onOpenChange(false); navigate("/subscriptions"); }}>
<LockIcon /> View Plans
</Button>
</>
@@ -99,7 +99,7 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
To complete this activity, subscribe to one of our available tier plans.
</p>
</div>
<Button size="sm" variant="outline" className="shrink-0 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40" onClick={() => navigate('/plans')}>
<Button size="sm" variant="outline" className="shrink-0 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40" onClick={() => navigate('/subscriptions')}>
<Zap className="size-3.5" /> View Plans
</Button>
</div>
@@ -125,7 +125,7 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
return (
<div
key={course.id}
onClick={() => 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"
>
<div className="flex items-center gap-1.5">
@@ -144,7 +144,7 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
</p>
</div>
<div className="mt-auto">
<Button size="sm" className="w-full gap-1.5" onClick={(e) => { e.stopPropagation(); navigate('/plans'); }}>
<Button size="sm" className="w-full gap-1.5" onClick={(e) => { e.stopPropagation(); navigate('/subscriptions'); }}>
<Zap className="size-3.5" /> Upgrade to unlock
</Button>
</div>
@@ -69,7 +69,7 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
To complete this activity, subscribe to one of our available tier plans.
</p>
</div>
<Button size="sm" variant="outline" className="shrink-0 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40" onClick={() => navigate('/plans')}>
<Button size="sm" variant="outline" className="shrink-0 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40" onClick={() => navigate('/subscriptions')}>
<Zap className="size-3.5" /> View Plans
</Button>
</div>
@@ -93,7 +93,7 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
return (
<div
key={lesson.id}
onClick={() => 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"
>
<div className="flex items-center gap-1.5">
@@ -117,7 +117,7 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
</p>
</div>
<div className="mt-auto">
<Button size="sm" className="w-full gap-1.5" onClick={(e) => { e.stopPropagation(); navigate('/plans'); }}>
<Button size="sm" className="w-full gap-1.5" onClick={(e) => { e.stopPropagation(); navigate('/subscriptions'); }}>
<Zap className="size-3.5" /> Upgrade to unlock
</Button>
</div>
@@ -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.
</p>
</div>
<Button size="sm" variant="outline" className="shrink-0 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40" onClick={() => navigate('/plans')}>
<Button size="sm" variant="outline" className="shrink-0 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40" onClick={() => navigate('/subscriptions')}>
<Zap className="size-3.5" /> View Plans
</Button>
</div>
@@ -112,7 +112,7 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
return (
<div
key={unit.id}
onClick={() => 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"
>
<div className="flex items-center gap-1.5">
@@ -136,7 +136,7 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
</p>
</div>
<div className="mt-auto">
<Button size="sm" className="w-full gap-1.5" onClick={(e) => { e.stopPropagation(); navigate('/plans'); }}>
<Button size="sm" className="w-full gap-1.5" onClick={(e) => { e.stopPropagation(); navigate('/subscriptions'); }}>
<Zap className="size-3.5" /> Upgrade to unlock
</Button>
</div>
+2 -2
View File
@@ -361,8 +361,8 @@ function ClientNav() {
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem className="bg-gradient-to-r from-[#0e7490] via-[#3b82f6] to-[#4f46e5] text-white" onClick={() => navigate("/plans")}>
<Zap /> Plans
<DropdownMenuItem className="bg-gradient-to-r from-[#0e7490] via-[#3b82f6] to-[#4f46e5] text-white" onClick={() => navigate("/subscriptions")}>
<Zap /> Subscriptions
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/profile")}>
<User /> Profile
+1 -1
View File
@@ -169,7 +169,7 @@ function SubscriptionSection() {
<span className="text-sm text-muted-foreground">Expires {expiresAt}</span>
)}
{tier === "free" && (
<Button size="sm" variant="outline" onClick={() => navigate("/plans")}>
<Button size="sm" variant="outline" onClick={() => navigate("/subscriptions")}>
Upgrade plan <ChevronRight className="size-3.5" />
</Button>
)}
+7 -7
View File
@@ -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: <House className="size-4" />, 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.
</p>
</div>
<Button onClick={() => navigate("/plans")}>
<ArrowLeft className="size-4" /> Back to Plans
<Button onClick={() => navigate("/subscriptions")}>
<ArrowLeft className="size-4" /> Back to Subscriptions
</Button>
</CardContent>
</Card>
@@ -229,8 +229,8 @@ const Checkout = () => {
is not available for purchase at the moment.
</p>
</div>
<Button onClick={() => navigate("/plans")}>
<ArrowLeft className="size-4" /> Back to Plans
<Button onClick={() => navigate("/subscriptions")}>
<ArrowLeft className="size-4" /> Back to Subscriptions
</Button>
</CardContent>
</Card>
+35 -19
View File
@@ -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,
<div
key={lesson.lesson_id}
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-200 dark:hover:bg-blue-500 transition-colors cursor-pointer"
onClick={() => 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 } })}
>
<div className="flex items-center gap-3 select-none min-w-0">
{isCompleted(lesson.uuid)
@@ -148,7 +150,9 @@ const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle,
{quiz && (
<div
className="flex items-center justify-between py-2 px-3 mt-1 rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/60 dark:bg-blue-950/30 hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors cursor-pointer"
onClick={() => navigate(`/course/${courseId}/unit`, { state: { quizUnitId: unit.unit_id } })}
onClick={() => locked
? onLockedClick?.()
: navigate(`/course/${courseId}/unit`, { state: { quizUnitId: unit.unit_id } })}
>
<div className="flex items-center gap-3 select-none min-w-0">
{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: <House className="size-4" />, to: `/dashboard` },
{ label: "Courses", to: `/course` },
@@ -631,13 +639,13 @@ const CourseDetails = () => {
)}
</DialogContent>
</Dialog>
<Button size="sm" className="lg:hidden" onClick={() => navigate(`/course/${courseId}/unit`)}>
<Button size="sm" className="lg:hidden" onClick={() => courseBlocked ? setUpsellOpen(true) : navigate(`/course/${courseId}/unit`)}>
{hasCompleted
? <><CheckCheck /> Start Again</>
: <><SendHorizonal /> Start Learning</>
}
</Button>
<Button size="default" className="hidden lg:inline-flex" onClick={() => navigate(`/course/${courseId}/unit`)}>
<Button size="default" className="hidden lg:inline-flex" onClick={() => courseBlocked ? setUpsellOpen(true) : navigate(`/course/${courseId}/unit`)}>
{hasCompleted
? <><CheckCheck /> Start Again</>
: <><SendHorizonal /> Start Learning</>
@@ -716,7 +724,7 @@ const CourseDetails = () => {
) : (
<Button
className="w-fit bg-blue-500"
onClick={() => navigate(`/course/${courseId}/unit`)}
onClick={() => courseBlocked ? setUpsellOpen(true) : navigate(`/course/${courseId}/unit`)}
>
{hasCompleted
? <><CheckCheck /> Start Again</>
@@ -743,8 +751,8 @@ const CourseDetails = () => {
</div>
{/* Objectives */}
<div className="space-y-4">
{/* 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 && (
<div className="space-y-4">
<div className="font-bold text-2xl">Learning Outcomes</div>
@@ -755,7 +763,6 @@ const CourseDetails = () => {
</ul>
</div>
)}
</div>
{/* Roles + Prerequisites */}
<div className="max-w-3xl space-y-6">
@@ -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 = () => {
</div>
</div>
</div>
<CourseUpsellModal
open={upsellOpen}
onOpenChange={setUpsellOpen}
course={course}
tierMap={tierMap}
/>
</div>
);
};
+2 -64
View File
@@ -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: <House className="size-4" />, to: `/dashboard` },
{ label: "Courses" },
];
// Upsell modal tier panel
const upsellTier = selectedCourse ? tierMap[selectedCourse.subscription] : null;
return (
<div>
<PageMeta title="Courses - STARR" description="Browse your available training courses." />
@@ -353,53 +338,6 @@ const CoursesList = () => {
)}
</div>
</div>
{/* Upsell Modal */}
<ResponsiveModal
open={modalOpen}
onOpenChange={setModalOpen}
title={selectedCourse?.title ?? "Course Details"}
description={selectedCourse?.product ? "Purchase this course or upgrade your plan." : "Upgrade your plan to access this course."}
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Close</Button>
{selectedCourse?.product?.is_active && (
<Button
variant="outline"
onClick={() => { setModalOpen(false); navigate(`/course/${selectedCourse.course_id}/checkout`); }}
>
<ShoppingCart className="size-4" />
Buy {fmtCurrency(selectedCourse.product.price ?? 0, selectedCourse.product.currency ?? "USD")}
</Button>
)}
<Button onClick={() => { setModalOpen(false); navigate("/plans"); }}>
<LockIcon /> View Plans
</Button>
</>
}
>
<div className="space-y-6 py-2">
{upsellTier && !upsellTier.is_default && (() => {
const { cls, panel } = resolveTierBadge(selectedCourse?.subscription ?? "", tierMap);
return (
<div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}>
<div className="flex items-center gap-3 mb-3">
<Badge className={cls}>
<LockIcon className="size-3" /> {upsellTier.name}
</Badge>
</div>
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4">
<li className="flex items-center gap-2"><Check /> Access to {upsellTier.name} content</li>
<li className="flex items-center gap-2"><Check /> Certificates & achievements</li>
</ul>
<p className="text-sm text-muted-foreground">
Upgrade to a <span className="font-medium">{upsellTier.name}</span> plan to unlock this course.
</p>
</div>
);
})()}
</div>
</ResponsiveModal>
</div>
);
};
+6 -86
View File
@@ -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: <Users className="size-4" /> },
];
// ── 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 = () => {
</div>
</div>
{/* ── Upsell Modal — only for locked courses ── */}
<ResponsiveModal
open={modalOpen}
onOpenChange={setModalOpen}
title={selectedCourse?.title ?? "Course Details"}
description="Upgrade your plan to access this course."
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Close</Button>
<Button onClick={() => { setModalOpen(false); navigate("/plans"); }}>
<LockIcon /> View Plans
</Button>
</>
}
>
<div className="space-y-6 py-2">
{(() => {
const slug = selectedCourse?.subscription ?? "free";
const { rank, label, cls, panel } = resolveTierBadge(slug, tierMap);
if (rank === 0) return null;
return (
<div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}>
<div className="flex items-center gap-3 mb-3">
<Badge className={cls}>
<LockIcon className="size-3" /> {label}
</Badge>
</div>
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4">
<li className="flex items-center gap-2"><Check /> Access to {label} content</li>
<li className="flex items-center gap-2"><Check /> Certificates & achievements</li>
</ul>
<p className="text-sm text-muted-foreground">
Upgrade to a <span className="font-medium">{label}</span> plan to unlock this course.
</p>
</div>
);
})()}
</div>
</ResponsiveModal>
{/* ── Upsell Modal — only for locked units ── */}
<UnitUpsellModal
open={unitModalOpen}
onOpenChange={setUnitModalOpen}
unit={selectedUnit}
tierMap={tierMap}
/>
{/* ── Upsell Modal — only for locked lessons ── */}
<LessonUpsellModal
open={lessonModalOpen}
+25 -26
View File
@@ -1,9 +1,10 @@
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate, useLocation } from "react-router-dom";
import {
House, CheckCheck, Check, Hourglass, Clock, Video, ListChecks,
House, CheckCheck, Check, Hourglass, Clock, Video, ListChecks, ArrowUpRight,
} from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { useCallback, useEffect, useRef, useState } from "react";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
@@ -11,7 +12,6 @@ import { PageMeta } from "@/contexts/MetadataContext";
import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
import LessonBlock from "../components/LessonBlock.jsx";
import MarkCompleteButton from "@/modules/client/components/MarkCompleteButton.jsx";
import { TYPE_DEFS } from "@/modules/admin/components/courses/completionRequirementTypes";
import api from "@/utils/api.util";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -64,9 +64,6 @@ const LessonDetails = () => {
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: <House className="size-4" />, to: `/dashboard` },
{ label: "Lessons", to: `/lessons` },
@@ -187,7 +176,7 @@ const LessonDetails = () => {
);
}
if (lessonLoading || !lesson || (hasCourse && !contentNotReady)) {
if (lessonLoading || !lesson) {
return (
<div className="my-17 p-8 lg:container lg:mx-auto space-y-4">
<Skeleton className="h-4 w-48" />
@@ -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' && (
<div
className="fixed left-0 right-0 z-30 h-1.5 bg-border"
style={{ top: "var(--navbar-h)" }}
@@ -241,24 +230,22 @@ const LessonDetails = () => {
<div className="flex flex-col gap-3 max-w-2xl">
<h1 className="font-bold xs:text-2xl lg:text-3xl">{lesson.title}</h1>
<p className="text-muted-foreground">{lesson.description ?? ""}</p>
{!hasCourse && (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground [&_svg]:size-4">
{formatDuration(lesson.duration_seconds) && (
<>
<span className="flex items-center gap-1"><Clock /> {formatDuration(lesson.duration_seconds)}</span>
<span>·</span>
</>
)}
{/* 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) && <span>·</span>}
<span className="flex items-center gap-1">
<Video /> {isVideoLesson ? "Video lesson" : "Reading lesson"}
</span>
{requirementDef && (
<>
<span>·</span>
<span className="flex items-center gap-1">
{RequirementIcon && <RequirementIcon />} {requirementDef.label}
</span>
</>
)}
</div>
@@ -269,6 +256,18 @@ const LessonDetails = () => {
</div>
)}
</div>
{/* 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 && (
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={() => navigate(`/units/${unit.uuid}/read`, { state: { lessonId: lesson.lesson_id } })}
>
Continue in "{unit.title}" <ArrowUpRight className="size-4" />
</Button>
)}
</div>
+4 -4
View File
@@ -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 (
<div className="mt-17">
<PageMeta title="Plans - STARR" description="Browse available subscription plans." />
<PageMeta title="Subscriptions - STARR" description="Browse available subscription plans." />
<div className="relative overflow-hidden bg-gradient-to-b from-primary/5 via-background to-background min-h-screen">
<div className="lg:container lg:mx-auto space-y-8 p-6">
<div className="relative xs:pt-2 lg:pt-8 space-y-4">
@@ -385,7 +385,7 @@ export default function PlanList() {
{/* Section Header */}
<div className="relative flex flex-col items-center gap-4 mt-6">
<div className="text-center my-8">
<h2 className="text-3xl font-bold">Available Plans</h2>
<h2 className="text-3xl font-bold">Available Subscriptions</h2>
<p className="text-muted-foreground mt-2">
Choose a subscription that matches your goals.
</p>
+63 -21
View File
@@ -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 = () => (
<div className="flex items-center gap-2 rounded-lg border border-dashed bg-muted/40 px-4 py-2.5 text-sm text-muted-foreground w-fit">
<Sparkles className="size-4 shrink-0" />
This unit is constantly improving — more lessons will be added soon.
</div>
);
// ─── 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: <House className="size-4" />, 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 (
<LockedContentPanel
course={unitBlockedInfo?.course}
item={unitBlockedInfo?.item}
tierMap={tierMap}
/>
);
}
if (unitDetailLoading) {
return (
<div className="my-17 p-8 lg:container lg:mx-auto space-y-4">
@@ -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 (
<div>
<PageMeta title={unitDetail ? `${unitDetail.title} - STARR` : undefined} description={unitDetail?.description} />
<PageMeta title={displayUnit ? `${displayUnit.title} - STARR` : undefined} description={displayUnit?.description} />
<div className="xs:py-24 xs:px-8 lg:px-0 lg:py-28 flex flex-col gap-6 lg:container lg:mx-auto lg:max-w-2xl">
<AppBreadcrumb items={items} />
<div className="flex flex-col gap-3">
<span className="text-xs font-semibold tracking-wide text-blue-600 dark:text-blue-400 uppercase">Unit</span>
<h1 className="font-bold xs:text-2xl lg:text-4xl">{unitDetail?.title ?? "Unit"}</h1>
<p className="text-muted-foreground lg:text-lg">{unitDetail?.description ?? ""}</p>
<h1 className="font-bold xs:text-2xl lg:text-4xl">{displayUnit?.title ?? "Unit"}</h1>
<p className="text-muted-foreground lg:text-lg">{displayUnit?.description ?? ""}</p>
</div>
{contentNotReady ? (
@@ -195,6 +205,26 @@ const UnitDetails = () => {
<Hourglass className="size-4 shrink-0" />
This unit is currently being prepared. Please check back later.
</div>
) : unitBlocked ? (
<>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground [&_svg]:size-4">
<span>{blockedLessons.length} {blockedLessons.length === 1 ? "lesson" : "lessons"}</span>
<span>·</span>
<span className="flex items-center gap-1"><Timer /> {formatDuration(displayUnit.duration_seconds) ?? "—"} total</span>
</div>
<div className="flex flex-col gap-3">
<h2 className="font-bold text-xl">Lessons in this unit</h2>
<LessonsRoadmap
unitDetail={{ lessons: blockedLessons, quiz: blockedQuiz }}
currentLessonId={blockedLessons[0]?.lesson_id}
onLessonClick={() => setUpsellOpen(true)}
onContinue={() => setUpsellOpen(true)}
onQuizClick={() => setUpsellOpen(true)}
/>
{blockedLessons.length > 0 && blockedLessons.length < ESTABLISHED_LESSON_COUNT && <GrowingUnitNote />}
</div>
</>
) : (
<>
<div className="flex flex-col gap-2">
@@ -231,10 +261,22 @@ const UnitDetails = () => {
onContinue={handleContinue}
onQuizClick={handleQuizClick}
/>
{lessons.length > 0 && lessons.length < ESTABLISHED_LESSON_COUNT && <GrowingUnitNote />}
</div>
</>
)}
</div>
<UnitUpsellModal
open={upsellOpen}
onOpenChange={setUpsellOpen}
unit={unitBlockedInfo?.item ? {
title: unitBlockedInfo.item.title,
subscription: unitBlockedInfo.item.subscription,
courses: unitBlockedInfo.course ? [unitBlockedInfo.course] : [],
} : null}
tierMap={tierMap}
/>
</div>
);
};
+4 -2
View File
@@ -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 = () => {
</p>
</div>
<div className="flex flex-col items-center gap-2">
<Button onClick={() => navigate('/plans')} className="gap-1.5">
<Button onClick={() => navigate('/subscriptions')} className="gap-1.5">
<Zap className="size-4" /> View Available Plans
</Button>
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this subscription.</p>
+4 -2
View File
@@ -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.
</p>
</div>
<Button onClick={() => navigate('/plans')} className="gap-1.5">
<Button onClick={() => navigate('/subscriptions')} className="gap-1.5">
<Zap className="size-4" /> View Available Plans
</Button>
</div>
+1 -18
View File
@@ -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: <House className="size-4" />, to: `/dashboard` },
@@ -197,13 +187,6 @@ const UnitsList = () => {
)}
</div>
</div>
<UnitUpsellModal
open={modalOpen}
onOpenChange={setModalOpen}
unit={selectedUnit}
tierMap={tierMap}
/>
</div>
);
};
+3 -3
View File
@@ -111,10 +111,10 @@ const ViewPlan = () => {
<div className="relative px-6 pt-8 pb-10 lg:container lg:max-w-3xl lg:mx-auto">
<button
onClick={() => navigate("/plans")}
onClick={() => navigate("/subscriptions")}
className="flex items-center gap-1.5 text-white/70 hover:text-white text-sm mb-8 transition-colors"
>
<ArrowLeft className="size-4" /> Back to Plans
<ArrowLeft className="size-4" /> Back to Subscriptions
</button>
<Badge className="bg-white/20 border border-white/30 text-white mb-4 text-sm px-3 py-1">
@@ -269,7 +269,7 @@ const ViewPlan = () => {
<Button
size="lg"
className="bg-white text-gray-900 hover:bg-white/90 font-bold shadow-lg"
onClick={() => 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)}
</Button>
+1 -1
View File
@@ -341,7 +341,7 @@ const LockedContent = () => {
</p>
</div>
<div className="flex flex-col items-center gap-2">
<Button onClick={() => navigate('/plans')} className="gap-1.5">
<Button onClick={() => navigate('/subscriptions')} className="gap-1.5">
<Zap className="size-4" /> View Available Plans
</Button>
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this subscription.</p>
+1 -1
View File
@@ -71,7 +71,7 @@ export const ClientRoutes = {
{ path: 'notifications', element: <Notifications /> },
{ path: 'ads/:uuid', element: <AdvertisementLandingPage /> },
{
path: 'plans', element: <Outlet />,
path: 'subscriptions', element: <Outlet />,
children: [
// { index: true, element: <Checkout /> },
{ index: true, element: <PlanList /> },
+3 -3
View File
@@ -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",