mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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 { Search, CheckCircle2, SlidersHorizontal, X } from "lucide-react";
|
||||||
|
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
|
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 { Spinner } from "@/components/ui/spinner";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
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";
|
import { formatPlayerTime } from "@/utils/format.util";
|
||||||
|
|
||||||
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
|
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
|
||||||
@@ -23,8 +23,13 @@ const EXT_OPTIONS = {
|
|||||||
|
|
||||||
// ─── Asset Card ───────────────────────────────────────────────────────────────
|
// ─── Asset Card ───────────────────────────────────────────────────────────────
|
||||||
// streamSrc is resolved at the sheet level (batch token request) — no per-card fetch.
|
// 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 directThumb = asset.thumbnail_url ?? asset.file_url;
|
||||||
const thumb = streamSrc ?? directThumb;
|
const thumb = streamSrc ?? directThumb;
|
||||||
// duration comes straight off the asset row (ffprobe-derived at upload) —
|
// duration comes straight off the asset row (ffprobe-derived at upload) —
|
||||||
@@ -69,7 +74,7 @@ function AssetCard({ asset, streamSrc, selected, onSelect }) {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
function EmptyState({ fileType }) {
|
function EmptyState({ fileType }) {
|
||||||
return (
|
return (
|
||||||
@@ -91,10 +96,20 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
|
|||||||
// `open` prop toggles), that meant React remounted every hook from scratch
|
// `open` prop toggles), that meant React remounted every hook from scratch
|
||||||
// on each open — wiping local state and forcing a full refetch every time,
|
// on each open — wiping local state and forcing a full refetch every time,
|
||||||
// plus skipping the <Sheet> close transition. Visibility is controlled by
|
// plus skipping the <Sheet> close transition. Visibility is controlled by
|
||||||
// <Sheet open={open}> below instead, so state (and the caches in
|
// <Sheet open={open}> below instead, so state survives across open/close
|
||||||
// AdminAssetsContext) survive across open/close toggles.
|
// 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 [search, setSearch] = useState("");
|
||||||
const [activeExts, setActiveExts] = useState(new Set());
|
const [activeExts, setActiveExts] = useState(new Set());
|
||||||
@@ -102,9 +117,22 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
|
|||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
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 debounceRef = useRef(null);
|
||||||
const isFirstSearchRun = useRef(true);
|
const isFirstSearchRun = useRef(true);
|
||||||
const LIMIT = 12;
|
const LIMIT = 10;
|
||||||
|
|
||||||
const extOptions = allowedExtensions ?? EXT_OPTIONS[fileType] ?? [];
|
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()] }] : []),
|
...(searchVal.trim() ? [{ id: "display_name", value: [searchVal.trim()] }] : []),
|
||||||
...(extFilterValue?.length ? [{ id: "extension", value: extFilterValue }] : []),
|
...(extFilterValue?.length ? [{ id: "extension", value: extFilterValue }] : []),
|
||||||
];
|
];
|
||||||
fetchAssets({ page: pg, limit: LIMIT, filters });
|
setLoading(true);
|
||||||
}, [fileType, fetchAssets, allowedExtensions]);
|
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 ──────
|
// ── Immediate fetch: on open, or when filters/page change while open ──────
|
||||||
// (fetchAssets itself is TTL-cached in AdminAssetsContext, so reopening
|
// (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;
|
if (!s3Ids.length) return;
|
||||||
|
|
||||||
getMediaTokens(s3Ids).catch((err) => {
|
setTokensLoading(true);
|
||||||
console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message);
|
getMediaTokens(s3Ids)
|
||||||
});
|
.catch((err) => {
|
||||||
|
console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message);
|
||||||
|
})
|
||||||
|
.finally(() => setTokensLoading(false));
|
||||||
}, [assets, open, getMediaTokens]);
|
}, [assets, open, getMediaTokens]);
|
||||||
|
|
||||||
// ── Reset on close ────────────────────────────────────────────────────────
|
// ── Reset on close ────────────────────────────────────────────────────────
|
||||||
@@ -194,7 +231,10 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
|
|||||||
setPage(1);
|
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);
|
setSelected(asset.asset_id);
|
||||||
// Pass the resolved stream/presigned URL as a second arg so callers
|
// Pass the resolved stream/presigned URL as a second arg so callers
|
||||||
// (e.g. badge image picker) can use the authenticated URL directly
|
// (e.g. badge image picker) can use the authenticated URL directly
|
||||||
@@ -206,13 +246,14 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
|
|||||||
?? null;
|
?? null;
|
||||||
onSelect(asset, resolvedUrl);
|
onSelect(asset, resolvedUrl);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
}, [resolveStreamSrc, onSelect, onOpenChange]);
|
||||||
|
|
||||||
const label = fileType
|
const label = fileType
|
||||||
? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s`
|
? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s`
|
||||||
: "Files";
|
: "Files";
|
||||||
|
|
||||||
const hasActiveFilters = activeExts.size > 0;
|
const hasActiveFilters = activeExts.size > 0;
|
||||||
|
const showSpinner = loading || tokensLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -304,10 +345,15 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Grid ── */}
|
{/* ── 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">
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
{loading ? (
|
{showSpinner ? (
|
||||||
<div className="flex items-center justify-center h-48">
|
<div className="flex items-center justify-center h-full min-h-[24rem]">
|
||||||
<Spinner className="h-5 w-5" />
|
<Spinner className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
) : !assets.length ? (
|
) : !assets.length ? (
|
||||||
<EmptyState fileType={fileType ?? "asset"} />
|
<EmptyState fileType={fileType ?? "asset"} />
|
||||||
@@ -327,7 +373,11 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Pagination ── */}
|
{/* ── 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">
|
<div className="flex items-center justify-between px-6 py-3 border-t text-sm">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
Page {pagination.page} of {pagination.totalPages}
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
@@ -335,7 +385,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!pagination.hasPrevPage || loading}
|
disabled={!pagination.hasPrevPage || showSpinner}
|
||||||
onClick={() => setPage((p) => p - 1)}
|
onClick={() => setPage((p) => p - 1)}
|
||||||
className="px-3 py-1 rounded border text-xs disabled:opacity-40 hover:bg-muted transition-colors"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!pagination.hasNextPage || loading}
|
disabled={!pagination.hasNextPage || showSpinner}
|
||||||
onClick={() => setPage((p) => p + 1)}
|
onClick={() => setPage((p) => p + 1)}
|
||||||
className="px-3 py-1 rounded border text-xs disabled:opacity-40 hover:bg-muted transition-colors"
|
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":
|
case "tier_expired":
|
||||||
return data.planId
|
return data.planId
|
||||||
? { label: "Renew plan", go: (navigate) => navigate(`/plans/view/${data.planId}`) }
|
? { label: "Renew plan", go: (navigate) => navigate(`/subscriptions/view/${data.planId}`) }
|
||||||
: { label: "View plans", go: (navigate) => navigate("/plans") };
|
: { label: "View plans", go: (navigate) => navigate("/subscriptions") };
|
||||||
|
|
||||||
case "announcement":
|
case "announcement":
|
||||||
if (data.groupId && data.groupCode) {
|
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) };
|
return { label: "Go to course", go: (navigate) => goToCourse(navigate, data.targetId) };
|
||||||
}
|
}
|
||||||
if (data.targetType === "tier_plan" && 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) {
|
if (data.targetType === "task_list" && data.groupId && data.targetId) {
|
||||||
return { label: "View task list", go: (navigate) => navigate(`/group/${data.groupId}/view/${data.targetId}`) };
|
return { label: "View task list", go: (navigate) => navigate(`/group/${data.groupId}/view/${data.targetId}`) };
|
||||||
|
|||||||
@@ -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 api from "@/utils/api.util";
|
||||||
import { presignAssetUpload, uploadPresigned } from "@/utils/presignedUpload.util";
|
import { presignAssetUpload, uploadPresigned } from "@/utils/presignedUpload.util";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const AssetsContext = createContext(null);
|
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() {
|
export function useAssets() {
|
||||||
const ctx = useContext(AssetsContext);
|
const ctx = useContext(AssetsContext);
|
||||||
if (!ctx) throw new Error("useAssets must be used within an AssetsProvider");
|
if (!ctx) throw new Error("useAssets must be used within an AssetsProvider");
|
||||||
return ctx;
|
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 ────────────────────────────────────────────────────────────
|
// ─── Initial States ────────────────────────────────────────────────────────────
|
||||||
const PAGINATION_INIT = {
|
const PAGINATION_INIT = {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -77,50 +91,79 @@ export function AssetsProvider({ children }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ─── GET /api/admin/assets ────────────────────────────────────────────────
|
// ─── GET /api/admin/assets (shared, cached) ──────────────────────────────
|
||||||
// Cached per (page, limit, filters, sort) for LIST_CACHE_TTL_MS so toggling
|
// Cached per (page, limit, filters, sort) for LIST_CACHE_TTL_MS so repeated
|
||||||
// a picker like AssetPickerSheet open/closed doesn't re-hit Postgres for the
|
// callers (the table page, every mounted picker) don't re-hit Postgres for
|
||||||
// same query within the TTL window. Pass force: true to bypass the cache.
|
// the same query within the TTL window. Pass force: true to bypass the
|
||||||
const fetchAssets = useCallback(
|
// cache. Returns the resolved { assets, pagination, attributes, raw } and
|
||||||
({ page = 1, limit = 10, filters = [], sort = [], force = false } = {}) => {
|
// 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 key = cacheKeyFor("assets", { page, limit, filters, sort });
|
||||||
const cached = listCacheRef.current.get(key);
|
const cached = listCacheRef.current.get(key);
|
||||||
if (!force && cached && Date.now() - cached.fetchedAt < LIST_CACHE_TTL_MS) {
|
if (!force && cached && Date.now() - cached.fetchedAt < LIST_CACHE_TTL_MS) {
|
||||||
setAssets(cached.assets);
|
|
||||||
setPagination(cached.pagination);
|
|
||||||
setAttributes(cached.attributes);
|
|
||||||
seedMediaTokensFromRows(cached.assets);
|
seedMediaTokensFromRows(cached.assets);
|
||||||
return Promise.resolve(cached.raw);
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
return request(async () => {
|
const { data } = await api.get("/admin/assets", {
|
||||||
const { data } = await api.get("/admin/assets", {
|
params: {
|
||||||
params: {
|
page, limit,
|
||||||
page, limit,
|
filters: filters.length ? JSON.stringify(filters) : undefined,
|
||||||
filters: filters.length ? JSON.stringify(filters) : undefined,
|
sort: sort.length ? JSON.stringify(sort) : undefined,
|
||||||
sort: sort.length ? JSON.stringify(sort) : undefined,
|
},
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = data?.data;
|
|
||||||
|
|
||||||
setAssets(result?.data ?? []);
|
|
||||||
setPagination(result?.pagination ?? PAGINATION_INIT);
|
|
||||||
setAttributes(result.attributes);
|
|
||||||
seedMediaTokensFromRows(result?.data);
|
|
||||||
|
|
||||||
listCacheRef.current.set(key, {
|
|
||||||
assets: result?.data ?? [],
|
|
||||||
pagination: result?.pagination ?? PAGINATION_INIT,
|
|
||||||
attributes: result.attributes,
|
|
||||||
raw: data.data,
|
|
||||||
fetchedAt: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return data.data;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const result = data?.data;
|
||||||
|
const resolved = {
|
||||||
|
assets: result?.data ?? [],
|
||||||
|
pagination: result?.pagination ?? PAGINATION_INIT,
|
||||||
|
attributes: result.attributes,
|
||||||
|
raw: data.data,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
seedMediaTokensFromRows(resolved.assets);
|
||||||
|
listCacheRef.current.set(key, resolved);
|
||||||
|
|
||||||
|
return resolved;
|
||||||
},
|
},
|
||||||
[request]
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shared-state variant — used by the admin Assets table page. Applies the
|
||||||
|
// resolved query directly onto the provider's assets/pagination/attributes/
|
||||||
|
// loading state.
|
||||||
|
const fetchAssets = useCallback(
|
||||||
|
(query = {}) =>
|
||||||
|
request(async () => {
|
||||||
|
const resolved = await resolveAssetsQuery(query);
|
||||||
|
setAssets(resolved.assets);
|
||||||
|
setPagination(resolved.pagination);
|
||||||
|
setAttributes(resolved.attributes);
|
||||||
|
return resolved.raw;
|
||||||
|
}),
|
||||||
|
[request, resolveAssetsQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Instance-local variant — used by AssetPickerSheet (and any other caller
|
||||||
|
// that needs its own independent list/pagination/loading rather than the
|
||||||
|
// single shared slice above). Multiple pickers can be mounted at once
|
||||||
|
// (one per media block on a page); sharing state here would mean opening
|
||||||
|
// one picker re-renders and stomps the list of every other mounted one.
|
||||||
|
const fetchAssetsList = useCallback(
|
||||||
|
async (query = {}) => {
|
||||||
|
try {
|
||||||
|
const resolved = await resolveAssetsQuery(query);
|
||||||
|
return { assets: resolved.assets, pagination: resolved.pagination };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
||||||
|
toast(message);
|
||||||
|
return { assets: [], pagination: PAGINATION_INIT };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[resolveAssetsQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── POST /api/admin/media/tokens (batch) ────────────────────────────────
|
// ─── POST /api/admin/media/tokens (batch) ────────────────────────────────
|
||||||
@@ -319,30 +362,43 @@ export function AssetsProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({
|
||||||
|
assets,
|
||||||
|
attributes,
|
||||||
|
pagination,
|
||||||
|
selectedAsset,
|
||||||
|
loading,
|
||||||
|
setPagination,
|
||||||
|
setSelectedAsset,
|
||||||
|
fetchAssets,
|
||||||
|
fetchAssetsList,
|
||||||
|
fetchAsset,
|
||||||
|
fetchArchivedAssets,
|
||||||
|
updateAsset,
|
||||||
|
archiveAsset,
|
||||||
|
archiveAssets,
|
||||||
|
restoreAsset,
|
||||||
|
restoreAssets,
|
||||||
|
permanentlyDeleteAsset,
|
||||||
|
permanentlyDeleteAssets,
|
||||||
|
fetchAssetFieldValues
|
||||||
|
}), [
|
||||||
|
assets, attributes, pagination, selectedAsset, loading,
|
||||||
|
fetchAssets, fetchAssetsList, fetchAsset, fetchArchivedAssets,
|
||||||
|
updateAsset, archiveAsset, archiveAssets, restoreAsset, restoreAssets,
|
||||||
|
permanentlyDeleteAsset, permanentlyDeleteAssets, fetchAssetFieldValues,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mediaTokensValue = useMemo(() => ({
|
||||||
|
mediaTokens,
|
||||||
|
getMediaTokens,
|
||||||
|
}), [mediaTokens, getMediaTokens]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AssetsContext.Provider value={{
|
<MediaTokensContext.Provider value={mediaTokensValue}>
|
||||||
assets,
|
<AssetsContext.Provider value={value}>
|
||||||
attributes,
|
{children}
|
||||||
pagination,
|
</AssetsContext.Provider>
|
||||||
selectedAsset,
|
</MediaTokensContext.Provider>
|
||||||
loading,
|
|
||||||
mediaTokens,
|
|
||||||
getMediaTokens,
|
|
||||||
setPagination,
|
|
||||||
setSelectedAsset,
|
|
||||||
fetchAssets,
|
|
||||||
fetchAsset,
|
|
||||||
fetchArchivedAssets,
|
|
||||||
updateAsset,
|
|
||||||
archiveAsset,
|
|
||||||
archiveAssets,
|
|
||||||
restoreAsset,
|
|
||||||
restoreAssets,
|
|
||||||
permanentlyDeleteAsset,
|
|
||||||
permanentlyDeleteAssets,
|
|
||||||
fetchAssetFieldValues
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</AssetsContext.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,6 +58,7 @@ export function ClientCoursesProvider({ children }) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.response?.status === 403) {
|
if (err?.response?.status === 403) {
|
||||||
setCourseBlocked(true); // let the UI show an upgrade prompt
|
setCourseBlocked(true); // let the UI show an upgrade prompt
|
||||||
|
setCourse(err.response.data?.item ?? null); // trimmed shell — title/description/etc.
|
||||||
} else {
|
} else {
|
||||||
toast(err?.response?.data?.message ?? "Could not load course.");
|
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={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
||||||
<Button onClick={() => { onOpenChange(false); navigate("/plans"); }}>
|
<Button onClick={() => { onOpenChange(false); navigate("/subscriptions"); }}>
|
||||||
<LockIcon /> View Plans
|
<LockIcon /> View Plans
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function LockedContentPanel({ course, item, tierMap = {} }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div className="flex 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
|
<Zap className="size-4" /> View Available Plans
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {}
|
|||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
||||||
<Button onClick={() => { onOpenChange(false); navigate("/plans"); }}>
|
<Button onClick={() => { onOpenChange(false); navigate("/subscriptions"); }}>
|
||||||
<LockIcon /> View Plans
|
<LockIcon /> View Plans
|
||||||
</Button>
|
</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.
|
To complete this activity, subscribe to one of our available tier plans.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<Zap className="size-3.5" /> View Plans
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +125,7 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={course.id}
|
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"
|
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">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -144,7 +144,7 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto">
|
<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
|
<Zap className="size-3.5" /> Upgrade to unlock
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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.
|
To complete this activity, subscribe to one of our available tier plans.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<Zap className="size-3.5" /> View Plans
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +93,7 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={lesson.id}
|
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"
|
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">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -117,7 +117,7 @@ const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId,
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto">
|
<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
|
<Zap className="size-3.5" /> Upgrade to unlock
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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.
|
To complete this activity, subscribe to one of our available tier plans.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<Zap className="size-3.5" /> View Plans
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,7 +112,7 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={unit.id}
|
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"
|
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">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -136,7 +136,7 @@ const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskI
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto">
|
<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
|
<Zap className="size-3.5" /> Upgrade to unlock
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -361,8 +361,8 @@ function ClientNav() {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem className="bg-gradient-to-r from-[#0e7490] via-[#3b82f6] to-[#4f46e5] text-white" onClick={() => navigate("/plans")}>
|
<DropdownMenuItem className="bg-gradient-to-r from-[#0e7490] via-[#3b82f6] to-[#4f46e5] text-white" onClick={() => navigate("/subscriptions")}>
|
||||||
<Zap /> Plans
|
<Zap /> Subscriptions
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => navigate("/profile")}>
|
<DropdownMenuItem onClick={() => navigate("/profile")}>
|
||||||
<User /> Profile
|
<User /> Profile
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ function SubscriptionSection() {
|
|||||||
<span className="text-sm text-muted-foreground">Expires {expiresAt}</span>
|
<span className="text-sm text-muted-foreground">Expires {expiresAt}</span>
|
||||||
)}
|
)}
|
||||||
{tier === "free" && (
|
{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" />
|
Upgrade plan <ChevronRight className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ const Checkout = () => {
|
|||||||
setCapturing(true);
|
setCapturing(true);
|
||||||
captureOrder(returnToken).then((result) => {
|
captureOrder(returnToken).then((result) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
navigate("/plans", { replace: true });
|
navigate("/subscriptions", { replace: true });
|
||||||
} else {
|
} else {
|
||||||
setCapturing(false);
|
setCapturing(false);
|
||||||
capturingRef.current = false;
|
capturingRef.current = false;
|
||||||
@@ -123,7 +123,7 @@ const Checkout = () => {
|
|||||||
const orderId = searchParams.get("token");
|
const orderId = searchParams.get("token");
|
||||||
if (orderId) cancelOrder(orderId);
|
if (orderId) cancelOrder(orderId);
|
||||||
toast("PayPal checkout was cancelled.");
|
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
|
}, [wasCancelled]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// const personalInfo = user?.personal_info ?? {};
|
// const personalInfo = user?.personal_info ?? {};
|
||||||
@@ -146,7 +146,7 @@ const Checkout = () => {
|
|||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/" },
|
||||||
{ label: "Plans", to: "/plans" },
|
{ label: "Subscriptions", to: "/subscriptions" },
|
||||||
{ label: "Checkout" },
|
{ label: "Checkout" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -204,8 +204,8 @@ const Checkout = () => {
|
|||||||
Select a subscription plan before continuing to checkout.
|
Select a subscription plan before continuing to checkout.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => navigate("/plans")}>
|
<Button onClick={() => navigate("/subscriptions")}>
|
||||||
<ArrowLeft className="size-4" /> Back to Plans
|
<ArrowLeft className="size-4" /> Back to Subscriptions
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -229,8 +229,8 @@ const Checkout = () => {
|
|||||||
is not available for purchase at the moment.
|
is not available for purchase at the moment.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => navigate("/plans")}>
|
<Button onClick={() => navigate("/subscriptions")}>
|
||||||
<ArrowLeft className="size-4" /> Back to Plans
|
<ArrowLeft className="size-4" /> Back to Subscriptions
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ import { useClientCourses } from "@/contexts/ClientCoursesContext";
|
|||||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||||
import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgressContext";
|
import { useCourseReadingProgress } from "@/contexts/ClientCourseReadingProgressContext";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
import { getTierColor } from "@/utils/tierColors";
|
import { getTierColor } from "@/utils/tierColors";
|
||||||
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
|
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
|
||||||
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
|
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
|
||||||
import { Tags } from "lucide-react";
|
import { Tags } from "lucide-react";
|
||||||
|
import CourseUpsellModal from "../components/CourseUpsellModal";
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ const useVisibleNodes = (refs, count) => {
|
|||||||
|
|
||||||
// ─── Unit Accordion Block ─────────────────────────────────────────────────────
|
// ─── 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 navigate = useNavigate();
|
||||||
const quiz = unit.quiz ?? null;
|
const quiz = unit.quiz ?? null;
|
||||||
|
|
||||||
@@ -129,7 +129,9 @@ const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle,
|
|||||||
<div
|
<div
|
||||||
key={lesson.lesson_id}
|
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"
|
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">
|
<div className="flex items-center gap-3 select-none min-w-0">
|
||||||
{isCompleted(lesson.uuid)
|
{isCompleted(lesson.uuid)
|
||||||
@@ -148,7 +150,9 @@ const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle,
|
|||||||
{quiz && (
|
{quiz && (
|
||||||
<div
|
<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"
|
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">
|
<div className="flex items-center gap-3 select-none min-w-0">
|
||||||
{quiz.has_passed
|
{quiz.has_passed
|
||||||
@@ -350,7 +354,7 @@ const CertCard = ({ courseTitle, courseLevel, badgeColor, badgeImageUrl, pending
|
|||||||
|
|
||||||
// ─── Course Units (spine + cards) ─────────────────────────────────────────────
|
// ─── 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 wrapRef = useRef(null);
|
||||||
const cardRefs = useRef([]);
|
const cardRefs = useRef([]);
|
||||||
|
|
||||||
@@ -469,6 +473,8 @@ const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, badgeColo
|
|||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
onToggle={measure}
|
onToggle={measure}
|
||||||
isCompleted={isCompleted}
|
isCompleted={isCompleted}
|
||||||
|
locked={locked}
|
||||||
|
onLockedClick={onLockedClick}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -520,6 +526,7 @@ const CourseDetails = () => {
|
|||||||
|
|
||||||
const [tierMap, setTierMap] = useState({});
|
const [tierMap, setTierMap] = useState({});
|
||||||
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
|
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
|
||||||
|
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get("/client/tiers/categories")
|
api.get("/client/tiers/categories")
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
@@ -542,13 +549,20 @@ const CourseDetails = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getMyTier();
|
getMyTier();
|
||||||
getCourse(courseId);
|
getCourse(courseId);
|
||||||
fetchCourseProgress(courseId);
|
|
||||||
fetchCourseProgressSummary(courseId);
|
|
||||||
getActiveAdvertisementList("course_details.banner");
|
getActiveAdvertisementList("course_details.banner");
|
||||||
return () => { resetCourse(); resetProgress(); setBadgeImageUrl(null); };
|
return () => { resetCourse(); resetProgress(); setBadgeImageUrl(null); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [courseId]);
|
}, [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"] ?? [];
|
const bannerAds = adLists["course_details.banner"] ?? [];
|
||||||
|
|
||||||
// Resolve badge image once course loads — issue a client stream token for
|
// 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));
|
.catch(() => setBadgeImageUrl(course.badge_image_url ?? null));
|
||||||
}, [course?.badge_asset_id, course?.badge_image_url]);
|
}, [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 = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
||||||
{ label: "Courses", to: `/course` },
|
{ label: "Courses", to: `/course` },
|
||||||
@@ -631,13 +639,13 @@ const CourseDetails = () => {
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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
|
{hasCompleted
|
||||||
? <><CheckCheck /> Start Again</>
|
? <><CheckCheck /> Start Again</>
|
||||||
: <><SendHorizonal /> Start Learning</>
|
: <><SendHorizonal /> Start Learning</>
|
||||||
}
|
}
|
||||||
</Button>
|
</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
|
{hasCompleted
|
||||||
? <><CheckCheck /> Start Again</>
|
? <><CheckCheck /> Start Again</>
|
||||||
: <><SendHorizonal /> Start Learning</>
|
: <><SendHorizonal /> Start Learning</>
|
||||||
@@ -716,7 +724,7 @@ const CourseDetails = () => {
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className="w-fit bg-blue-500"
|
className="w-fit bg-blue-500"
|
||||||
onClick={() => navigate(`/course/${courseId}/unit`)}
|
onClick={() => courseBlocked ? setUpsellOpen(true) : navigate(`/course/${courseId}/unit`)}
|
||||||
>
|
>
|
||||||
{hasCompleted
|
{hasCompleted
|
||||||
? <><CheckCheck /> Start Again</>
|
? <><CheckCheck /> Start Again</>
|
||||||
@@ -743,19 +751,18 @@ const CourseDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Objectives */}
|
{/* Objectives — no wrapper rendered at all when empty, otherwise an
|
||||||
<div className="space-y-4">
|
empty div still eats a gap-12 slot in the flex column below */}
|
||||||
{course?.objectives?.length > 0 && (
|
{course?.objectives?.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="font-bold text-2xl">Learning Outcomes</div>
|
<div className="font-bold text-2xl">Learning Outcomes</div>
|
||||||
<ul className="max-w-3xl list-disc list-inside space-y-1 ">
|
<ul className="max-w-3xl list-disc list-inside space-y-1">
|
||||||
{course.objectives.map((obj) => (
|
{course.objectives.map((obj) => (
|
||||||
<li key={obj.objective_id}>{obj.text}</li>
|
<li key={obj.objective_id}>{obj.text}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Roles + Prerequisites */}
|
{/* Roles + Prerequisites */}
|
||||||
<div className="max-w-3xl space-y-6">
|
<div className="max-w-3xl space-y-6">
|
||||||
@@ -878,6 +885,8 @@ const CourseDetails = () => {
|
|||||||
certificate={course.certificate ?? null}
|
certificate={course.certificate ?? null}
|
||||||
assessment={course.assessment ?? null}
|
assessment={course.assessment ?? null}
|
||||||
contentNotReady={contentNotReady}
|
contentNotReady={contentNotReady}
|
||||||
|
locked={courseBlocked}
|
||||||
|
onLockedClick={() => setUpsellOpen(true)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -888,6 +897,13 @@ const CourseDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CourseUpsellModal
|
||||||
|
open={upsellOpen}
|
||||||
|
onOpenChange={setUpsellOpen}
|
||||||
|
course={course}
|
||||||
|
tierMap={tierMap}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -9,12 +9,10 @@ import {
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
|
||||||
import { useClientCourses } from "@/contexts/ClientCoursesContext";
|
import { useClientCourses } from "@/contexts/ClientCoursesContext";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
import { Building2 } from "lucide-react";
|
import { Building2 } from "lucide-react";
|
||||||
@@ -163,7 +161,6 @@ const Pagination = ({ currentPage, totalPages, totalItems, itemsPerPage, onPageC
|
|||||||
const CoursesList = () => {
|
const CoursesList = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { courses, coursesLoading, getCourses } = useClientCourses();
|
const { courses, coursesLoading, getCourses } = useClientCourses();
|
||||||
const { fmtCurrency } = useDateFormat();
|
|
||||||
|
|
||||||
const [tierCategories, setTierCategories] = useState([]);
|
const [tierCategories, setTierCategories] = useState([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@@ -171,8 +168,6 @@ const CoursesList = () => {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [subFilter, setSubFilter] = useState("All");
|
const [subFilter, setSubFilter] = useState("All");
|
||||||
const [categoryFilter, setCategoryFilter] = useState("All");
|
const [categoryFilter, setCategoryFilter] = useState("All");
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const [selectedCourse, setSelectedCourse] = useState(null);
|
|
||||||
|
|
||||||
const [allCategories, setAllCategories] = useState([]);
|
const [allCategories, setAllCategories] = useState([]);
|
||||||
|
|
||||||
@@ -212,23 +207,13 @@ const CoursesList = () => {
|
|||||||
|
|
||||||
const runSearch = () => { setSearch(searchInput); setCurrentPage(1); };
|
const runSearch = () => { setSearch(searchInput); setCurrentPage(1); };
|
||||||
|
|
||||||
const handleViewDetails = (course) => {
|
const handleViewDetails = (course) => navigate(`/course/${course.course_id}`);
|
||||||
if (course.is_locked) {
|
|
||||||
setSelectedCourse(course);
|
|
||||||
setModalOpen(true);
|
|
||||||
} else {
|
|
||||||
navigate(`/course/${course.course_id}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
||||||
{ label: "Courses" },
|
{ label: "Courses" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Upsell modal tier panel
|
|
||||||
const upsellTier = selectedCourse ? tierMap[selectedCourse.subscription] : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageMeta title="Courses - STARR" description="Browse your available training courses." />
|
<PageMeta title="Courses - STARR" description="Browse your available training courses." />
|
||||||
@@ -353,53 +338,6 @@ const CoursesList = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Users, Timer,
|
Users, Timer,
|
||||||
Tag, LockIcon, Check,
|
Tag, LockIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -13,11 +13,9 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
|
||||||
import { useClientCourses } from "@/contexts/ClientCoursesContext";
|
import { useClientCourses } from "@/contexts/ClientCoursesContext";
|
||||||
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||||
import UnitUpsellModal from "../components/UnitUpsellModal";
|
|
||||||
import { UnitCard, UnitCardSkeleton } from "../components/UnitCard";
|
import { UnitCard, UnitCardSkeleton } from "../components/UnitCard";
|
||||||
import LessonUpsellModal from "../components/LessonUpsellModal";
|
import LessonUpsellModal from "../components/LessonUpsellModal";
|
||||||
import { LessonCard, LessonCardSkeleton } from "../components/LessonCard";
|
import { LessonCard, LessonCardSkeleton } from "../components/LessonCard";
|
||||||
@@ -39,13 +37,6 @@ function formatDuration(seconds = 0) {
|
|||||||
return `${m}m`;
|
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 ──────────────────────────────────────────────────────────────
|
// ── Course Card ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const CourseCard = ({ course, onViewDetails }) => {
|
const CourseCard = ({ course, onViewDetails }) => {
|
||||||
@@ -200,24 +191,16 @@ const Client = () => {
|
|||||||
const { state: navState } = useLocation();
|
const { state: navState } = useLocation();
|
||||||
const { courses, coursesLoading, getCourses } = useClientCourses();
|
const { courses, coursesLoading, getCourses } = useClientCourses();
|
||||||
const { units, unitsLoading, getUnits, lessons, lessonsLoading, getLessons } = useLibrary();
|
const { units, unitsLoading, getUnits, lessons, lessonsLoading, getLessons } = useLibrary();
|
||||||
const { myTier, getMyTier, tierMap } = useClientTiers();
|
const { tierMap } = useClientTiers();
|
||||||
const { groups, fetchGroups, loading: groupLoading } = useGroup();
|
const { groups, fetchGroups, loading: groupLoading } = useGroup();
|
||||||
const {
|
const {
|
||||||
adLists, listLoading, getActiveAdvertisementList,
|
adLists, listLoading, getActiveAdvertisementList,
|
||||||
handleAdCtaClick,
|
handleAdCtaClick,
|
||||||
} = useClientAdvertisements();
|
} = 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 [lessonModalOpen, setLessonModalOpen] = useState(false);
|
||||||
const [selectedLesson, setSelectedLesson] = useState(null);
|
const [selectedLesson, setSelectedLesson] = useState(null);
|
||||||
|
|
||||||
const userTier = myTier?.tier ?? "free";
|
|
||||||
|
|
||||||
const heroAds = adLists["dashboard.hero"] ?? [];
|
const heroAds = adLists["dashboard.hero"] ?? [];
|
||||||
|
|
||||||
// Show welcome toast on first registration
|
// Show welcome toast on first registration
|
||||||
@@ -245,7 +228,6 @@ const Client = () => {
|
|||||||
getCourses();
|
getCourses();
|
||||||
getUnits();
|
getUnits();
|
||||||
getLessons();
|
getLessons();
|
||||||
if (!myTier) getMyTier();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -267,25 +249,11 @@ const Client = () => {
|
|||||||
{ label: "My Groups", icon: <Users className="size-4" /> },
|
{ label: "My Groups", icon: <Users className="size-4" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Card click — mirrors CoursesList.jsx logic ────────────────────────────
|
// ── Card click — always go to the detail page; locked items show their
|
||||||
const handleViewDetails = (course) => {
|
// upgrade prompt there, on the primary action button ──────────────────────
|
||||||
const accessible = canAccess(userTier, course.subscription, tierMap);
|
const handleViewDetails = (course) => navigate(`/course/${course.course_id}`);
|
||||||
if (!accessible) {
|
|
||||||
setSelectedCourse(course);
|
|
||||||
setModalOpen(true);
|
|
||||||
} else {
|
|
||||||
navigate(`/course/${course.course_id}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewUnitDetails = (unit) => {
|
const handleViewUnitDetails = (unit) => navigate(`/units/${unit.uuid}`);
|
||||||
if (unit.is_locked) {
|
|
||||||
setSelectedUnit(unit);
|
|
||||||
setUnitModalOpen(true);
|
|
||||||
} else {
|
|
||||||
navigate(`/units/${unit.uuid}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewLessonDetails = (lesson) => {
|
const handleViewLessonDetails = (lesson) => {
|
||||||
if (lesson.is_locked) {
|
if (lesson.is_locked) {
|
||||||
@@ -420,54 +388,6 @@ const Client = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 ── */}
|
{/* ── Upsell Modal — only for locked lessons ── */}
|
||||||
<LessonUpsellModal
|
<LessonUpsellModal
|
||||||
open={lessonModalOpen}
|
open={lessonModalOpen}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { useParams, useNavigate, useLocation } from "react-router-dom";
|
import { useParams, useNavigate, useLocation } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
House, CheckCheck, Check, Hourglass, Clock, Video, ListChecks,
|
House, CheckCheck, Check, Hourglass, Clock, Video, ListChecks, ArrowUpRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||||
@@ -11,7 +12,6 @@ import { PageMeta } from "@/contexts/MetadataContext";
|
|||||||
import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
|
import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
|
||||||
import LessonBlock from "../components/LessonBlock.jsx";
|
import LessonBlock from "../components/LessonBlock.jsx";
|
||||||
import MarkCompleteButton from "@/modules/client/components/MarkCompleteButton.jsx";
|
import MarkCompleteButton from "@/modules/client/components/MarkCompleteButton.jsx";
|
||||||
import { TYPE_DEFS } from "@/modules/admin/components/courses/completionRequirementTypes";
|
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -64,9 +64,6 @@ const LessonDetails = () => {
|
|||||||
const blockTypes = new Set((lesson?.blocks ?? []).map((b) => b.type));
|
const blockTypes = new Set((lesson?.blocks ?? []).map((b) => b.type));
|
||||||
const isVideoLesson = blockTypes.has("video") || blockTypes.has("text-video");
|
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.
|
// read_all_content (or unconfigured/default) → scroll-to-bottom tracking, below.
|
||||||
// watch_percent / manual_complete → their own dedicated triggers — same dispatch
|
// watch_percent / manual_complete → their own dedicated triggers — same dispatch
|
||||||
// pattern as UnitList.jsx / UnitReader.jsx, brought here for standalone lessons.
|
// pattern as UnitList.jsx / UnitReader.jsx, brought here for standalone lessons.
|
||||||
@@ -139,7 +136,7 @@ const LessonDetails = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!taskCtx?.has_task) return;
|
if (!taskCtx?.has_task) return;
|
||||||
if (lessonCompletionType !== 'read_all_content') 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 (completedSessionRef.current.has(lesson.uuid)) return;
|
||||||
if (hasCompleted) return;
|
if (hasCompleted) return;
|
||||||
completedSessionRef.current.add(lesson.uuid);
|
completedSessionRef.current.add(lesson.uuid);
|
||||||
@@ -160,14 +157,6 @@ const LessonDetails = () => {
|
|||||||
await markComplete(lesson.uuid, unit?.uuid ?? null);
|
await markComplete(lesson.uuid, unit?.uuid ?? null);
|
||||||
}, [lesson, unit, markComplete]);
|
}, [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 = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
||||||
{ label: "Lessons", to: `/lessons` },
|
{ label: "Lessons", to: `/lessons` },
|
||||||
@@ -187,7 +176,7 @@ const LessonDetails = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lessonLoading || !lesson || (hasCourse && !contentNotReady)) {
|
if (lessonLoading || !lesson) {
|
||||||
return (
|
return (
|
||||||
<div className="my-17 p-8 lg:container lg:mx-auto space-y-4">
|
<div className="my-17 p-8 lg:container lg:mx-auto space-y-4">
|
||||||
<Skeleton className="h-4 w-48" />
|
<Skeleton className="h-4 w-48" />
|
||||||
@@ -205,7 +194,7 @@ const LessonDetails = () => {
|
|||||||
tracked automatically" banner's promise), read_all_content lessons only,
|
tracked automatically" banner's promise), read_all_content lessons only,
|
||||||
fixed edge-to-edge under the navbar, same style as the Course > Unit >
|
fixed edge-to-edge under the navbar, same style as the Course > Unit >
|
||||||
Lesson reader's top bar. */}
|
Lesson reader's top bar. */}
|
||||||
{taskCtx?.has_task && !hasCourse && !contentNotReady && lessonCompletionType === 'read_all_content' && (
|
{taskCtx?.has_task && !contentNotReady && lessonCompletionType === 'read_all_content' && (
|
||||||
<div
|
<div
|
||||||
className="fixed left-0 right-0 z-30 h-1.5 bg-border"
|
className="fixed left-0 right-0 z-30 h-1.5 bg-border"
|
||||||
style={{ top: "var(--navbar-h)" }}
|
style={{ top: "var(--navbar-h)" }}
|
||||||
@@ -241,34 +230,44 @@ const LessonDetails = () => {
|
|||||||
<div className="flex flex-col gap-3 max-w-2xl">
|
<div className="flex flex-col gap-3 max-w-2xl">
|
||||||
<h1 className="font-bold xs:text-2xl lg:text-3xl">{lesson.title}</h1>
|
<h1 className="font-bold xs:text-2xl lg:text-3xl">{lesson.title}</h1>
|
||||||
<p className="text-muted-foreground">{lesson.description ?? ""}</p>
|
<p className="text-muted-foreground">{lesson.description ?? ""}</p>
|
||||||
{!hasCourse && (
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground [&_svg]:size-4">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground [&_svg]:size-4">
|
{formatDuration(lesson.duration_seconds) && (
|
||||||
{formatDuration(lesson.duration_seconds) && (
|
<span className="flex items-center gap-1"><Clock /> {formatDuration(lesson.duration_seconds)}</span>
|
||||||
<>
|
)}
|
||||||
<span className="flex items-center gap-1"><Clock /> {formatDuration(lesson.duration_seconds)}</span>
|
{/* Reading/video type only means anything when there's an actual
|
||||||
<span>·</span>
|
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
|
||||||
<span className="flex items-center gap-1">
|
working completion mechanism (scroll/watch/mark-complete). */}
|
||||||
<Video /> {isVideoLesson ? "Video lesson" : "Reading lesson"}
|
{taskCtx?.has_task && (
|
||||||
</span>
|
<>
|
||||||
{requirementDef && (
|
{formatDuration(lesson.duration_seconds) && <span>·</span>}
|
||||||
<>
|
<span className="flex items-center gap-1">
|
||||||
<span>·</span>
|
<Video /> {isVideoLesson ? "Video lesson" : "Reading lesson"}
|
||||||
<span className="flex items-center gap-1">
|
</span>
|
||||||
{RequirementIcon && <RequirementIcon />} {requirementDef.label}
|
</>
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{hasCompleted && (
|
|
||||||
<div className="flex items-center gap-1.5 text-sm font-medium text-emerald-600 dark:text-emerald-400 w-fit">
|
|
||||||
<CheckCheck className="size-4" />
|
|
||||||
Success — you've completed this lesson.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{hasCompleted && (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-medium text-emerald-600 dark:text-emerald-400 w-fit">
|
||||||
|
<CheckCheck className="size-4" />
|
||||||
|
Success — you've completed this lesson.
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -321,9 +321,9 @@ export default function PlanList() {
|
|||||||
return () => clearInterval(refundTimerRef.current);
|
return () => clearInterval(refundTimerRef.current);
|
||||||
}, [myTier?.active_tiers]);
|
}, [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);
|
const handleRefundClick = (plan) => setRefundPlan(plan);
|
||||||
|
|
||||||
@@ -369,7 +369,7 @@ export default function PlanList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-17">
|
<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="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="lg:container lg:mx-auto space-y-8 p-6">
|
||||||
<div className="relative xs:pt-2 lg:pt-8 space-y-4">
|
<div className="relative xs:pt-2 lg:pt-8 space-y-4">
|
||||||
@@ -385,7 +385,7 @@ export default function PlanList() {
|
|||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<div className="relative flex flex-col items-center gap-4 mt-6">
|
<div className="relative flex flex-col items-center gap-4 mt-6">
|
||||||
<div className="text-center my-8">
|
<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">
|
<p className="text-muted-foreground mt-2">
|
||||||
Choose a subscription that matches your goals.
|
Choose a subscription that matches your goals.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
House, Timer, CheckCircle2, Check, CheckCheck, ClipboardList, Hourglass,
|
House, Timer, CheckCircle2, Check, CheckCheck, ClipboardList, Hourglass, Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -9,11 +9,11 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
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";
|
import { TYPE_DEFS } from "@/modules/admin/components/courses/completionRequirementTypes";
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -26,6 +26,17 @@ function formatDuration(seconds = 0) {
|
|||||||
return `${m}min`;
|
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) ─────
|
// ─── Lessons roadmap (single unit — lessons in order + trailing quiz row) ─────
|
||||||
|
|
||||||
const LessonsRoadmap = ({ unitDetail, currentLessonId, onLessonClick, onContinue, onQuizClick }) => {
|
const LessonsRoadmap = ({ unitDetail, currentLessonId, onLessonClick, onContinue, onQuizClick }) => {
|
||||||
@@ -122,8 +133,12 @@ const UnitDetails = () => {
|
|||||||
|
|
||||||
const { getUnitDetail, unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo, resetUnitDetail } = useLibrary();
|
const { getUnitDetail, unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo, resetUnitDetail } = useLibrary();
|
||||||
const { tierMap, getTierCategories } = useClientTiers();
|
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(() => {
|
useEffect(() => {
|
||||||
getTierCategories();
|
getTierCategories();
|
||||||
@@ -135,20 +150,9 @@ const UnitDetails = () => {
|
|||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
||||||
{ label: "Units", to: `/units` },
|
{ 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) {
|
if (unitDetailLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="my-17 p-8 lg:container lg:mx-auto space-y-4">
|
<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 currentLesson = lessons.find((l) => l.status !== "completed") ?? null;
|
||||||
const progressPct = lessons.length > 0 ? Math.round((completedCount / lessons.length) * 100) : 0;
|
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).
|
// Locked unit's own lessons/quiz — titles/durations only, a preview outline
|
||||||
const requirementDef = unitDetail?.completion?.type ? TYPE_DEFS[unitDetail.completion.type] : null;
|
// 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 RequirementIcon = requirementDef?.icon;
|
||||||
|
|
||||||
const handleLessonClick = (lesson) => {
|
const handleLessonClick = (lesson) => {
|
||||||
@@ -180,14 +190,14 @@ const UnitDetails = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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">
|
<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} />
|
<AppBreadcrumb items={items} />
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<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>
|
<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>
|
<h1 className="font-bold xs:text-2xl lg:text-4xl">{displayUnit?.title ?? "Unit"}</h1>
|
||||||
<p className="text-muted-foreground lg:text-lg">{unitDetail?.description ?? ""}</p>
|
<p className="text-muted-foreground lg:text-lg">{displayUnit?.description ?? ""}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{contentNotReady ? (
|
{contentNotReady ? (
|
||||||
@@ -195,6 +205,26 @@ const UnitDetails = () => {
|
|||||||
<Hourglass className="size-4 shrink-0" />
|
<Hourglass className="size-4 shrink-0" />
|
||||||
This unit is currently being prepared. Please check back later.
|
This unit is currently being prepared. Please check back later.
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -231,10 +261,22 @@ const UnitDetails = () => {
|
|||||||
onContinue={handleContinue}
|
onContinue={handleContinue}
|
||||||
onQuizClick={handleQuizClick}
|
onQuizClick={handleQuizClick}
|
||||||
/>
|
/>
|
||||||
|
{lessons.length > 0 && lessons.length < ESTABLISHED_LESSON_COUNT && <GrowingUnitNote />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -668,7 +668,9 @@ const UnitList = () => {
|
|||||||
|
|
||||||
// ── "How to complete" help text — this lesson's / this unit's / this course's
|
// ── "How to complete" help text — this lesson's / this unit's / this course's
|
||||||
// configured completion requirement (or the default implicit rule).
|
// 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 courseCompletionType = course?.completion?.type ?? 'read_all_content';
|
||||||
const lessonRequirementText = TYPE_DEFS[selectedLessonCompletionType]?.describe('lesson');
|
const lessonRequirementText = TYPE_DEFS[selectedLessonCompletionType]?.describe('lesson');
|
||||||
const unitRequirementText = TYPE_DEFS[unitCompletionType]?.describe('unit');
|
const unitRequirementText = TYPE_DEFS[unitCompletionType]?.describe('unit');
|
||||||
@@ -848,7 +850,7 @@ const UnitList = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<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
|
<Zap className="size-4" /> View Available Plans
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this subscription.</p>
|
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this subscription.</p>
|
||||||
|
|||||||
@@ -204,7 +204,9 @@ const UnitReader = () => {
|
|||||||
|
|
||||||
// ── "How to complete" help text — explains this lesson's and this unit's
|
// ── "How to complete" help text — explains this lesson's and this unit's
|
||||||
// configured completion requirement (or the default implicit rule).
|
// 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 lessonRequirementText = TYPE_DEFS[lessonCompletionType]?.describe('lesson');
|
||||||
const unitRequirementText = TYPE_DEFS[unitCompletionType]?.describe('unit');
|
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.
|
To access this unit, subscribe to one of our available tier plans.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => navigate('/plans')} className="gap-1.5">
|
<Button onClick={() => navigate('/subscriptions')} className="gap-1.5">
|
||||||
<Zap className="size-4" /> View Available Plans
|
<Zap className="size-4" /> View Available Plans
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||||
import UnitUpsellModal from "../components/UnitUpsellModal";
|
|
||||||
import { UnitCard, UnitCardSkeleton } from "../components/UnitCard";
|
import { UnitCard, UnitCardSkeleton } from "../components/UnitCard";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
|
|
||||||
@@ -73,8 +72,6 @@ const UnitsList = () => {
|
|||||||
const [searchInput, setSearchInput] = useState("");
|
const [searchInput, setSearchInput] = useState("");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [lockFilter, setLockFilter] = useState("All");
|
const [lockFilter, setLockFilter] = useState("All");
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const [selectedUnit, setSelectedUnit] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getUnits();
|
getUnits();
|
||||||
@@ -101,14 +98,7 @@ const UnitsList = () => {
|
|||||||
|
|
||||||
const runSearch = () => { setSearch(searchInput); setCurrentPage(1); };
|
const runSearch = () => { setSearch(searchInput); setCurrentPage(1); };
|
||||||
|
|
||||||
const handleViewDetails = (unit) => {
|
const handleViewDetails = (unit) => navigate(`/units/${unit.uuid}`);
|
||||||
if (unit.is_locked) {
|
|
||||||
setSelectedUnit(unit);
|
|
||||||
setModalOpen(true);
|
|
||||||
} else {
|
|
||||||
navigate(`/units/${unit.uuid}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
||||||
@@ -197,13 +187,6 @@ const UnitsList = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UnitUpsellModal
|
|
||||||
open={modalOpen}
|
|
||||||
onOpenChange={setModalOpen}
|
|
||||||
unit={selectedUnit}
|
|
||||||
tierMap={tierMap}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -111,10 +111,10 @@ const ViewPlan = () => {
|
|||||||
|
|
||||||
<div className="relative px-6 pt-8 pb-10 lg:container lg:max-w-3xl lg:mx-auto">
|
<div className="relative px-6 pt-8 pb-10 lg:container lg:max-w-3xl lg:mx-auto">
|
||||||
<button
|
<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"
|
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>
|
</button>
|
||||||
|
|
||||||
<Badge className="bg-white/20 border border-white/30 text-white mb-4 text-sm px-3 py-1">
|
<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
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
className="bg-white text-gray-900 hover:bg-white/90 font-bold shadow-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)}
|
Get {tierLabel} Plan — {fmtCurrency(plan.price, plan.currency)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ const LockedContent = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<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
|
<Zap className="size-4" /> View Available Plans
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this subscription.</p>
|
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this subscription.</p>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const ClientRoutes = {
|
|||||||
{ path: 'notifications', element: <Notifications /> },
|
{ path: 'notifications', element: <Notifications /> },
|
||||||
{ path: 'ads/:uuid', element: <AdvertisementLandingPage /> },
|
{ path: 'ads/:uuid', element: <AdvertisementLandingPage /> },
|
||||||
{
|
{
|
||||||
path: 'plans', element: <Outlet />,
|
path: 'subscriptions', element: <Outlet />,
|
||||||
children: [
|
children: [
|
||||||
// { index: true, element: <Checkout /> },
|
// { index: true, element: <Checkout /> },
|
||||||
{ index: true, element: <PlanList /> },
|
{ index: true, element: <PlanList /> },
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ export const CLIENT_ROUTE_PATHS = [
|
|||||||
"settings",
|
"settings",
|
||||||
"notifications",
|
"notifications",
|
||||||
"ads/:uuid",
|
"ads/:uuid",
|
||||||
"plans",
|
"subscriptions",
|
||||||
"plans/view/:id",
|
"subscriptions/view/:id",
|
||||||
"plans/checkout",
|
"subscriptions/checkout",
|
||||||
"course",
|
"course",
|
||||||
"course/:id",
|
"course/:id",
|
||||||
"course/:id/unit",
|
"course/:id/unit",
|
||||||
|
|||||||
Reference in New Issue
Block a user