change things

This commit is contained in:
rgrgogu
2026-08-09 17:33:21 +08:00
parent 08a0d122ea
commit 377a4d4b5e
62 changed files with 424 additions and 299 deletions
+3 -3
View File
@@ -74,7 +74,7 @@ function AssetCard({ asset, streamSrc, selected, onSelect }) {
function EmptyState({ fileType }) { function EmptyState({ fileType }) {
return ( return (
<div className="flex flex-col items-center justify-center h-48 gap-2"> <div className="flex flex-col items-center justify-center h-48 gap-2">
<p className="text-sm text-muted-foreground">No {fileType} assets found.</p> <p className="text-sm text-muted-foreground">No {fileType} files found.</p>
</div> </div>
); );
} }
@@ -210,7 +210,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
const label = fileType const label = fileType
? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s` ? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s`
: "Assets"; : "Files";
const hasActiveFilters = activeExts.size > 0; const hasActiveFilters = activeExts.size > 0;
@@ -221,7 +221,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
{/* ── Header ── */} {/* ── Header ── */}
<SheetHeader className="px-6 pt-6 pb-4 border-b"> <SheetHeader className="px-6 pt-6 pb-4 border-b">
<SheetTitle>Select {label}</SheetTitle> <SheetTitle>Select {label}</SheetTitle>
<SheetDescription>Click an asset to attach it.</SheetDescription> <SheetDescription>Click a file to attach it.</SheetDescription>
</SheetHeader> </SheetHeader>
{/* ── Search + Filter ── */} {/* ── Search + Filter ── */}
@@ -28,7 +28,7 @@ const TARGET_CONFIGS = {
fetch: () => api.get("/admin/tiers", { params: { limit: 100 } }).then((res) => res.data?.data?.data ?? []), fetch: () => api.get("/admin/tiers", { params: { limit: 100 } }).then((res) => res.data?.data?.data ?? []),
idKey: "plan_id", idKey: "plan_id",
labelKey: "label", labelKey: "label",
placeholder: "Select a tier plan…", placeholder: "Select a subscription plan…",
}, },
}; };
+2 -2
View File
@@ -12,12 +12,12 @@ export default function DashboardGrid({ sections = [] }) {
return ( return (
<section className="bg-muted/60"> <section className="bg-muted/60">
<div className="lg:container lg:mx-auto flex flex-col items-start gap-8 px-4"> <div className="lg:container lg:mx-auto flex flex-col items-start gap-8 p-4">
{sections.map(({ title, description, tiles }) => ( {sections.map(({ title, description, tiles }) => (
<div key={title} className="flex flex-col items-start gap-4 w-full"> <div key={title} className="flex flex-col items-start gap-4 w-full">
{/* Header */} {/* Header */}
<div className="flex flex-col gap-2 mt-6"> <div className="flex flex-col gap-2">
<h1 className="text-2xl leading-tighter font-medium tracking-tighter"> <h1 className="text-2xl leading-tighter font-medium tracking-tighter">
{title} {title}
</h1> </h1>
+12 -5
View File
@@ -1,9 +1,15 @@
// components/generic/UploadProgressToast.jsx // components/generic/UploadProgressToast.jsx
// //
// Floating widget mounted once in AdminLayout (outside the router Outlet's // Floating widget mounted once in AdminLayout (outside the router Outlet's
// unmount cycle) so it keeps showing asset-upload progress no matter what // unmount cycle) so it keeps showing Add Assets Bulk's upload progress no
// admin page you navigate to mid-upload. State comes from UploadQueueContext, // matter what admin page you navigate to mid-batch. State comes from
// which lives above the router for the same reason. // UploadQueueContext, which lives above the router for the same reason.
//
// Only renders "bulk"-sourced jobs — Add File (single) uploads share the
// same underlying queue (so they too survive navigation) but get their own
// SingleUploadToast instead of being folded into this multi-file "N of M"
// widget, which was designed around an actual batch and reads oddly for a
// single file.
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -17,7 +23,8 @@ const ACTIVE = new Set(["uploading", "queued"]);
const FAILED = new Set(["failed", "invalid"]); const FAILED = new Set(["failed", "invalid"]);
export default function UploadProgressToast() { export default function UploadProgressToast() {
const { jobs, retryJob } = useUploadQueue(); const { jobs: allJobs, retryJob } = useUploadQueue();
const jobs = allJobs.filter((j) => j.source === "bulk");
const navigate = useNavigate(); const navigate = useNavigate();
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@@ -91,7 +98,7 @@ export default function UploadProgressToast() {
<button <button
type="button" type="button"
className="w-full text-xs text-primary hover:underline py-2" className="w-full text-xs text-primary hover:underline py-2"
onClick={() => navigate("/admin/assets/add")} onClick={() => navigate("/admin/assets/add/bulk")}
> >
View upload details View upload details
</button> </button>
+7 -55
View File
@@ -186,53 +186,6 @@ export function AssetsProvider({ children }) {
[request] [request]
); );
// ─── POST /api/admin/assets/presign + direct PUT + POST /api/admin/assets ──
//
// onProgress?: ({ phase: 'uploading'|'processing'|'done', pct }) => void
// "uploading" — browser -> storage, real bytes sent directly (this
// backend is never in that data path at all anymore).
// "processing" — brief server-side step once the upload lands: reads the
// object back (HeadObjectCommand), runs ffprobe for
// video/audio, inserts the DB row.
const uploadAsset = useCallback(
({ file, thumbnail, onProgress, ...rest }) =>
request(async () => {
const [mainPresign, thumbPresign] = await Promise.all([
presignAssetUpload(file),
thumbnail ? presignAssetUpload(thumbnail) : Promise.resolve(null),
]);
const storage_key = mainPresign.key;
await Promise.all([
uploadPresigned(file, mainPresign, (pct) => onProgress?.({ phase: "uploading", pct })),
thumbPresign ? uploadPresigned(thumbnail, thumbPresign) : Promise.resolve(),
]);
onProgress?.({ phase: "processing", pct: 100 });
const res = await api.post("/admin/assets", {
storage_key,
thumbnail_storage_key: thumbPresign?.key,
original_name: file.name,
// Fallback only — the backend prefers storage's own
// Content-Type, this just covers the rare case a browser
// sent the PUT with no Content-Type at all (empty File.type).
mimetype: file.type || undefined,
...rest,
});
const asset = res.data?.data?.data ?? null;
if (asset) {
setAssets((prev) => [asset, ...prev]);
invalidateListCache();
toast("Asset uploaded successfully.");
}
onProgress?.({ phase: "done", pct: 100 });
return res.data;
}),
[request]
);
// ─── PATCH /api/admin/assets/:assetId ──────────────────────────────────── // ─── PATCH /api/admin/assets/:assetId ────────────────────────────────────
// //
// A replacement file (video thumbnail, or an image/audio asset's main // A replacement file (video thumbnail, or an image/audio asset's main
@@ -260,7 +213,7 @@ export function AssetsProvider({ children }) {
setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a))); setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a)));
setSelectedAsset(asset); setSelectedAsset(asset);
invalidateListCache(); invalidateListCache();
toast("Asset updated successfully."); toast("File updated successfully.");
} }
return res.data; return res.data;
}), }),
@@ -277,7 +230,7 @@ export function AssetsProvider({ children }) {
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId)); setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev)); setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
invalidateListCache(); invalidateListCache();
toast("Asset archived."); toast("File archived.");
return res.data; return res.data;
}), }),
[request] [request]
@@ -292,7 +245,7 @@ export function AssetsProvider({ children }) {
}); });
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id))); setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
invalidateListCache(); invalidateListCache();
toast(`${ids.length} asset(s) archived.`); toast(`${ids.length} file(s) archived.`);
return res.data; return res.data;
}), }),
[request] [request]
@@ -307,7 +260,7 @@ export function AssetsProvider({ children }) {
if (asset) { if (asset) {
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId)); setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
invalidateListCache(); invalidateListCache();
toast("Asset restored."); toast("File restored.");
} }
return res.data; return res.data;
}), }),
@@ -321,7 +274,7 @@ export function AssetsProvider({ children }) {
const res = await api.patch("/admin/assets/bulk-restore", { ids }); const res = await api.patch("/admin/assets/bulk-restore", { ids });
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id))); setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
invalidateListCache(); invalidateListCache();
toast(`${ids.length} asset(s) restored.`); toast(`${ids.length} file(s) restored.`);
return res.data; return res.data;
}), }),
[request] [request]
@@ -335,7 +288,7 @@ export function AssetsProvider({ children }) {
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId)); setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev)); setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
invalidateListCache(); invalidateListCache();
toast("Asset permanently deleted."); toast("File permanently deleted.");
return res.data; return res.data;
}), }),
[request] [request]
@@ -350,7 +303,7 @@ export function AssetsProvider({ children }) {
}); });
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id))); setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
invalidateListCache(); invalidateListCache();
toast(`${ids.length} asset(s) permanently deleted.`); toast(`${ids.length} file(s) permanently deleted.`);
return res.data; return res.data;
}), }),
[request] [request]
@@ -380,7 +333,6 @@ export function AssetsProvider({ children }) {
fetchAssets, fetchAssets,
fetchAsset, fetchAsset,
fetchArchivedAssets, fetchArchivedAssets,
uploadAsset,
updateAsset, updateAsset,
archiveAsset, archiveAsset,
archiveAssets, archiveAssets,
+12
View File
@@ -280,6 +280,17 @@ export function LibraryProvider({ children }) {
[request], [request],
); );
const attachLessonToUnits = useCallback(
(lessonId, unitIds) =>
request(async () => {
await Promise.all(
unitIds.map((unitId) => api.post(`${UNITS_BASE}/${unitId}/lessons`, { lesson_ids: [lessonId] }))
);
toast(`Attached to ${unitIds.length} unit${unitIds.length === 1 ? "" : "s"}.`);
}),
[request],
);
const reorderUnitLessons = useCallback( const reorderUnitLessons = useCallback(
(unitId, lessonIds) => (unitId, lessonIds) =>
request(async () => { request(async () => {
@@ -499,6 +510,7 @@ export function LibraryProvider({ children }) {
fetchUnitArchiveImpact, fetchUnitPermanentDeleteImpact, fetchUnitArchiveImpact, fetchUnitPermanentDeleteImpact,
fetchUnitFieldValues, fetchUnitFieldValues,
attachLessonsToUnit, detachLessonFromUnit, detachUnitFromCourse, attachUnitToCourses, reorderUnitLessons, attachLessonsToUnit, detachLessonFromUnit, detachUnitFromCourse, attachUnitToCourses, reorderUnitLessons,
attachLessonToUnits,
fetchUnitProduct, saveUnitProduct, removeUnitProduct, fetchUnitProduct, saveUnitProduct, removeUnitProduct,
// lesson library // lesson library
+3 -3
View File
@@ -41,7 +41,7 @@ export function AdminTierCategoriesProvider({ children }) {
const createCategory = useCallback((payload) => const createCategory = useCallback((payload) =>
request(async () => { request(async () => {
const { data } = await api.post("/admin/tiers/categories", payload); const { data } = await api.post("/admin/tiers/categories", payload);
toast("Tier category created."); toast("Subscription category created.");
return data.data; return data.data;
}), [request]); }), [request]);
@@ -52,7 +52,7 @@ export function AdminTierCategoriesProvider({ children }) {
prev.map((c) => (String(c.tier_category_id) === String(id) ? data.data : c)) prev.map((c) => (String(c.tier_category_id) === String(id) ? data.data : c))
); );
if (category && String(category.tier_category_id) === String(id)) setCategory(data.data); if (category && String(category.tier_category_id) === String(id)) setCategory(data.data);
toast("Tier category updated."); toast("Subscription category updated.");
return data.data; return data.data;
}), [request, category]); }), [request, category]);
@@ -60,7 +60,7 @@ export function AdminTierCategoriesProvider({ children }) {
request(async () => { request(async () => {
await api.delete(`/admin/tiers/categories/${id}`); await api.delete(`/admin/tiers/categories/${id}`);
setCategories((prev) => prev.filter((c) => String(c.tier_category_id) !== String(id))); setCategories((prev) => prev.filter((c) => String(c.tier_category_id) !== String(id)));
toast("Tier category deleted."); toast("Subscription category deleted.");
return true; return true;
}), [request]); }), [request]);
+5 -5
View File
@@ -167,7 +167,7 @@ export function AdminTiersProvider({ children }) {
try { try {
const { data } = await api.get(`/admin/tiers/users/${userId}/tiers`); const { data } = await api.get(`/admin/tiers/users/${userId}/tiers`);
setUserTiers(data.data ?? []); setUserTiers(data.data ?? []);
} catch { toast("Could not load user tiers."); } } catch { toast("Could not load user subscriptions."); }
finally { setLoading(false); } finally { setLoading(false); }
}, []); }, []);
@@ -175,10 +175,10 @@ export function AdminTiersProvider({ children }) {
setLoading(true); setLoading(true);
try { try {
await api.post("/admin/tiers/users/tiers/grant", payload); await api.post("/admin/tiers/users/tiers/grant", payload);
toast("Tier granted."); toast("Subscription granted.");
return true; return true;
} catch (err) { } catch (err) {
toast(err?.response?.data?.message ?? "Could not grant tier."); toast(err?.response?.data?.message ?? "Could not grant subscription.");
return false; return false;
} finally { setLoading(false); } } finally { setLoading(false); }
}, []); }, []);
@@ -187,10 +187,10 @@ export function AdminTiersProvider({ children }) {
setLoading(true); setLoading(true);
try { try {
await api.patch(`/admin/tiers/users/tiers/${tid}/revoke`); await api.patch(`/admin/tiers/users/tiers/${tid}/revoke`);
toast("Tier revoked."); toast("Subscription revoked.");
return true; return true;
} catch (err) { } catch (err) {
toast(err?.response?.data?.message ?? "Could not revoke tier."); toast(err?.response?.data?.message ?? "Could not revoke subscription.");
return false; return false;
} finally { setLoading(false); } } finally { setLoading(false); }
}, []); }, []);
+3 -3
View File
@@ -47,7 +47,7 @@ export function ClientTiersProvider({ children }) {
toast("Your subscription has expired. You've been moved to the Free plan."); toast("Your subscription has expired. You've been moved to the Free plan.");
} }
} catch (err) { } catch (err) {
if (!silent) toast(err?.response?.data?.message ?? "Could not load tier."); if (!silent) toast(err?.response?.data?.message ?? "Could not load subscription.");
} finally { } finally {
if (!silent) setTierLoading(false); if (!silent) setTierLoading(false);
} }
@@ -78,7 +78,7 @@ export function ClientTiersProvider({ children }) {
const { data } = await api.get("/client/tiers/me/history"); const { data } = await api.get("/client/tiers/me/history");
setTierHistory(data.data ?? []); setTierHistory(data.data ?? []);
} catch (err) { } catch (err) {
toast(err?.response?.data?.message ?? "Could not load tier history."); toast(err?.response?.data?.message ?? "Could not load subscription history.");
} finally { } finally {
setTierHistoryLoading(false); setTierHistoryLoading(false);
} }
@@ -130,7 +130,7 @@ export function ClientTiersProvider({ children }) {
setCheckoutLoading(true); setCheckoutLoading(true);
try { try {
const { data } = await api.post("/client/tiers/checkout/capture", { order_id }); const { data } = await api.post("/client/tiers/checkout/capture", { order_id });
toast(data.message ?? "Payment successful. Tier activated."); toast(data.message ?? "Payment successful. Subscription activated.");
setMyTier({ tier: data.data?.tier, expires_at: data.data?.expires_at, status: "active" }); setMyTier({ tier: data.data?.tier, expires_at: data.data?.expires_at, status: "active" });
// Refresh from server so the browser cache holds fresh Premium data — prevents // Refresh from server so the browser cache holds fresh Premium data — prevents
// subsequent getMyTier() calls from getting a stale 304 with the old Free/null response. // subsequent getMyTier() calls from getting a stale 304 with the old Free/null response.
+49 -18
View File
@@ -1,9 +1,10 @@
// contexts/UploadQueueContext.jsx // contexts/UploadQueueContext.jsx
// //
// Global, app-shell-mounted upload queue for the Add Asset multi-file drop // Global, app-shell-mounted upload queue shared by both Add File (single)
// zone. Lives above the router (see AdminProvider.jsx) so an in-flight // and Add Assets Bulk (multi-file drop zone). Lives above the router (see
// batch survives navigating away from the Add Asset page — the floating // AdminProvider.jsx) so an in-flight upload survives navigating away from
// UploadProgressToast reads the same state from anywhere in /admin. // either page — the floating UploadProgressToast reads the same state from
// anywhere in /admin.
// //
// Every job — whether it's part of an initial addBatch() or a single retry — // Every job — whether it's part of an initial addBatch() or a single retry —
// uploads directly to storage via its own presigned PUT (see // uploads directly to storage via its own presigned PUT (see
@@ -52,18 +53,26 @@ export function UploadQueueProvider({ children }) {
// now go straight to storage instead of buffering through the backend. // now go straight to storage instead of buffering through the backend.
const uploadOne = useCallback(async (job) => { const uploadOne = useCallback(async (job) => {
try { try {
const presigned = await presignAssetUpload(job.file); const [presigned, thumbPresigned] = await Promise.all([
presignAssetUpload(job.file),
job.thumbnail ? presignAssetUpload(job.thumbnail) : Promise.resolve(null),
]);
const storage_key = presigned.key; const storage_key = presigned.key;
await uploadPresigned(job.file, presigned, (pct) => patchJobs([job.id], { progress: pct })); await Promise.all([
uploadPresigned(job.file, presigned, (pct) => patchJobs([job.id], { progress: pct })),
thumbPresigned ? uploadPresigned(job.thumbnail, thumbPresigned) : Promise.resolve(),
]);
// Bulk display_name derivation only applies when the caller didn't // display_name/description are never collected from the user anymore
// already provide one — each job keeps whatever name it resolves to. // (Add File and Add Assets Bulk both dropped those fields) — every
// job's display_name is derived from its filename here.
const display_name = job.meta.display_name || baseNameOf(job.name); const display_name = job.meta.display_name || baseNameOf(job.name);
const { data } = await api.post("/admin/assets", { const { data } = await api.post("/admin/assets", {
...job.meta, ...job.meta,
storage_key, storage_key,
thumbnail_storage_key: thumbPresigned?.key,
original_name: job.name, original_name: job.name,
display_name, display_name,
// Fallback only — the backend prefers storage's own // Fallback only — the backend prefers storage's own
@@ -73,12 +82,15 @@ export function UploadQueueProvider({ children }) {
}); });
const asset = data?.data?.data; const asset = data?.data?.data;
patchJobs([job.id], { status: "uploaded", progress: 100, asset, error: null }); patchJobs([job.id], { status: "uploaded", progress: 100, asset, error: null });
return { id: job.id, name: job.name, status: "uploaded", asset };
} catch (err) { } catch (err) {
// No sonner toast() here — the floating widget (both corners // No sonner toast() here — the floating widget (see
// would collide, see UploadProgressToast) already surfaces this // UploadProgressToast) already surfaces this via the job's own
// via the job's own failed status. // failed status; addBatch()'s onSettled callback gets this same
// result too, for callers (e.g. Add File) that want their own toast.
const message = err?.response?.data?.message ?? "Upload failed."; const message = err?.response?.data?.message ?? "Upload failed.";
patchJobs([job.id], { status: "failed", progress: 100, error: message }); patchJobs([job.id], { status: "failed", progress: 100, error: message });
return { id: job.id, name: job.name, status: "failed", error: message };
} }
}, [patchJobs]); }, [patchJobs]);
@@ -91,29 +103,43 @@ export function UploadQueueProvider({ children }) {
const ids = batchJobs.map((j) => j.id); const ids = batchJobs.map((j) => j.id);
patchJobs(ids, { status: "uploading", progress: 0 }); patchJobs(ids, { status: "uploading", progress: 0 });
const results = new Array(batchJobs.length);
let next = 0; let next = 0;
const worker = async () => { const worker = async () => {
while (next < batchJobs.length) { while (next < batchJobs.length) {
await uploadOne(batchJobs[next++]); const i = next++;
results[i] = await uploadOne(batchJobs[i]);
} }
}; };
await Promise.all( await Promise.all(
Array.from({ length: Math.min(MAX_CONCURRENT, batchJobs.length) }, worker) Array.from({ length: Math.min(MAX_CONCURRENT, batchJobs.length) }, worker)
); );
onSettledRef.current.get(batchId)?.(); onSettledRef.current.get(batchId)?.(results);
onSettledRef.current.delete(batchId); onSettledRef.current.delete(batchId);
}, [patchJobs, uploadOne]); }, [patchJobs, uploadOne]);
// files: File[]; meta: { is_public, storage_provider, createdBy } // items: (File | { file: File, thumbnail?: File })[] — a plain File is what
const addBatch = useCallback((files, meta, { onSettled } = {}) => { // the bulk drop zone passes; Add Asset (single) passes the { file,
// thumbnail } shape so its optional video/audio thumbnail rides along
// with the same job. meta: { is_public, storage_provider, createdBy }
// source: "single" | "bulk" — both flows share this one queue (and the
// floating UploadProgressToast) so an upload survives navigation either
// way, but Add Assets Bulk only lists jobs tagged "bulk" on its own page
// (see AddAssetsBulk.jsx) so a lone Add File upload doesn't show up
// there looking like a bulk batch that was never actually started.
const addBatch = useCallback((items, meta, { onSettled, source = "bulk" } = {}) => {
const batchId = nanoid(); const batchId = nanoid();
const newJobs = files.map((file) => { const newJobs = items.map((item) => {
const file = item instanceof File ? item : item.file;
const thumbnail = item instanceof File ? undefined : item.thumbnail;
const { ok, reason } = validateAssetFile(file); const { ok, reason } = validateAssetFile(file);
return { return {
id: nanoid(), id: nanoid(),
batchId, batchId,
source,
file, file,
thumbnail,
name: file.name, name: file.name,
size: file.size, size: file.size,
mime: file.type, mime: file.type,
@@ -153,8 +179,13 @@ export function UploadQueueProvider({ children }) {
setJobs((prev) => prev.filter((j) => j.id !== jobId || j.status === "uploading")); setJobs((prev) => prev.filter((j) => j.id !== jobId || j.status === "uploading"));
}, []); }, []);
const clearFinished = useCallback(() => { // source: restricts the clear to that source's finished jobs only, so
setJobs((prev) => prev.filter((j) => j.status === "uploading" || j.status === "queued")); // clicking "Clear finished" on the Bulk page can't wipe out a still-shown
// Add File (single) entry in the floating toast, and vice versa.
const clearFinished = useCallback((source) => {
setJobs((prev) => prev.filter((j) =>
j.status === "uploading" || j.status === "queued" || (source && j.source !== source)
));
}, []); }, []);
return ( return (
+16 -16
View File
@@ -105,16 +105,16 @@ export const ACTION_CONFIG = {
add_user_to_group: { label: "Added User to Group", group: "grp" }, add_user_to_group: { label: "Added User to Group", group: "grp" },
remove_user_from_group:{ label: "Removed User from Group", group: "grp" }, remove_user_from_group:{ label: "Removed User from Group", group: "grp" },
// ── Tier Plans ────────────────────────────────────────────────────────────── // ── Subscriptions ───────────────────────────────────────────────────────────
create_tier_plan: { label: "Created Tier Plan", group: "commerce" }, create_tier_plan: { label: "Created Subscription", group: "commerce" },
update_tier_plan: { label: "Updated Tier Plan", group: "commerce" }, update_tier_plan: { label: "Updated Subscription", group: "commerce" },
archive_tier_plan: { label: "Archived Tier Plan", group: "commerce" }, archive_tier_plan: { label: "Archived Subscription", group: "commerce" },
restore_tier_plan: { label: "Restored Tier Plan", group: "commerce" }, restore_tier_plan: { label: "Restored Subscription", group: "commerce" },
bulk_archive_tier_plans: { label: "Bulk Archived Tier Plans", group: "commerce" }, bulk_archive_tier_plans: { label: "Bulk Archived Subscriptions", group: "commerce" },
bulk_restore_tier_plans: { label: "Bulk Restored Tier Plans", group: "commerce" }, bulk_restore_tier_plans: { label: "Bulk Restored Subscriptions", group: "commerce" },
sync_plan_courses: { label: "Synced Plan Courses", group: "commerce" }, sync_plan_courses: { label: "Synced Plan Courses", group: "commerce" },
grant_tier: { label: "Granted Tier", group: "success" }, grant_tier: { label: "Granted Subscription", group: "success" },
revoke_tier: { label: "Revoked Tier", group: "danger" }, revoke_tier: { label: "Revoked Subscription", group: "danger" },
// ── Products & Categories ─────────────────────────────────────────────────── // ── Products & Categories ───────────────────────────────────────────────────
upsert_course_product: { label: "Set Course Product", group: "commerce" }, upsert_course_product: { label: "Set Course Product", group: "commerce" },
@@ -125,13 +125,13 @@ export const ACTION_CONFIG = {
archive_category: { label: "Archived Category", group: "commerce" }, archive_category: { label: "Archived Category", group: "commerce" },
restore_category: { label: "Restored Category", group: "commerce" }, restore_category: { label: "Restored Category", group: "commerce" },
// ── Assets ────────────────────────────────────────────────────────────────── // ── Files ───────────────────────────────────────────────────────────────────
upload_asset: { label: "Uploaded Asset", group: "content" }, upload_asset: { label: "Uploaded File", group: "content" },
update_asset: { label: "Updated Asset", group: "content" }, update_asset: { label: "Updated File", group: "content" },
archive_asset: { label: "Archived Asset", group: "content" }, archive_asset: { label: "Archived File", group: "content" },
restore_asset: { label: "Restored Asset", group: "content" }, restore_asset: { label: "Restored File", group: "content" },
bulk_archive_assets:{ label: "Bulk Archived Assets", group: "content" }, bulk_archive_assets:{ label: "Bulk Archived Files", group: "content" },
bulk_restore_assets:{ label: "Bulk Restored Assets", group: "content" }, bulk_restore_assets:{ label: "Bulk Restored Files", group: "content" },
// ── Advertisements ────────────────────────────────────────────────────────── // ── Advertisements ──────────────────────────────────────────────────────────
create_advertisement: { label: "Created Ad", group: "content" }, create_advertisement: { label: "Created Ad", group: "content" },
+3 -3
View File
@@ -16,10 +16,10 @@ export const ADMIN_SECTIONS = [
id: "section-resources", id: "section-resources",
tab: "Resource Management", tab: "Resource Management",
title: "Resource Management", title: "Resource Management",
description: "It includes assets management and tier plans.", description: "It includes files management and subscriptions.",
tiles: [ tiles: [
{ key: "assets", label: "Assets", icon: FolderOpen, link: "/admin/assets" }, { key: "assets", label: "Files", icon: FolderOpen, link: "/admin/assets" },
{ key: "tiers", label: "Tier Plans", icon: ShieldCheck, link: "/admin/tiers/plans" }, { key: "tiers", label: "Subscriptions", icon: ShieldCheck, link: "/admin/tiers/plans" },
], ],
}, },
{ {
+1 -1
View File
@@ -20,7 +20,7 @@ export const JOB_LABELS = {
taskOverdue: { label: "Task Alerts (Admin)", description: "Automatically marks expired tasks as overdue or completed, and notifies admins." }, taskOverdue: { label: "Task Alerts (Admin)", description: "Automatically marks expired tasks as overdue or completed, and notifies admins." },
userNotifications: { label: "Task Alerts (Users)", description: "Notifies affected users when their tasks are automatically marked overdue or completed." }, userNotifications: { label: "Task Alerts (Users)", description: "Notifies affected users when their tasks are automatically marked overdue or completed." },
issueCertificates: { label: "Certificate Issued", description: "Notifies users when a course certificate is ready." }, issueCertificates: { label: "Certificate Issued", description: "Notifies users when a course certificate is ready." },
expireUserTiers: { label: "Tier Expired", description: "Notifies users when their subscription tier expires." }, expireUserTiers: { label: "Subscription Expired", description: "Notifies users when their subscription expires." },
}; };
// The only jobs that support a configurable target_status, and the values it accepts. // The only jobs that support a configurable target_status, and the values it accepts.
+1 -1
View File
@@ -11,7 +11,7 @@ export const TARGET_TYPE_OPTIONS = [
{ value: "both", label: "Admins & Users", icon: Megaphone, description: "Sent to admins and every active user", needsTarget: false }, { value: "both", label: "Admins & Users", icon: Megaphone, description: "Sent to admins and every active user", needsTarget: false },
{ value: "task_list", label: "Task List", icon: ListCheck, description: "Sent to everyone assigned to a specific task list", needsTarget: true }, { value: "task_list", label: "Task List", icon: ListCheck, description: "Sent to everyone assigned to a specific task list", needsTarget: true },
{ value: "course", label: "Course", icon: BookText, description: "Sent to everyone with access to a specific course", needsTarget: true }, { value: "course", label: "Course", icon: BookText, description: "Sent to everyone with access to a specific course", needsTarget: true },
{ value: "tier_plan", label: "Tier Plan", icon: ShieldCheck, description: "Sent to everyone currently on a specific tier plan", needsTarget: true }, { value: "tier_plan", label: "Subscription", icon: ShieldCheck, description: "Sent to everyone currently on a specific subscription plan", needsTarget: true },
]; ];
export const TARGET_TYPE_MAP = Object.fromEntries( export const TARGET_TYPE_MAP = Object.fromEntries(
+1 -1
View File
@@ -8,7 +8,7 @@
export const PLACEMENTS = [ export const PLACEMENTS = [
{ key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" }, { key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" },
{ key: "tier_plans.banner", format: "banner", page: "tier_plans", pageLabel: "Tier Plans", slotLabel: "Banner (above plan cards)" }, { key: "tier_plans.banner", format: "banner", page: "tier_plans", pageLabel: "Subscriptions", slotLabel: "Banner (above plan cards)" },
{ key: "course_details.banner", format: "banner", page: "course_details", pageLabel: "Course Details", slotLabel: "Banner (below hero)" }, { key: "course_details.banner", format: "banner", page: "course_details", pageLabel: "Course Details", slotLabel: "Banner (below hero)" },
]; ];
@@ -51,8 +51,8 @@ export default function ArchivedAssetsTable() {
const exportConfig = { const exportConfig = {
allData: assets, allData: assets,
attributes, attributes,
filename: `${getTimestamp()}_ArchivedAssets`, filename: `${getTimestamp()}_ArchivedFiles`,
sheetName: "Archived Assets", sheetName: "Archived Files",
generatedBy: formatGeneratedBy(currentUser), generatedBy: formatGeneratedBy(currentUser),
}; };
@@ -102,7 +102,7 @@ export default function ArchivedAssetsTable() {
return ( return (
<> <>
<DataTable <DataTable
title="Archived Assets" title="Archived Files"
data={assets} data={assets}
columns={columns} columns={columns}
attributes={attributes} attributes={attributes}
@@ -125,8 +125,8 @@ export default function ArchivedAssetsTable() {
columnPinning={columnPinning} columnPinning={columnPinning}
toolbarActions={toolbarActions} toolbarActions={toolbarActions}
selectionActions={selectionActions} selectionActions={selectionActions}
recordLabel="archived asset" recordLabel="archived file"
emptyMessage="No archived assets found." emptyMessage="No archived files found."
/> />
{/* ── Single restore ── */} {/* ── Single restore ── */}
@@ -134,7 +134,7 @@ export default function ArchivedAssetsTable() {
open={!!restoreTarget} open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)} onOpenChange={(v) => !v && setRestoreTarget(null)}
entity={restoreTarget} entity={restoreTarget}
entityLabel="Asset" entityLabel="File"
getName={(a) => a?.display_name ?? a?.original_name} getName={(a) => a?.display_name ?? a?.original_name}
onRestore={(a) => restoreAsset(a?.asset_id)} onRestore={(a) => restoreAsset(a?.asset_id)}
loading={loading} loading={loading}
@@ -146,7 +146,7 @@ export default function ArchivedAssetsTable() {
open={!!restoreIds} open={!!restoreIds}
onOpenChange={(v) => !v && setRestoreIds(null)} onOpenChange={(v) => !v && setRestoreIds(null)}
ids={restoreIds ?? []} ids={restoreIds ?? []}
entityLabel="Asset" entityLabel="File"
onRestore={(ids) => restoreAssets(ids)} onRestore={(ids) => restoreAssets(ids)}
loading={loading} loading={loading}
onSuccess={handleRestoreSuccess} onSuccess={handleRestoreSuccess}
@@ -157,7 +157,7 @@ export default function ArchivedAssetsTable() {
open={!!deleteTarget} open={!!deleteTarget}
onOpenChange={(v) => !v && setDeleteTarget(null)} onOpenChange={(v) => !v && setDeleteTarget(null)}
entity={deleteTarget} entity={deleteTarget}
entityLabel="Asset" entityLabel="File"
getName={(a) => a?.display_name ?? a?.original_name} getName={(a) => a?.display_name ?? a?.original_name}
onDelete={(a) => permanentlyDeleteAsset(a?.asset_id)} onDelete={(a) => permanentlyDeleteAsset(a?.asset_id)}
loading={loading} loading={loading}
@@ -169,7 +169,7 @@ export default function ArchivedAssetsTable() {
open={!!deleteIds} open={!!deleteIds}
onOpenChange={(v) => !v && setDeleteIds(null)} onOpenChange={(v) => !v && setDeleteIds(null)}
ids={deleteIds ?? []} ids={deleteIds ?? []}
entityLabel="Asset" entityLabel="File"
onDelete={(ids) => permanentlyDeleteAssets(ids)} onDelete={(ids) => permanentlyDeleteAssets(ids)}
loading={loading} loading={loading}
onSuccess={handleDeleteSuccess} onSuccess={handleDeleteSuccess}
@@ -45,8 +45,8 @@ export default function AssetsTable() {
const exportConfig = { const exportConfig = {
allData: assets, allData: assets,
attributes, attributes,
filename: `${getTimestamp()}_Assets`, filename: `${getTimestamp()}_Files`,
sheetName: "Assets", sheetName: "Files",
generatedBy: formatGeneratedBy(currentUser), generatedBy: formatGeneratedBy(currentUser),
}; };
@@ -94,7 +94,7 @@ export default function AssetsTable() {
return ( return (
<> <>
<DataTable <DataTable
title="Assets" title="Files"
data={assets} data={assets}
columns={columns} columns={columns}
attributes={attributes} attributes={attributes}
@@ -117,8 +117,8 @@ export default function AssetsTable() {
columnPinning={columnPinning} columnPinning={columnPinning}
toolbarActions={toolbarActions} toolbarActions={toolbarActions}
selectionActions={selectionActions} selectionActions={selectionActions}
recordLabel="asset" recordLabel="file"
emptyMessage="No assets match the current filters." emptyMessage="No files match the current filters."
/> />
{/* ── Single archive ── */} {/* ── Single archive ── */}
@@ -126,7 +126,7 @@ export default function AssetsTable() {
open={!!archiveTarget} open={!!archiveTarget}
onOpenChange={(v) => !v && setArchiveTarget(null)} onOpenChange={(v) => !v && setArchiveTarget(null)}
entity={archiveTarget} entity={archiveTarget}
entityLabel="Asset" entityLabel="File"
getName={(a) => a?.display_name ?? a?.original_name} getName={(a) => a?.display_name ?? a?.original_name}
onArchive={(a) => archiveAsset(a?.asset_id)} onArchive={(a) => archiveAsset(a?.asset_id)}
loading={loading} loading={loading}
@@ -138,7 +138,7 @@ export default function AssetsTable() {
open={!!archiveIds} open={!!archiveIds}
onOpenChange={(v) => !v && setArchiveIds(null)} onOpenChange={(v) => !v && setArchiveIds(null)}
ids={archiveIds ?? []} ids={archiveIds ?? []}
entityLabel="Asset" entityLabel="File"
onArchive={(ids) => archiveAssets(ids)} onArchive={(ids) => archiveAssets(ids)}
loading={loading} loading={loading}
onSuccess={handleArchiveSuccess} onSuccess={handleArchiveSuccess}
@@ -23,7 +23,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
const TRIGGER_OPTIONS = [ const TRIGGER_OPTIONS = [
{ value: "auth", label: "Auth (registration / login)" }, { value: "auth", label: "Auth (registration / login)" },
{ value: "tier", label: "Tier (subscription purchase)" }, { value: "tier", label: "Subscription (plan purchase)" },
{ value: "course", label: "Course (lessons / quizzes)" }, { value: "course", label: "Course (lessons / quizzes)" },
{ value: "profile", label: "Profile completion" }, { value: "profile", label: "Profile completion" },
{ value: "social", label: "Social (referrals / community)" }, { value: "social", label: "Social (referrals / community)" },
@@ -0,0 +1,124 @@
// AttachLessonToUnitsDialog — pick existing Units and attach this Lesson to them.
// Reverse direction of AttachLessonsDialog: unit_lessons has no exclusivity,
// so a Lesson can freely belong to several Units at once (multi-select).
import { useEffect, useMemo, useState } from "react";
import { Search, Link2 } from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
import {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { formatDuration } from "@/utils/timestamp.util";
export default function AttachLessonToUnitsDialog({ open, onOpenChange, attachedUnitIds = [], onAttach, loading }) {
const { unitsFlat, fetchUnitsFlat, loading: libraryLoading } = useLibrary();
const [query, setQuery] = useState("");
const [selected, setSelected] = useState([]);
useEffect(() => {
if (open) {
setSelected([]);
setQuery("");
fetchUnitsFlat();
}
}, [open, fetchUnitsFlat]);
const attachedSet = useMemo(
() => new Set(attachedUnitIds.map(String)),
[attachedUnitIds]
);
const candidates = useMemo(() => {
const q = query.trim().toLowerCase();
return (unitsFlat ?? [])
.filter((u) => !attachedSet.has(String(u.unit_id)))
.filter((u) => !q || u.title?.toLowerCase().includes(q));
}, [unitsFlat, attachedSet, query]);
const toggle = (unitId) =>
setSelected((prev) =>
prev.includes(unitId) ? prev.filter((id) => id !== unitId) : [...prev, unitId]
);
const handleAttach = async () => {
if (!selected.length) return;
await onAttach(selected);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Link2 className="h-4 w-4" /> Select Units
</DialogTitle>
<DialogDescription>
Attach this lesson to one or more existing units without copying it.
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search units..."
className="pl-8"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<ScrollArea className="h-64 rounded-md border">
{libraryLoading ? (
<div className="flex items-center justify-center h-full py-10">
<Spinner className="h-5 w-5" />
</div>
) : candidates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
{query ? "No units match your search." : "This lesson is already attached to every unit."}
</p>
) : (
<div className="divide-y">
{candidates.map((u) => (
<label
key={u.unit_id}
className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/60 cursor-pointer"
>
<Checkbox
checked={selected.includes(u.unit_id)}
onCheckedChange={() => toggle(u.unit_id)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium w-64 truncate" title={u.title}>
{u.title}
</p>
<p className="text-xs text-muted-foreground truncate">
{formatDuration(u.duration_seconds ?? 0)}
</p>
</div>
</label>
))}
</div>
)}
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button onClick={handleAttach} disabled={loading || !selected.length}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Attach {selected.length > 0 ? `(${selected.length})` : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -51,7 +51,7 @@ export default function ArchivedTierPlansTable() {
allData: plans, allData: plans,
attributes: planAttributes, attributes: planAttributes,
filename: `${getTimestamp()}_ArchivedTierPlans`, filename: `${getTimestamp()}_ArchivedTierPlans`,
sheetName: "Archived Tier Plans", sheetName: "Archived Subscriptions",
generatedBy: formatGeneratedBy(currentUser), generatedBy: formatGeneratedBy(currentUser),
}), [plans, planAttributes, currentUser]); }), [plans, planAttributes, currentUser]);
@@ -67,7 +67,7 @@ export default function TierPlansTable() {
allData: plans, allData: plans,
attributes: planAttributes, attributes: planAttributes,
filename: `${getTimestamp()}_TierPlans`, filename: `${getTimestamp()}_TierPlans`,
sheetName: "Tier Plans", sheetName: "Subscriptions",
generatedBy: formatGeneratedBy(currentUser), generatedBy: formatGeneratedBy(currentUser),
}), [plans, planAttributes, currentUser]); }), [plans, planAttributes, currentUser]);
@@ -105,16 +105,16 @@ export default function TierPlansTable() {
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-4"> <div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-4">
<Layers className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" /> <Layers className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No tier categories defined.{" "} No subscription categories defined.{" "}
<Link to="/admin/tiers/categories/add" className="underline text-primary font-medium"> <Link to="/admin/tiers/categories/add" className="underline text-primary font-medium">
Add a tier category Add a subscription category
</Link>{" "} </Link>{" "}
before creating plans. before creating plans.
</p> </p>
</div> </div>
)} )}
<DataTable <DataTable
title="Tier Plans" title="Subscriptions"
data={plans} data={plans}
columns={columns} columns={columns}
attributes={planAttributes} attributes={planAttributes}
@@ -138,7 +138,7 @@ export default function TierPlansTable() {
toolbarActions={toolbarActions} toolbarActions={toolbarActions}
selectionActions={selectionActions} selectionActions={selectionActions}
recordLabel="plan" recordLabel="plan"
emptyMessage="No tier plans found." emptyMessage="No subscriptions found."
/> />
{/* Single archive — always force-revokes current subscribers' access (no refund), handled server-side */} {/* Single archive — always force-revokes current subscribers' access (no refund), handled server-side */}
@@ -46,6 +46,6 @@ export function buildDataColumns(attributes, rowActions) {
return [ return [
buildSelectionColumn(), buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }), ...buildColumns(visibleAttributes, { cellOverrides }),
buildRowActionsColumn(rowActions, { dropdownLabel: "Asset Actions" }), buildRowActionsColumn(rowActions, { dropdownLabel: "File Actions" }),
]; ];
} }
@@ -46,6 +46,6 @@ export function buildDataColumns(attributes, rowActions) {
return [ return [
buildSelectionColumn(), buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }), ...buildColumns(visibleAttributes, { cellOverrides }),
buildRowActionsColumn(rowActions, { dropdownLabel: "Asset Actions" }), buildRowActionsColumn(rowActions, { dropdownLabel: "File Actions" }),
]; ];
} }
@@ -42,7 +42,7 @@ export function buildToolbarActions({ fetchAssets, pagination, exportConfig, nav
key: "add-asset", key: "add-asset",
type: "button", type: "button",
icon: <Plus className="h-3.5 w-3.5" />, icon: <Plus className="h-3.5 w-3.5" />,
label: "Add Asset", label: "Add File",
variant: "default", variant: "default",
className: "text-primary-foreground", className: "text-primary-foreground",
onClick: () => navigate("add"), onClick: () => navigate("add"),
@@ -60,7 +60,7 @@ export function buildToolbarActions({ fetchAssets, pagination, exportConfig, nav
key: "archived-users", key: "archived-users",
type: "button", type: "button",
icon: <Archive className="h-3.5 w-3.5" />, icon: <Archive className="h-3.5 w-3.5" />,
label: "Archived Assets", label: "Archived Files",
variant: "secondary", variant: "secondary",
className: "border border-border", className: "border border-border",
onClick: () => navigate("/admin/assets/archived"), onClick: () => navigate("/admin/assets/archived"),
@@ -39,7 +39,7 @@ export function buildToolbarActions({
{ {
key: "categories", key: "categories",
type: "button", type: "button",
label: "Tier Categories", label: "Subscription Categories",
icon: <Layers className="h-3.5 w-3.5" />, icon: <Layers className="h-3.5 w-3.5" />,
variant: "outline", variant: "outline",
onClick: () => navigate("/admin/tiers/categories"), onClick: () => navigate("/admin/tiers/categories"),
@@ -55,7 +55,7 @@ const schema = z.object({
// ─── Steps ──────────────────────────────────────────────────────────────────── // ─── Steps ────────────────────────────────────────────────────────────────────
const ALL_STEPS = [ const ALL_STEPS = [
{ id: "placement", label: "Placement", icon: MapPin, description: "Choose where this ad appears — Dashboard, Tier Plans, or Course Details." }, { id: "placement", label: "Placement", icon: MapPin, description: "Choose where this ad appears — Dashboard, Subscriptions, or Course Details." },
{ id: "content", label: "Content", icon: FileText, description: "Badge labels, headline, description, image, and link for this ad." }, { id: "content", label: "Content", icon: FileText, description: "Badge labels, headline, description, image, and link for this ad." },
{ id: "scheduling", label: "Scheduling & Display", icon: CalendarClock, description: "Start/end dates and draft/active status." }, { id: "scheduling", label: "Scheduling & Display", icon: CalendarClock, description: "Start/end dates and draft/active status." },
{ id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this ad." }, { id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this ad." },
@@ -267,7 +267,7 @@ export default function EditAdvertisement() {
</div> </div>
</SectionCard> </SectionCard>
<SectionCard title="Image" description="Choose an existing asset from Asset Management."> <SectionCard title="Image" description="Choose an existing file from File Management.">
{selectedAsset ? ( {selectedAsset ? (
<div <div
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group" className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
+40 -84
View File
@@ -5,19 +5,17 @@ import { Link, useNavigate } from "react-router-dom";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { ArrowLeft, ArrowRight, UploadCloud, X, FileVideo, FileText, Image, FileAudio } from "lucide-react"; import { ArrowLeft, ArrowRight, UploadCloud, X, FileVideo, FileText, Image, FileAudio } from "lucide-react";
import { useAssets } from "@/contexts/AdminAssetsContext"; import { useAssets } from "@/contexts/AdminAssetsContext";
import { useUploadQueue } from "@/contexts/UploadQueueContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { MAX_ASSET_FILE_SIZE_SINGLE, MAX_ASSET_FILE_SIZE_SINGLE_LABEL } from "@/utils/assetUpload.util"; import { MAX_ASSET_FILE_SIZE_SINGLE, MAX_ASSET_FILE_SIZE_SINGLE_LABEL } from "@/utils/assetUpload.util";
import { formatFileSize } from "@/utils/format.util"; import { formatFileSize } from "@/utils/format.util";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Progress } from "@/components/ui/progress";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -39,8 +37,6 @@ function resolveFileType(mimeType = "") {
// ─── Schema ─────────────────────────────────────────────────────────────────── // ─── Schema ───────────────────────────────────────────────────────────────────
const schema = z.object({ const schema = z.object({
display_name: z.string().min(1, "Display name is required."),
description: z.string().optional(),
is_public: z.enum(["true", "false"]), is_public: z.enum(["true", "false"]),
storage_provider: z.enum(["chibisafe", "local", "s3"]), storage_provider: z.enum(["chibisafe", "local", "s3"]),
}); });
@@ -119,16 +115,15 @@ function FieldError({ message }) {
export default function AddAsset() { export default function AddAsset() {
const navigate = useNavigate(); const navigate = useNavigate();
const { uploadAsset, loading } = useAssets(); const { fetchAssets } = useAssets();
const { addBatch } = useUploadQueue();
const { user } = useAuth(); const { user } = useAuth();
const fileRef = useRef(null); const fileRef = useRef(null);
const thumbnailRef = useRef(null); const thumbnailRef = useRef(null);
const [thumbKey, setThumbKey] = useState(0); const [thumbKey, setThumbKey] = useState(0);
const [progress, setProgress] = useState(null); // { phase: 'uploading'|'processing'|'done'|'error', pct } | null
const { const {
register,
control, control,
handleSubmit, handleSubmit,
setValue, setValue,
@@ -139,8 +134,6 @@ export default function AddAsset() {
} = useForm({ } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
display_name: "",
description: "",
is_public: "false", is_public: "false",
storage_provider: "s3", // ← changed from "chibisafe" storage_provider: "s3", // ← changed from "chibisafe"
}, },
@@ -164,7 +157,6 @@ export default function AddAsset() {
} }
fileRef.current = f; fileRef.current = f;
setValue("_file", f); setValue("_file", f);
if (!watch("display_name")) setValue("display_name", f.name);
clearErrors("_file"); clearErrors("_file");
}; };
@@ -174,40 +166,46 @@ export default function AddAsset() {
clearErrors("_thumbnail"); clearErrors("_thumbnail");
}; };
const onSubmit = async (data) => { // Fire-and-forget: the job goes on the same global UploadQueueContext the
let hasFileError = false; // Bulk flow uses, so it keeps uploading in the background no matter where
// you navigate to next — a single sonner toast.promise() tracks it
// through loading -> success/error instead of a dedicated widget.
const onSubmit = (data) => {
if (!fileRef.current) { if (!fileRef.current) {
setError("_file", { message: "A file is required." }); setError("_file", { message: "A file is required." });
hasFileError = true; return;
} }
if (hasFileError) return; const fileName = fileRef.current.name;
const uploadPromise = new Promise((resolve, reject) => {
setProgress({ phase: "uploading", pct: 0 }); addBatch(
const result = await uploadAsset({ [{ file: fileRef.current, thumbnail: thumbnailRef.current ?? undefined }],
file: fileRef.current, {
thumbnail: thumbnailRef.current ?? undefined, is_public: data.is_public === "true",
display_name: data.display_name, storage_provider: data.storage_provider,
description: data.description ?? "", createdBy: user?.user_id,
file_type: fileType, },
is_public: data.is_public === "true", {
storage_provider: data.storage_provider, source: "single",
createdBy: user?.user_id, onSettled: ([result]) => {
onProgress: setProgress, fetchAssets({ force: true });
if (result?.status === "uploaded") resolve(result);
else reject(new Error(result?.error || "Upload failed."));
},
}
);
}); });
setProgress(null);
if (result) { bypassOnce(); navigate("/admin/assets"); } toast.promise(uploadPromise, {
loading: `Uploading ${fileName}…`,
success: (result) => `${result.name} uploaded successfully.`,
error: (err) => err.message || "Upload failed.",
});
bypassOnce();
navigate("/admin/assets");
}; };
const progressLabel = {
uploading: "Uploading…",
processing: "Processing…",
done: "Done.",
error: "Upload failed.",
}[progress?.phase];
return ( return (
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6"> <div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
@@ -217,8 +215,8 @@ export default function AddAsset() {
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div> <div>
<h1 className="text-xl font-semibold">Add Asset</h1> <h1 className="text-xl font-semibold">Add File</h1>
<p className="text-sm text-muted-foreground">Upload a new file to the asset library.</p> <p className="text-sm text-muted-foreground">Upload a new file to the file library.</p>
</div> </div>
</div> </div>
@@ -236,7 +234,6 @@ export default function AddAsset() {
onClear={() => { onClear={() => {
fileRef.current = null; fileRef.current = null;
setValue("_file", null); setValue("_file", null);
setValue("display_name", "");
clearErrors("_file"); clearErrors("_file");
}} }}
error={errors._file?.message} error={errors._file?.message}
@@ -277,30 +274,6 @@ export default function AddAsset() {
</div> </div>
)} )}
{/* ── Display Name ── */}
<div className="space-y-1.5">
<Label htmlFor="display_name">
Display Name <span className="text-destructive">*</span>
</Label>
<Input
id="display_name"
placeholder="Friendly name for this asset"
{...register("display_name")}
/>
<FieldError message={errors.display_name?.message} />
</div>
{/* ── Description ── */}
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Optional description"
rows={3}
{...register("description")}
/>
</div>
{/* ── File Type · Access · Storage (one row) ── */} {/* ── File Type · Access · Storage (one row) ── */}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
@@ -355,29 +328,12 @@ export default function AddAsset() {
</div> </div>
{/* ── Upload progress (real — see AdminAssetsContext.uploadAsset) ── */}
{progress && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{progressLabel}</span>
<span>{progress.pct ?? 0}%</span>
</div>
<Progress value={progress.pct ?? 0} />
</div>
)}
{/* ── Actions ── */} {/* ── Actions ── */}
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Button <Button type="button" variant="outline" onClick={() => navigate("/admin/assets")}>
type="button"
variant="outline"
onClick={() => navigate("/admin/assets")}
disabled={loading}
>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={loading}> <Button type="submit">
{loading && <Spinner className="h-4 w-4 mr-2" />}
Upload Asset Upload Asset
</Button> </Button>
</div> </div>
@@ -122,9 +122,14 @@ function FileRow({ job, onRetry, onRemove }) {
export default function AddAssetsBulk() { export default function AddAssetsBulk() {
const navigate = useNavigate(); const navigate = useNavigate();
const { fetchAssets } = useAssets(); const { fetchAssets } = useAssets();
const { jobs, addBatch, retryJob, removeJob, clearFinished } = useUploadQueue(); const { jobs: allJobs, addBatch, retryJob, removeJob, clearFinished } = useUploadQueue();
const { user } = useAuth(); const { user } = useAuth();
// Add File (single) shares this same queue so its upload survives
// navigation too — but it's a separate flow the user never asked to
// batch, so it shouldn't appear here looking like part of a bulk run.
const jobs = allJobs.filter((j) => j.source === "bulk");
const [isPublic, setIsPublic] = useState("false"); const [isPublic, setIsPublic] = useState("false");
// TEMPORARY: locked to S3 (see zrok tunnel note; revert after VPS migration) // TEMPORARY: locked to S3 (see zrok tunnel note; revert after VPS migration)
const storageProvider = "s3"; const storageProvider = "s3";
@@ -140,6 +145,7 @@ export default function AddAssetsBulk() {
createdBy: user?.user_id, createdBy: user?.user_id,
}, { }, {
onSettled: () => fetchAssets({ force: true }), onSettled: () => fetchAssets({ force: true }),
source: "bulk",
}); });
}; };
@@ -152,9 +158,9 @@ export default function AddAssetsBulk() {
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div> <div>
<h1 className="text-xl font-semibold">Uploading Assets</h1> <h1 className="text-xl font-semibold">Uploading Files</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{total ? `${total} file${total === 1 ? "" : "s"} · ${done} done · ${failed} failed` : "Upload one or more files to the asset library."} {total ? `${total} file${total === 1 ? "" : "s"} · ${done} done · ${failed} failed` : "Upload one or more files to the file library."}
</p> </p>
</div> </div>
</div> </div>
@@ -195,7 +201,7 @@ export default function AddAssetsBulk() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Files</span> <span className="text-sm font-medium text-muted-foreground">Files</span>
{(done > 0 || failed > 0) && ( {(done > 0 || failed > 0) && (
<Button type="button" variant="ghost" size="sm" onClick={clearFinished}> <Button type="button" variant="ghost" size="sm" onClick={() => clearFinished("bulk")}>
<Trash2 className="h-3.5 w-3.5 mr-1" /> Clear finished <Trash2 className="h-3.5 w-3.5 mr-1" /> Clear finished
</Button> </Button>
)} )}
@@ -6,7 +6,7 @@ import ArchivedAssetsTable from "../../components/assets/ArchivedAssetsTable";
export default function ArchivedAssetList() { export default function ArchivedAssetList() {
const items = [ const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/admin` }, { label: "Home", icon: <House className="size-4" />, to: `/admin` },
{ label: "Assets", to: `/admin/assets` }, { label: "Files", to: `/admin/assets` },
{ label: "Archived" }, { label: "Archived" },
]; ];
+1 -1
View File
@@ -6,7 +6,7 @@ import AssetsTable from "../../components/assets/AssetsTable";
export default function AssetList() { export default function AssetList() {
const items = [ const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/admin` }, { label: "Home", icon: <House className="size-4" />, to: `/admin` },
{ label: "Assets" }, { label: "Files" },
] ]
return ( return (
+3 -3
View File
@@ -209,7 +209,7 @@ export default function EditAsset() {
if (!asset) { if (!asset) {
return ( return (
<div className="max-w-2xl mx-auto px-4 py-6"> <div className="max-w-2xl mx-auto px-4 py-6">
<p className="text-sm text-muted-foreground">Asset not found.</p> <p className="text-sm text-muted-foreground">File not found.</p>
</div> </div>
); );
} }
@@ -223,7 +223,7 @@ export default function EditAsset() {
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div> <div>
<h1 className="text-xl font-semibold">Edit Asset</h1> <h1 className="text-xl font-semibold">Edit File</h1>
<p className="text-sm text-muted-foreground truncate max-w-sm"> <p className="text-sm text-muted-foreground truncate max-w-sm">
{asset.original_name} {asset.original_name}
</p> </p>
@@ -287,7 +287,7 @@ export default function EditAsset() {
</Label> </Label>
<Input <Input
id="display_name" id="display_name"
placeholder="Friendly name for this asset" placeholder="Friendly name for this file"
{...register("display_name")} {...register("display_name")}
/> />
<FieldError message={errors.display_name?.message} /> <FieldError message={errors.display_name?.message} />
@@ -43,7 +43,7 @@ export default function ViewAudioAsset() {
if (notFound) { if (notFound) {
return ( return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground"> <div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p> <p>File not found.</p>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button> <Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div> </div>
); );
@@ -40,7 +40,7 @@ export default function ViewDocumentAsset() {
if (notFound) { if (notFound) {
return ( return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground"> <div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p> <p>File not found.</p>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button> <Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div> </div>
); );
@@ -51,7 +51,7 @@ export default function ViewImageAsset() {
if (notFound) { if (notFound) {
return ( return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground"> <div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p> <p>File not found.</p>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button> <Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div> </div>
); );
@@ -37,7 +37,7 @@ export default function ViewVideoAsset() {
if (notFound) { if (notFound) {
return ( return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground"> <div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p> <p>File not found.</p>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button> <Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div> </div>
); );
@@ -593,7 +593,7 @@ export default function AddCourse() {
className="h-7 text-xs" className="h-7 text-xs"
> >
<ImagePlus className="h-3 w-3 mr-1" /> <ImagePlus className="h-3 w-3 mr-1" />
{badgeImageUrl ? "Change" : "Pick from assets"} {badgeImageUrl ? "Change" : "Pick from files"}
</Button> </Button>
{badgeImageUrl && ( {badgeImageUrl && (
<Button <Button
@@ -21,10 +21,10 @@ export default function CourseList() {
{/* <div className="flex items-start gap-3 rounded-lg border bg-card p-4 text-sm"> {/* <div className="flex items-start gap-3 rounded-lg border bg-card p-4 text-sm">
<Layers className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" /> <Layers className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Courses are organized under <span className="font-medium text-foreground">Tier Plans</span> — each course's{" "} Courses are organized under <span className="font-medium text-foreground">Subscriptions</span> — each course's{" "}
<span className="font-medium text-foreground">Subscription</span> value determines which plan bundle it belongs to.{" "} <span className="font-medium text-foreground">Subscription</span> value determines which plan bundle it belongs to.{" "}
<Link to="/admin/tiers/plans" className="text-primary hover:underline font-medium"> <Link to="/admin/tiers/plans" className="text-primary hover:underline font-medium">
Manage Tier Plans Manage Subscriptions
</Link> </Link>
{" "}· Units and Lessons now run independently — build them once in the{" "} {" "}· Units and Lessons now run independently — build them once in the{" "}
<Link to="/admin/units" className="text-primary hover:underline font-medium"> <Link to="/admin/units" className="text-primary hover:underline font-medium">
@@ -988,7 +988,7 @@ export default function EditCourse() {
className="h-7 text-xs" className="h-7 text-xs"
> >
<ImagePlus className="h-3 w-3 mr-1" /> <ImagePlus className="h-3 w-3 mr-1" />
{badgeImageUrl ? "Change" : "Pick from assets"} {badgeImageUrl ? "Change" : "Pick from files"}
</Button> </Button>
{badgeImageUrl && ( {badgeImageUrl && (
<Button <Button
@@ -103,7 +103,7 @@ function StepDetails({ register, errors, control, setValue, tierCategories }) {
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })} onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="No tier gate" /> <SelectValue placeholder="No subscription gate" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tierCategories.map((c) => ( {tierCategories.map((c) => (
@@ -156,7 +156,7 @@ export default function EditLesson() {
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })} onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="No tier gate" /> <SelectValue placeholder="No subscription gate" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tierCategories.map((c) => ( {tierCategories.map((c) => (
@@ -201,7 +201,7 @@ export default function AddUnit() {
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })} onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="No tier gate" /> <SelectValue placeholder="No subscription gate" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tierCategories.map((c) => ( {tierCategories.map((c) => (
@@ -128,7 +128,7 @@ export default function EditUnit() {
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })} onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="No tier gate" /> <SelectValue placeholder="No subscription gate" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tierCategories.map((c) => ( {tierCategories.map((c) => (
@@ -87,7 +87,7 @@ function StepLesson({ register, errors, control, setValue, tierCategories }) {
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })} onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="No tier gate" /> <SelectValue placeholder="No subscription gate" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tierCategories.map((c) => ( {tierCategories.map((c) => (
@@ -113,7 +113,7 @@ export default function EditLibraryLesson() {
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })} onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="No tier gate" /> <SelectValue placeholder="No subscription gate" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tierCategories.map((c) => ( {tierCategories.map((c) => (
@@ -1,12 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams, Link } from "react-router-dom"; import { useNavigate, useParams, Link } from "react-router-dom";
import { import {
House, Pencil, LayoutTemplate, Clock, BookCheck, House, Pencil, LayoutTemplate, Clock, BookCheck, Link2, Unlink,
} from "lucide-react"; } from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext"; import { useLibrary } from "@/contexts/AdminLibraryContext";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import AttachLessonToUnitsDialog from "../../../components/library/AttachLessonToUnitsDialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
@@ -16,8 +17,9 @@ import { PreviewChrome, PreviewContent } from "../../../components/courses/Lesso
export default function ViewLibraryLesson() { export default function ViewLibraryLesson() {
const navigate = useNavigate(); const navigate = useNavigate();
const { lessonId } = useParams(); const { lessonId } = useParams();
const { fetchLesson, lesson, loading } = useLibrary(); const { fetchLesson, lesson, loading, attachLessonToUnits, detachLessonFromUnit } = useLibrary();
const [initializing, setInitializing] = useState(true); const [initializing, setInitializing] = useState(true);
const [attachOpen, setAttachOpen] = useState(false);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -36,6 +38,16 @@ export default function ViewLibraryLesson() {
const blocks = lesson?.page?.blocks ?? []; const blocks = lesson?.page?.blocks ?? [];
const objectives = lesson?.objectives ?? []; const objectives = lesson?.objectives ?? [];
const handleAttach = async (unitIds) => {
await attachLessonToUnits(lessonId, unitIds);
fetchLesson(lessonId);
};
const handleDetach = async (unitId) => {
await detachLessonFromUnit(unitId, lessonId);
fetchLesson(lessonId);
};
if (initializing) { if (initializing) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
@@ -64,6 +76,23 @@ export default function ViewLibraryLesson() {
)} )}
</div> </div>
<div className="flex gap-2 shrink-0"> <div className="flex gap-2 shrink-0">
{units.length === 0 && (
<Button size="sm" variant="outline" onClick={() => setAttachOpen(true)}>
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Attach to Unit
</Button>
)}
{units.map((u) => (
<Button
key={u.unit_id}
size="sm"
variant="destructive"
onClick={() => handleDetach(u.unit_id)}
disabled={loading}
title={`Detach from ${u.title} (lesson stays in library)`}
>
<Unlink className="h-3.5 w-3.5 mr-1.5" /> Detach from Unit
</Button>
))}
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/lessons/${lessonId}/edit`)}> <Button size="sm" variant="outline" onClick={() => navigate(`/admin/lessons/${lessonId}/edit`)}>
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit <Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
</Button> </Button>
@@ -99,7 +128,7 @@ export default function ViewLibraryLesson() {
<div className="bg-muted/60 rounded-lg p-3 space-y-1"> <div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Subscription</p> <p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Subscription</p>
<Badge variant={lesson?.subscription ? "outline" : "secondary"} className="capitalize"> <Badge variant={lesson?.subscription ? "outline" : "secondary"} className="capitalize">
{lesson?.subscription ?? "No tier gate"} {lesson?.subscription ?? "No subscription gate"}
</Badge> </Badge>
</div> </div>
</div> </div>
@@ -159,6 +188,14 @@ export default function ViewLibraryLesson() {
</div> </div>
</div> </div>
</div> </div>
<AttachLessonToUnitsDialog
open={attachOpen}
onOpenChange={setAttachOpen}
attachedUnitIds={units.map((u) => u.unit_id)}
onAttach={handleAttach}
loading={loading}
/>
</section> </section>
); );
} }
@@ -107,7 +107,7 @@ function StepUnit({ register, errors, control, setValue, tierCategories }) {
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })} onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="No tier gate" /> <SelectValue placeholder="No subscription gate" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tierCategories.map((c) => ( {tierCategories.map((c) => (
@@ -113,7 +113,7 @@ export default function EditLibraryUnit() {
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })} onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="No tier gate" /> <SelectValue placeholder="No subscription gate" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tierCategories.map((c) => ( {tierCategories.map((c) => (
@@ -166,7 +166,7 @@ export default function ViewLibraryUnit() {
<div className="bg-muted/60 rounded-lg p-3 space-y-1"> <div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Subscription</p> <p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Subscription</p>
<Badge variant={unit?.subscription ? "outline" : "secondary"} className="capitalize"> <Badge variant={unit?.subscription ? "outline" : "secondary"} className="capitalize">
{unit?.subscription ?? "No tier gate"} {unit?.subscription ?? "No subscription gate"}
</Badge> </Badge>
</div> </div>
</div> </div>
+7 -7
View File
@@ -46,7 +46,7 @@ const BUNDLE_TYPES = [
]; ];
const schema = z.object({ const schema = z.object({
tier_category_id: z.string().min(1, "Tier category is required."), tier_category_id: z.string().min(1, "Subscription category is required."),
label: z.string().min(1, "Label is required."), label: z.string().min(1, "Label is required."),
description: z.string().optional(), description: z.string().optional(),
features: z.array(z.object({ text: z.string().min(1, "Feature cannot be empty.") })).default([]), features: z.array(z.object({ text: z.string().min(1, "Feature cannot be empty.") })).default([]),
@@ -335,7 +335,7 @@ export default function AddPlan() {
</Button> </Button>
<div> <div>
<h1 className="text-xl font-semibold">Add Plan</h1> <h1 className="text-xl font-semibold">Add Plan</h1>
<p className="text-sm text-muted-foreground">Create a new paid tier plan.</p> <p className="text-sm text-muted-foreground">Create a new paid subscription plan.</p>
</div> </div>
</div> </div>
@@ -345,17 +345,17 @@ export default function AddPlan() {
{/* ── Step 0: Bundles & Details ── */} {/* ── Step 0: Bundles & Details ── */}
{currentStep === 0 && ( {currentStep === 0 && (
<SectionCard title="Bundles & Details" description="Which tier category this targets, what it unlocks, and how it's presented."> <SectionCard title="Bundles & Details" description="Which subscription category this targets, what it unlocks, and how it's presented.">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Tier Category <span className="text-destructive">*</span></Label> <Label>Subscription Category <span className="text-destructive">*</span></Label>
{catLoading ? ( {catLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2"> <div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Spinner className="h-4 w-4" /> Loading categories… <Spinner className="h-4 w-4" /> Loading categories…
</div> </div>
) : categories.length === 0 ? ( ) : categories.length === 0 ? (
<p className="text-sm text-muted-foreground py-2"> <p className="text-sm text-muted-foreground py-2">
No tier categories found.{" "} No subscription categories found.{" "}
<a href="/admin/tiers/categories/add" className="underline text-primary">Add one first.</a> <a href="/admin/tiers/categories/add" className="underline text-primary">Add one first.</a>
</p> </p>
) : ( ) : (
@@ -364,7 +364,7 @@ export default function AddPlan() {
onValueChange={(v) => setValue("tier_category_id", v, { shouldDirty: true })} onValueChange={(v) => setValue("tier_category_id", v, { shouldDirty: true })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select tier category" /> <SelectValue placeholder="Select subscription category" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{categories.map((c) => ( {categories.map((c) => (
@@ -560,7 +560,7 @@ export default function AddPlan() {
<SectionCard title="Review" description="Confirm everything before creating this plan."> <SectionCard title="Review" description="Confirm everything before creating this plan.">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Tier Category</Label> <Label className="text-xs text-muted-foreground">Subscription Category</Label>
<p className="text-sm"> <p className="text-sm">
{selectedCategory ? `${selectedCategory.name} (${selectedCategory.slug})` : "—"} {selectedCategory ? `${selectedCategory.name} (${selectedCategory.slug})` : "—"}
</p> </p>
@@ -11,7 +11,7 @@ export default function ArchivedPlanList() {
const items = [ const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" }, { label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Tiers", to: "/admin/tiers/plans" }, { label: "Subscriptions", to: "/admin/tiers/plans" },
{ label: "Plans", to: "/admin/tiers/plans" }, { label: "Plans", to: "/admin/tiers/plans" },
{ label: "Archived" }, { label: "Archived" },
]; ];
@@ -121,7 +121,7 @@ function BadgePicker({ currentAsset, selectedAsset, onSelect, onClear }) {
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}> <Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
<ImagePlus className="h-3.5 w-3.5 mr-1.5" /> <ImagePlus className="h-3.5 w-3.5 mr-1.5" />
{display ? "Change image" : "Pick from assets"} {display ? "Change image" : "Pick from files"}
</Button> </Button>
{display && ( {display && (
<Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={onClear}> <Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={onClear}>
@@ -212,14 +212,14 @@ function EditTierCategoryInner({ isAdd }) {
return ( return (
<section className="bg-muted/60 min-h-full"> <section className="bg-muted/60 min-h-full">
<PageMeta title={isAdd ? "Add Tier Category - STARR" : "Edit Tier Category - STARR"} /> <PageMeta title={isAdd ? "Add Subscription Category - STARR" : "Edit Subscription Category - STARR"} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto"> <div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6"> <div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[ <AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" }, { label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Tier Categories", to: "/admin/tiers/categories" }, { label: "Subscription Categories", to: "/admin/tiers/categories" },
{ label: isAdd ? "Add Category" : (category?.name ?? "Edit") }, { label: isAdd ? "Add Category" : (category?.name ?? "Edit") },
]} /> ]} />
</div> </div>
@@ -229,9 +229,9 @@ function EditTierCategoryInner({ isAdd }) {
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div> <div>
<h1 className="text-xl font-semibold">{isAdd ? "Add Tier Category" : "Edit Tier Category"}</h1> <h1 className="text-xl font-semibold">{isAdd ? "Add Subscription Category" : "Edit Subscription Category"}</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{isAdd ? "Define a new tier level for the platform." : "Update this tier category's details and badge."} {isAdd ? "Define a new subscription level for the platform." : "Update this subscription category's details and badge."}
</p> </p>
</div> </div>
</div> </div>
@@ -268,9 +268,9 @@ function EditTierCategoryInner({ isAdd }) {
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="rank">Rank (ordering)</Label> <Label htmlFor="rank">Rank (ordering)</Label>
<Input id="rank" type="number" min={isLocked ? 0 : 1} value={rank} onChange={(e) => setRank(e.target.value)} className="w-32" /> <Input id="rank" type="number" min={isLocked ? 0 : 1} value={rank} onChange={(e) => setRank(e.target.value)} className="w-32" />
<p className="text-xs text-muted-foreground">Must be greater than 0. Free is rank 0. Higher rank = higher access tier.</p> <p className="text-xs text-muted-foreground">Must be greater than 0. Free is rank 0. Higher rank = higher access subscription.</p>
{!isLocked && Number(rank) <= 0 && ( {!isLocked && Number(rank) <= 0 && (
<p className="text-xs text-destructive">Rank must be at least 1 — rank 0 is reserved for the Free (default) tier.</p> <p className="text-xs text-destructive">Rank must be at least 1 — rank 0 is reserved for the Free (default) subscription.</p>
)} )}
</div> </div>
@@ -19,7 +19,7 @@ export default function PaymentList() {
const items = [ const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" }, { label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Tiers", to: "/admin/tiers/plans" }, { label: "Subscriptions", to: "/admin/tiers/plans" },
{ label: "Payments" }, { label: "Payments" },
]; ];
+1 -1
View File
@@ -12,7 +12,7 @@ export default function PlanList() {
const items = [ const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" }, { label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Tiers", to: "/admin/tiers/plans" }, { label: "Subscriptions", to: "/admin/tiers/plans" },
{ label: "Plans" }, { label: "Plans" },
]; ];
@@ -88,7 +88,7 @@ function SystemBadgeCard({ badge: initialBadge }) {
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Button type="button" variant="outline" size="sm" onClick={() => setPickerOpen(true)}> <Button type="button" variant="outline" size="sm" onClick={() => setPickerOpen(true)}>
<ImagePlus className="h-3.5 w-3.5 mr-1.5" /> <ImagePlus className="h-3.5 w-3.5 mr-1.5" />
{displayAsset ? "Change image" : "Pick from assets"} {displayAsset ? "Change image" : "Pick from files"}
</Button> </Button>
{displayAsset && ( {displayAsset && (
<Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={handleClear}> <Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={handleClear}>
@@ -87,21 +87,21 @@ function TierCategoriesInner() {
return ( return (
<section className="bg-muted/60 min-h-full"> <section className="bg-muted/60 min-h-full">
<PageMeta title="Tier Categories - STARR" /> <PageMeta title="Subscription Categories - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto"> <div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6"> <div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[ <AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" }, { label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Tiers", to: "/admin/tiers/plans" }, { label: "Subscriptions", to: "/admin/tiers/plans" },
{ label: "Tier Categories" }, { label: "Subscription Categories" },
]} /> ]} />
</div> </div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h1 className="text-xl font-semibold">Tier Categories</h1> <h1 className="text-xl font-semibold">Subscription Categories</h1>
<p className="text-sm text-muted-foreground mt-0.5"> <p className="text-sm text-muted-foreground mt-0.5">
Define the tier levels available on the platform. Plans are built under each category. Define the tier levels available on the platform. Plans are built under each category.
</p> </p>
@@ -125,7 +125,7 @@ function TierCategoriesInner() {
{loading && !categories.length ? ( {loading && !categories.length ? (
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div> <div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
) : !categories.length ? ( ) : !categories.length ? (
<p className="text-sm text-muted-foreground text-center py-12">No tier categories found.</p> <p className="text-sm text-muted-foreground text-center py-12">No subscription categories found.</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{categories.map((cat) => ( {categories.map((cat) => (
@@ -145,7 +145,7 @@ function TierCategoriesInner() {
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}> <Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<DialogContent className="sm:max-w-sm"> <DialogContent className="sm:max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle>Delete Tier Category</DialogTitle> <DialogTitle>Delete Subscription Category</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to delete{" "} Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{deleteTarget?.name}</span>? <span className="font-semibold text-foreground">{deleteTarget?.name}</span>?
+10 -10
View File
@@ -111,7 +111,7 @@ export default function UserTierList() {
return ( return (
<section className="bg-muted/60 min-h-full"> <section className="bg-muted/60 min-h-full">
<PageMeta title="User Tier Management - STARR" /> <PageMeta title="User Subscription Management - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto"> <div className="w-full max-w-2xl mx-auto">
@@ -120,7 +120,7 @@ export default function UserTierList() {
{ label: "Home", icon: <House className="size-4" />, to: "/admin" }, { label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Users", to: "/admin/users" }, { label: "Users", to: "/admin/users" },
{ label: `User #${userId}`, to: `/admin/users/view/${userId}` }, { label: `User #${userId}`, to: `/admin/users/view/${userId}` },
{ label: "Tiers" }, { label: "Subscriptions" },
]} /> ]} />
</div> </div>
@@ -130,7 +130,7 @@ export default function UserTierList() {
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div> <div>
<h1 className="text-xl font-semibold">User Tiers</h1> <h1 className="text-xl font-semibold">User Subscriptions</h1>
<p className="text-sm text-muted-foreground">Tier history for User #{userId}</p> <p className="text-sm text-muted-foreground">Tier history for User #{userId}</p>
</div> </div>
</div> </div>
@@ -144,7 +144,7 @@ export default function UserTierList() {
{activeTier && ( {activeTier && (
<div className="mb-5 rounded-lg border bg-card p-5 flex items-center justify-between gap-4"> <div className="mb-5 rounded-lg border bg-card p-5 flex items-center justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide">Current Tier</p> <p className="text-xs text-muted-foreground uppercase tracking-wide">Current Subscription</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm px-3 py-0.5">{tierBadge(activeTier.tier)}</span> <span className="text-sm px-3 py-0.5">{tierBadge(activeTier.tier)}</span>
{activeTier.expires_at && ( {activeTier.expires_at && (
@@ -169,9 +169,9 @@ export default function UserTierList() {
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)} {[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div> </div>
) : ( ) : (
<SectionCard icon={BadgeCheck} title="Tier History"> <SectionCard icon={BadgeCheck} title="Subscription History">
{userTiers.length === 0 ? ( {userTiers.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No tier records found.</p> <p className="text-sm text-muted-foreground text-center py-4">No subscription records found.</p>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{userTiers.map((t) => ( {userTiers.map((t) => (
@@ -206,11 +206,11 @@ export default function UserTierList() {
<Dialog open={grantOpen} onOpenChange={setGrantOpen}> <Dialog open={grantOpen} onOpenChange={setGrantOpen}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Grant Tier</DialogTitle> <DialogTitle>Grant Subscription</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Tier</Label> <Label>Subscription</Label>
<Select <Select
value={grantForm.tier} value={grantForm.tier}
onValueChange={(v) => setGrantForm((p) => ({ ...p, tier: v, plan_id: "" }))} onValueChange={(v) => setGrantForm((p) => ({ ...p, tier: v, plan_id: "" }))}
@@ -232,7 +232,7 @@ export default function UserTierList() {
disabled={!filteredPlans.length} disabled={!filteredPlans.length}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={filteredPlans.length ? "Select a plan" : "No active plans for this tier"} /> <SelectValue placeholder={filteredPlans.length ? "Select a plan" : "No active plans for this subscription"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{filteredPlans.map((p) => ( {filteredPlans.map((p) => (
@@ -271,7 +271,7 @@ export default function UserTierList() {
<AlertDialog open={!!revokeTarget} onOpenChange={(o) => !o && setRevokeTarget(null)}> <AlertDialog open={!!revokeTarget} onOpenChange={(o) => !o && setRevokeTarget(null)}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Revoke tier?</AlertDialogTitle> <AlertDialogTitle>Revoke subscription?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
The user's <strong className="capitalize">{revokeTarget?.tier}</strong> tier will be revoked The user's <strong className="capitalize">{revokeTarget?.tier}</strong> tier will be revoked
and they'll be automatically downgraded to Free. and they'll be automatically downgraded to Free.
@@ -204,7 +204,7 @@ export default function ViewPayment() {
<SectionCard icon={BadgeCheck} title="Plan"> <SectionCard icon={BadgeCheck} title="Plan">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<InfoRow label="Label">{payment.plan.label}</InfoRow> <InfoRow label="Label">{payment.plan.label}</InfoRow>
<InfoRow label="Tier"> <InfoRow label="Subscription">
{(() => { const { cls, label } = resolveTierBadge(payment.plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()} {(() => { const { cls, label } = resolveTierBadge(payment.plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
</InfoRow> </InfoRow>
<InfoRow label="Duration">{payment.plan.duration_days} days</InfoRow> <InfoRow label="Duration">{payment.plan.duration_days} days</InfoRow>
+1 -1
View File
@@ -97,7 +97,7 @@ function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoadin
<SectionCard icon={Tag} title="Plan Details"> <SectionCard icon={Tag} title="Plan Details">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<InfoRow label="Label">{plan.label}</InfoRow> <InfoRow label="Label">{plan.label}</InfoRow>
<InfoRow label="Tier"> <InfoRow label="Subscription">
{(() => { {(() => {
const { cls, label } = resolveTierBadge(plan.tier, tierMap); const { cls, label } = resolveTierBadge(plan.tier, tierMap);
return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; return <Badge className={`${cls} mt-0.5`}>{label}</Badge>;
@@ -58,7 +58,7 @@ export default function LockedContentPanel({ course, item, tierMap = {}, checkou
<Zap className="size-4" /> View Available Plans <Zap className="size-4" /> View Available Plans
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p> <p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this subscription.</p>
</div> </div>
</div> </div>
); );
+1 -1
View File
@@ -851,7 +851,7 @@ const UnitList = () => {
<Button onClick={() => navigate('/plans')} className="gap-1.5"> <Button onClick={() => navigate('/plans')} 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 tier.</p> <p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this subscription.</p>
</div> </div>
</div> </div>
); );
+1 -1
View File
@@ -344,7 +344,7 @@ const LockedContent = () => {
<Button onClick={() => navigate('/plans')} className="gap-1.5"> <Button onClick={() => navigate('/plans')} 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 tier.</p> <p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this subscription.</p>
</div> </div>
</div> </div>
); );