assets and tier plans revamp

This commit is contained in:
rgrgogu
2026-08-01 17:44:12 +08:00
parent 4f738a691d
commit a34c6feb84
45 changed files with 2172 additions and 273 deletions
@@ -13,18 +13,11 @@ import { Textarea } from "@/components/ui/textarea";
import { AssetPickerSheet } from "../../AssetPickerSheet";
import api from "@/utils/api.util";
import { MediaFallback } from "@/components/generic/MediaFallback";
import { Spinner } from "@/components/ui/spinner";
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
// ─── Helpers ──────────────────────────────────────────────────────────────────
const fmtTime = (s) => {
if (!s || isNaN(s)) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
};
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
// ─── AudioBlock (Admin) ───────────────────────────────────────────────────────
@@ -45,6 +38,10 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [speedIdx, setSpeedIdx] = useState(2); // 1×
// True from the moment `src` is set until the browser has actually
// buffered enough to play (or stalls mid-playback) — same gap VideoBlock
// closes, so a slow-loading audio file doesn't just sit there silently.
const [mediaLoading, setMediaLoading] = useState(true);
const assetId = content.asset_id ?? null;
const storageProvider = content.storage_provider ?? null;
@@ -79,6 +76,10 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
return () => { cancelled = true; };
}, [assetId, isS3]);
// Reset the loading spinner whenever the src actually changes (new
// asset picked, or the S3 token above just resolved).
useEffect(() => { setMediaLoading(true); }, [src]);
// ── Audio events ──────────────────────────────────────────────────────────
const onTimeUpdate = useCallback(() => setCurrentTime(audioRef.current?.currentTime ?? 0), []);
@@ -90,6 +91,10 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
setBuffered((el.buffered.end(el.buffered.length - 1) / el.duration) * 100);
}
}, []);
const onLoadedData = useCallback(() => setMediaLoading(false), []);
const onCanPlay = useCallback(() => setMediaLoading(false), []);
const onWaiting = useCallback(() => setMediaLoading(true), []);
const onPlaying = useCallback(() => setMediaLoading(false), []);
// ── Controls ──────────────────────────────────────────────────────────────
@@ -186,6 +191,10 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
onLoadedMetadata={onLoadedMeta}
onEnded={onEnded}
onProgress={onProgress}
onLoadedData={onLoadedData}
onCanPlay={onCanPlay}
onWaiting={onWaiting}
onPlaying={onPlaying}
preload="metadata"
/>
@@ -206,13 +215,13 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
)}
<div className="relative z-10 flex items-center gap-4 p-4 text-white">
<div className="shrink-0 w-20 h-20 rounded-md overflow-hidden bg-black/25">
{thumbnail ? (
<div className="shrink-0 w-20 h-20 rounded-md overflow-hidden bg-black/25 flex items-center justify-center">
{mediaLoading ? (
<Spinner className="size-6 text-white" />
) : thumbnail ? (
<img src={thumbnail} alt={title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<Music2 className="w-7 h-7 text-white/30" />
</div>
)}
</div>
<div className="flex flex-col gap-1 flex-1 min-w-0">
@@ -12,15 +12,8 @@ import { Label } from "@/components/ui/label";
import { AssetPickerSheet } from "../../AssetPickerSheet";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { MediaFallback } from "@/components/generic/MediaFallback";
// ─── Helpers ──────────────────────────────────────────────────────────────────
const fmtTime = (s) => {
if (!s || isNaN(s)) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
};
import { Spinner } from "@/components/ui/spinner";
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
// ─── VideoBlock (Admin) ───────────────────────────────────────────────────────
@@ -44,6 +37,13 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
const [muted, setMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [overlayVisible,setOverlayVisible]= useState(true);
// True from the moment `src` is set until the browser has actually
// buffered enough to render a frame (or stalls mid-playback) — closes the
// gap between "token resolved" (the `loading` from useAssetPreviewSrc
// above) and "video is actually watchable", which used to render as a
// blank black box with no indication anything was happening, especially
// on large/slow-loading files.
const [mediaLoading, setMediaLoading] = useState(true);
// Reset player when video changes
useEffect(() => {
@@ -52,6 +52,7 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
setCurrentTime(0);
setTotalDuration(0);
setOverlayVisible(true);
setMediaLoading(true);
}, [src]);
// ── Video event listeners ─────────────────────────────────────────────────
@@ -66,15 +67,27 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
};
const onLoaded = () => setTotalDuration(v.duration);
const onEnded = () => { setPlaying(false); setOverlayVisible(true); };
const onLoadedData = () => setMediaLoading(false);
const onCanPlay = () => setMediaLoading(false);
const onWaiting = () => setMediaLoading(true);
const onPlaying = () => setMediaLoading(false);
v.addEventListener("timeupdate", onTimeUpdate);
v.addEventListener("loadedmetadata", onLoaded);
v.addEventListener("ended", onEnded);
v.addEventListener("loadeddata", onLoadedData);
v.addEventListener("canplay", onCanPlay);
v.addEventListener("waiting", onWaiting);
v.addEventListener("playing", onPlaying);
return () => {
v.removeEventListener("timeupdate", onTimeUpdate);
v.removeEventListener("loadedmetadata", onLoaded);
v.removeEventListener("ended", onEnded);
v.removeEventListener("loadeddata", onLoadedData);
v.removeEventListener("canplay", onCanPlay);
v.removeEventListener("waiting", onWaiting);
v.removeEventListener("playing", onPlaying);
};
}, [src]);
@@ -180,7 +193,16 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
className="w-full h-full object-cover"
/>
{/* Loading spinner — covers the gap between src resolving and the
browser actually having a frame to show */}
{mediaLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 pointer-events-none">
<Spinner className="size-8 text-white" />
</div>
)}
{/* Play/pause overlay */}
{!mediaLoading && (
<div
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${overlayVisible ? "opacity-100" : "opacity-0 pointer-events-none"}`}
style={{ background: "rgba(0,0,0,0.3)" }}
@@ -196,6 +218,7 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
}
</button>
</div>
)}
{/* Change video hover hint */}
{!readOnly && (
@@ -3,15 +3,7 @@ import { Music2, RotateCcw, RotateCw, Play, Pause, VolumeOff, Volume2 } from "lu
import api from "@/utils/api.util";
import { MediaFallback } from "@/components/generic/MediaFallback";
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
// ─── Helpers ──────────────────────────────────────────────────────────────────
const fmtTime = (s) => {
if (!s || isNaN(s)) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
};
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
@@ -10,15 +10,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
import api from "@/utils/api.util";
import { MediaFallback } from "@/components/generic/MediaFallback";
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
// ─── Helpers ──────────────────────────────────────────────────────────────────
const fmtTime = (s) => {
if (!s || isNaN(s)) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
};
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
const PLAYBACK_SPEEDS = ["0.5", "0.75", "Normal", "1.25", "1.5", "2"];
@@ -1,7 +1,11 @@
/***********************************************************************************************************************************************************************
* File Name : FileZoomViewer.jsx
* Type : Component (Client)
* Description : Zoom/pan/fit viewer for image and PDF files.
* Type : Component (Generic)
* Description : Zoom/pan/fit viewer for image and PDF files. Used by both the
* client task-attachment preview (FilePreview.jsx, via blob:
* URLs) and the admin asset preview dialog (AssetPreviewDialog.jsx,
* via direct stream-token URLs) — `src` accepts either, this
* component doesn't care how the URL was produced.
*
* Supported:
* image/jpeg, image/png → <img> with scroll-zoom + drag-pan
@@ -10,13 +14,15 @@
* with page navigation for multi-page PDFs
*
* Not supported (shows a message instead of attempting render):
* DOCX, video, audio, and any other file type
* DOCX, video, audio, and any other file type — video/audio
* assets use the existing admin VideoBlock/AudioBlock players
* instead (see Blocks/Admin/), not this viewer.
*
* Props:
* blobUrl {string} – object URL (from FilePreview's useBlobUrl)
* src {string} – object URL or direct stream URL
* mimeType {string}
* fileName {string}
* loading {boolean} – true while the parent is still fetching the blob
* loading {boolean} – true while the parent is still resolving the src
***********************************************************************************************************************************************************************/
import { useState, useRef, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
@@ -154,7 +160,7 @@ const ZoomPanArea = ({ scale, setScale, offset, setOffset, fitFn, children }) =>
};
// ─── Image viewer ───────────────────────────────────────────────────────────────
const ImageViewer = ({ blobUrl, fileName }) => {
const ImageViewer = ({ src, fileName }) => {
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
@@ -174,9 +180,10 @@ const ImageViewer = ({ blobUrl, fileName }) => {
/>
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit}>
<img
src={blobUrl}
src={src}
alt={fileName}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
className="max-h-[380px] max-w-none select-none pointer-events-none"
/>
</ZoomPanArea>
@@ -185,7 +192,7 @@ const ImageViewer = ({ blobUrl, fileName }) => {
};
// ─── PDF viewer (pdf.js → canvas) ───────────────────────────────────────────────
const PdfViewer = ({ blobUrl, fileName }) => {
const PdfViewer = ({ src, fileName }) => {
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [pdfDoc, setPdfDoc] = useState(null);
@@ -207,7 +214,7 @@ const PdfViewer = ({ blobUrl, fileName }) => {
pdfjsLib.GlobalWorkerOptions.workerSrc =
new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
const doc = await pdfjsLib.getDocument(blobUrl).promise;
const doc = await pdfjsLib.getDocument(src).promise;
if (cancelled) return;
setPdfDoc(doc);
setNumPages(doc.numPages);
@@ -217,7 +224,7 @@ const PdfViewer = ({ blobUrl, fileName }) => {
})();
return () => { cancelled = true; };
}, [blobUrl]);
}, [src]);
// ── Render current page to canvas ──────────────────────────────────────────
useEffect(() => {
@@ -274,7 +281,7 @@ const PdfViewer = ({ blobUrl, fileName }) => {
};
// ─── Main viewer ────────────────────────────────────────────────────────────────
const FileZoomViewer = ({ blobUrl, mimeType, fileName, loading }) => {
const FileZoomViewer = ({ src, mimeType, fileName, loading }) => {
const mode = resolveMode(mimeType, fileName);
if (loading) {
@@ -285,12 +292,12 @@ const FileZoomViewer = ({ blobUrl, mimeType, fileName, loading }) => {
);
}
if (!blobUrl || mode === 'unsupported') {
if (!src || mode === 'unsupported') {
return <UnsupportedMessage fileName={fileName} />;
}
if (mode === 'image') return <ImageViewer blobUrl={blobUrl} fileName={fileName} />;
if (mode === 'pdf') return <PdfViewer blobUrl={blobUrl} fileName={fileName} />;
if (mode === 'image') return <ImageViewer src={src} fileName={fileName} />;
if (mode === 'pdf') return <PdfViewer src={src} fileName={fileName} />;
return <UnsupportedMessage fileName={fileName} />;
};
@@ -0,0 +1,22 @@
// components/generic/TranscodeStatusBanner.jsx
//
// Small inline notice for a video asset's background remux (see backend
// services/assetTranscode.service.js) — .mov/.mkv uploads get repackaged
// into a faststart .mp4 for fast in-browser playback. Non-blocking: the
// asset still plays from its original (slower) file while this is pending/
// processing, this banner is just a heads-up. Renders nothing once the
// asset is "done"/"none" (fast already) — "failed" also renders nothing,
// the asset just quietly keeps playing from the original.
import { Loader2 } from "lucide-react";
export function TranscodeStatusBanner({ status }) {
if (status !== "pending" && status !== "processing") return null;
return (
<div className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground bg-muted/40 border-b">
<Loader2 className="size-3.5 animate-spin" />
Optimizing this video for faster playback — still watchable now, will load quicker shortly.
</div>
);
}
+50
View File
@@ -416,6 +416,54 @@ export function LibraryProvider({ children }) {
[request],
);
// ─── Product listings (individual purchase, parity with course products) ──
const fetchUnitProduct = useCallback(
(unitId) => request(async () => {
const { data } = await api.get(`/admin/products/units/${unitId}/product`);
return data.data ?? null;
}), [request],
);
const saveUnitProduct = useCallback(
(unitId, payload) => request(async () => {
const { data } = await api.put(`/admin/products/units/${unitId}/product`, payload);
toast("Product listing saved.");
return data.data ?? null;
}), [request],
);
const removeUnitProduct = useCallback(
(unitId) => request(async () => {
await api.delete(`/admin/products/units/${unitId}/product`);
toast("Product listing removed.");
return true;
}), [request],
);
const fetchLessonProduct = useCallback(
(lessonId) => request(async () => {
const { data } = await api.get(`/admin/products/lessons/${lessonId}/product`);
return data.data ?? null;
}), [request],
);
const saveLessonProduct = useCallback(
(lessonId, payload) => request(async () => {
const { data } = await api.put(`/admin/products/lessons/${lessonId}/product`, payload);
toast("Product listing saved.");
return data.data ?? null;
}), [request],
);
const removeLessonProduct = useCallback(
(lessonId) => request(async () => {
await api.delete(`/admin/products/lessons/${lessonId}/product`);
toast("Product listing removed.");
return true;
}), [request],
);
// ─── Value ────────────────────────────────────────────────────────────────
const value = {
// shared table state
@@ -430,6 +478,7 @@ export function LibraryProvider({ children }) {
fetchUnitArchiveImpact, fetchUnitPermanentDeleteImpact,
fetchUnitFieldValues,
attachLessonsToUnit, detachLessonFromUnit, reorderUnitLessons,
fetchUnitProduct, saveUnitProduct, removeUnitProduct,
// lesson library
lessons, lesson, lessonsFlat,
@@ -439,6 +488,7 @@ export function LibraryProvider({ children }) {
permanentlyDeleteLesson, permanentlyDeleteLessons,
fetchLessonPermanentDeleteImpact,
fetchLessonFieldValues,
fetchLessonProduct, saveLessonProduct, removeLessonProduct,
};
return <LibraryContext.Provider value={value}>{children}</LibraryContext.Provider>;
+2 -30
View File
@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useState } from "react";
import api from "@/utils/api.util";
import { toast } from "sonner";
import { usePurchases } from "@/hooks/usePurchases";
// ─── Context ──────────────────────────────────────────────────────────────────
@@ -176,7 +177,7 @@ export function ClientCoursesProvider({ children }) {
const [purchases, setPurchases] = useState([]);
const [purchasesLoading, setPurchasesLoading] = useState(false);
const [purchaseLoading, setPurchaseLoading] = useState(false);
const { purchaseLoading, createOrder: createCourseOrder, captureOrder: captureCourseOrder, cancelOrder: cancelCourseOrder } = usePurchases();
const getMyPurchases = useCallback(async () => {
setPurchasesLoading(true);
@@ -188,35 +189,6 @@ export function ClientCoursesProvider({ children }) {
} finally { setPurchasesLoading(false); }
}, []);
const createCourseOrder = useCallback(async (productId) => {
setPurchaseLoading(true);
try {
const { data } = await api.post("/client/course-purchases/order", { product_id: productId });
return data.data ?? null;
} catch (err) {
toast(err?.response?.data?.message ?? "Could not create order.");
return null;
} finally { setPurchaseLoading(false); }
}, []);
const captureCourseOrder = useCallback(async (orderId) => {
setPurchaseLoading(true);
try {
const { data } = await api.post("/client/course-purchases/capture", { order_id: orderId });
toast("Purchase confirmed! You now have access to this course.");
return data.data ?? null;
} catch (err) {
toast(err?.response?.data?.message ?? "Could not capture payment.");
return null;
} finally { setPurchaseLoading(false); }
}, []);
const cancelCourseOrder = useCallback(async (orderId) => {
try {
await api.post("/client/course-purchases/cancel", { order_id: orderId });
} catch { /* silent */ }
}, []);
// ─── Reset helpers ──────────────────────────────────────────────────────────
const resetCourse = useCallback(() => { setCourse(null); setCourseBlocked(false); }, []);
+31
View File
@@ -36,6 +36,12 @@ export function ClientLibraryProvider({ children }) {
const [quiz, setQuiz] = useState(null);
const [quizLoading, setQuizLoading] = useState(false);
// ── Checkout info (unit/lesson individual-purchase page) — deliberately not
// gated by canAccessUnit/canAccessLesson like unitDetail/lesson above, since
// this is exactly what a locked-and-unpurchased learner needs to see.
const [checkoutInfo, setCheckoutInfo] = useState(null);
const [checkoutInfoLoading, setCheckoutInfoLoading] = useState(false);
// ─── Actions ────────────────────────────────────────────────────────────
const getUnits = useCallback(async () => {
@@ -222,6 +228,26 @@ export function ClientLibraryProvider({ children }) {
}
}, []);
const getUnitCheckoutInfo = useCallback(async (uuid) => {
setCheckoutInfoLoading(true);
try {
const { data } = await api.get(`/client/units/${uuid}/checkout-info`);
setCheckoutInfo(data.data ?? null);
} catch (err) {
toast(err?.response?.data?.message ?? "Could not load unit.");
} finally { setCheckoutInfoLoading(false); }
}, []);
const getLessonCheckoutInfo = useCallback(async (uuid) => {
setCheckoutInfoLoading(true);
try {
const { data } = await api.get(`/client/lessons/${uuid}/checkout-info`);
setCheckoutInfo(data.data ?? null);
} catch (err) {
toast(err?.response?.data?.message ?? "Could not load lesson.");
} finally { setCheckoutInfoLoading(false); }
}, []);
// ─── Resets ─────────────────────────────────────────────────────────────
const resetUnitDetail = useCallback(() => {
@@ -231,6 +257,7 @@ export function ClientLibraryProvider({ children }) {
}, []);
const resetLesson = useCallback(() => setLesson(null), []);
const resetQuiz = useCallback(() => setQuiz(null), []);
const resetCheckoutInfo = useCallback(() => setCheckoutInfo(null), []);
// ─── Value ──────────────────────────────────────────────────────────────
@@ -240,6 +267,7 @@ export function ClientLibraryProvider({ children }) {
unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo,
lesson, lessonLoading,
quiz, quizLoading,
checkoutInfo, checkoutInfoLoading,
getUnits,
getLessons,
@@ -251,10 +279,13 @@ export function ClientLibraryProvider({ children }) {
upsertLessonProgress,
upsertWatchProgress,
markComplete,
getUnitCheckoutInfo,
getLessonCheckoutInfo,
resetUnitDetail,
resetLesson,
resetQuiz,
resetCheckoutInfo,
};
return (
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { usePurchases } from "./usePurchases";
/**
* Shared PayPal order/capture/cancel flow for a standalone checkout page
* (Unit or Lesson) — mirrors CourseCheckout.jsx's logic exactly, including
* the cancel-vs-capture race-condition guard: a real PayPal cancel redirect
* carries BOTH `cancelled=true` (our own cancelUrl) and `token=<order_id>`
* (PayPal always appends its own token to whatever return/cancel URL it's
* given) — without the `wasCancelled` check below, both the capture effect
* and the cancel effect would fire on the same load, racing a capture call
* against the cancel and surfacing a confusing "not found" error instead of
* a clean cancellation message.
*
* @param {string} targetId - uuid used to reload the page's own detail state
* @param {(id: string) => void} loadTarget - memoized loader, called on mount and again after a successful capture
* @param {string} checkoutPath - this page's own path, used to strip query params after a cancel
*/
export function usePurchaseCheckout({ targetId, loadTarget, checkoutPath }) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { purchaseLoading, createOrder, captureOrder, cancelOrder } = usePurchases();
const returnToken = searchParams.get("token");
const wasCancelled = searchParams.get("cancelled") === "true";
const [capturing, setCapturing] = useState(false);
const capturingRef = useRef(false);
useEffect(() => {
loadTarget(targetId);
}, [targetId]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!returnToken || wasCancelled || capturingRef.current) return;
capturingRef.current = true;
setCapturing(true);
captureOrder(returnToken).then((result) => {
if (result) {
loadTarget(targetId);
navigate(checkoutPath, { replace: true });
} else {
setCapturing(false);
capturingRef.current = false;
}
});
}, [returnToken]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!wasCancelled) return;
const orderId = searchParams.get("token");
if (orderId) cancelOrder(orderId);
// Deferred to a new macrotask, same reasoning as CourseCheckout.jsx: this
// page renders before <Toaster /> in the layout, so a toast fired
// synchronously here races the Toaster's own mount effect and is
// silently dropped.
setTimeout(() => toast("Payment was cancelled."), 0);
navigate(checkoutPath, { replace: true });
}, [wasCancelled]); // eslint-disable-line react-hooks/exhaustive-deps
const buyNow = async (productId) => {
const order = await createOrder(productId);
if (!order) return;
if (!order.approval_url) { toast("Could not get PayPal approval URL."); return; }
window.location.href = order.approval_url;
};
return { capturing, purchaseLoading, buyNow };
}
+43
View File
@@ -0,0 +1,43 @@
import { useCallback, useState } from "react";
import api from "@/utils/api.util";
import { toast } from "sonner";
// Generic individual-purchase API (PayPal order/capture/cancel) — a Product's
// purchasable_type/purchasable_id already carries its target (course, unit,
// or lesson) server-side, so this hook doesn't need to know which either.
// Endpoint paths are historically "course-purchases" (predates Units/Lessons
// being individually purchasable) but the rows and this API are generic.
export function usePurchases() {
const [purchaseLoading, setPurchaseLoading] = useState(false);
const createOrder = useCallback(async (productId) => {
setPurchaseLoading(true);
try {
const { data } = await api.post("/client/course-purchases/order", { product_id: productId });
return data.data ?? null;
} catch (err) {
toast(err?.response?.data?.message ?? "Could not create order.");
return null;
} finally { setPurchaseLoading(false); }
}, []);
const captureOrder = useCallback(async (orderId) => {
setPurchaseLoading(true);
try {
const { data } = await api.post("/client/course-purchases/capture", { order_id: orderId });
toast("Purchase confirmed! You now have access.");
return data.data ?? null;
} catch (err) {
toast(err?.response?.data?.message ?? "Could not capture payment.");
return null;
} finally { setPurchaseLoading(false); }
}, []);
const cancelOrder = useCallback(async (orderId) => {
try {
await api.post("/client/course-purchases/cancel", { order_id: orderId });
} catch { /* silent */ }
}, []);
return { purchaseLoading, createOrder, captureOrder, cancelOrder };
}
@@ -0,0 +1,114 @@
// modules/admin/components/assets/AssetPreviewDialog.jsx
//
// Quick-look dialog for the Assets table's "Preview" row action. Images and
// PDFs render through the shared FileZoomViewer (zoom/pan). Video and audio
// reuse the existing admin VideoBlock/AudioBlock players (Blocks/Admin/) —
// same rich, custom-controls-only UI already used on ViewVideoAsset/
// ViewAudioAsset and in the lesson block editors — in readOnly mode. No
// Download button here or anywhere else an asset can be previewed —
// protecting assets means the raw file is never handed to the browser as a
// downloadable blob, only streamed inline via short-lived token.
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Image, Video, Music, FileText, File } from "lucide-react";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import FileZoomViewer from "@/components/generic/FileZoomViewer";
import { VideoBlock } from "@/components/generic/Blocks/Admin/VideoBlock";
import { AudioBlock } from "@/components/generic/Blocks/Admin/AudioBlock";
import { TranscodeStatusBanner } from "@/components/generic/TranscodeStatusBanner";
import { formatFileSize } from "@/utils/format.util";
const KIND_ICON = { image: Image, video: Video, audio: Music, document: FileText };
const NOOP = () => {};
function PreviewBody({ asset }) {
const isZoomable = asset.file_type === "image" || asset.file_type === "document";
const { src, loading } = useAssetPreviewSrc(isZoomable ? asset : null, { scope: "admin" });
if (asset.file_type === "video") {
return (
<div>
<TranscodeStatusBanner status={asset.transcode_status} />
<div className="p-4">
<VideoBlock
readOnly
onUpdate={NOOP}
content={{
asset_id: asset.asset_id,
storage_provider: asset.storage_provider,
url: asset.file_url,
thumbnail_url: asset.thumbnail_url,
title: asset.display_name ?? asset.original_name,
tag: asset.extension?.toUpperCase() ?? "",
}}
/>
</div>
</div>
);
}
if (asset.file_type === "audio") {
return (
<div className="p-4 flex justify-center">
<AudioBlock
readOnly
onUpdate={NOOP}
content={{
asset_id: asset.asset_id,
storage_provider: asset.storage_provider,
url: asset.file_url,
thumbnail: asset.thumbnail_url,
title: asset.display_name ?? asset.original_name,
tag: asset.extension?.toUpperCase() ?? "",
}}
/>
</div>
);
}
return (
<FileZoomViewer
src={src}
mimeType={asset.mime_type}
fileName={asset.display_name ?? asset.original_name}
loading={loading}
/>
);
}
export function AssetPreviewDialog({ asset, open, onOpenChange }) {
if (!asset) return null;
const Icon = KIND_ICON[asset.file_type] ?? File;
const fileName = asset.display_name ?? asset.original_name;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg lg:max-w-4xl p-0 gap-0 overflow-hidden">
<DialogHeader className="px-4 py-3 pr-12 border-b flex-row items-center gap-2 space-y-0">
<Icon className="size-4.5 text-muted-foreground shrink-0" />
<DialogTitle className="text-sm font-medium truncate">{fileName}</DialogTitle>
</DialogHeader>
<PreviewBody asset={asset} />
<div className="px-4 py-2.5 border-t flex items-center gap-2 text-xs text-muted-foreground">
{asset.mime_type && (
<Badge variant="secondary" className="text-xs font-normal select-none">
{asset.mime_type}
</Badge>
)}
{formatFileSize(asset.file_size) && (
<Badge variant="secondary" className="text-xs font-normal select-none">
{formatFileSize(asset.file_size)}
</Badge>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -9,6 +9,7 @@ import { useAuth } from "@/contexts/AuthContext";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
import { AssetPreviewDialog } from "./AssetPreviewDialog";
import { buildDataColumns, columnPinning } from "../../config/assets/columns.config";
import { buildToolbarActions } from "../../config/assets/toolbar.config";
@@ -21,6 +22,7 @@ import { formatGeneratedBy } from "@/utils/generatedBy.util";
export default function AssetsTable() {
const [archiveTarget, setArchiveTarget] = useState(null);
const [archiveIds, setArchiveIds] = useState(null);
const [previewTarget, setPreviewTarget] = useState(null);
const tableRefsRef = useRef({
getFilters: () => [],
@@ -62,6 +64,7 @@ export default function AssetsTable() {
const rowActions = buildRowActions({
onView: (row) => navigate(resolveViewPath(row)),
onPreview: (row) => setPreviewTarget(row),
onEdit: (row) => navigate(`edit/${row.asset_id}`),
onArchive: (row) => setArchiveTarget(row),
});
@@ -144,6 +147,13 @@ export default function AssetsTable() {
loading={loading}
onSuccess={handleArchiveSuccess}
/>
{/* ── Quick-look preview ── */}
<AssetPreviewDialog
asset={previewTarget}
open={!!previewTarget}
onOpenChange={(v) => !v && setPreviewTarget(null)}
/>
</>
);
}
@@ -0,0 +1,173 @@
import { useEffect, useState } from "react";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
const EMPTY_FORM = { name: "", price: "", currency: "USD", access_days: "", is_active: true };
/**
* Self-contained product-listing editor for individual-purchase pricing —
* fetches on mount, saves via its own button (same convention as
* CompletionRequirementBuilder: fetchFn/saveFn/removeFn + args resolve the
* target server-side, so this one component covers Course/Unit/Lesson).
*/
export default function ProductPricingCard({ label = "this content", fetchFn, saveFn, removeFn, args = [] }) {
const [product, setProduct] = useState(null);
const [form, setForm] = useState(EMPTY_FORM);
const [dirty, setDirty] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
let active = true;
fetchFn(...args).then((prod) => {
if (!active) return;
if (prod) {
setProduct(prod);
setForm({
name: prod.name ?? "",
price: prod.price ?? "",
currency: prod.currency ?? "USD",
access_days: prod.access_days ?? "",
is_active: prod.is_active ?? true,
});
}
setLoading(false);
setDirty(false);
});
return () => { active = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...args]);
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }));
setDirty(true);
};
const handleSave = async () => {
if (!form.price) return;
setSaving(true);
const saved = await saveFn(...args, {
name: form.name || null,
price: Number(form.price),
currency: form.currency || "USD",
access_days: form.access_days ? Number(form.access_days) : null,
is_active: form.is_active,
});
if (saved) { setProduct(saved); setDirty(false); }
setSaving(false);
};
const handleRemove = async () => {
setSaving(true);
await removeFn(...args);
setProduct(null);
setForm(EMPTY_FORM);
setDirty(false);
setSaving(false);
};
if (loading) {
return (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Spinner className="size-5" />
</div>
);
}
return (
<div className="space-y-5">
<p className="text-xs text-muted-foreground">
Allow learners to purchase {label} individually via PayPal, as an alternative to a tier plan.
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5 col-span-2">
<Label htmlFor="prod_name">Listing Name</Label>
<Input
id="prod_name"
placeholder="e.g. Real Estate Fundamentals"
value={form.name}
onChange={(e) => handleChange("name", e.target.value)}
/>
<p className="text-xs text-muted-foreground">Defaults to the title if left blank.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="prod_price">Price <span className="text-destructive">*</span></Label>
<Input
id="prod_price"
type="number"
step="0.01"
min={0}
placeholder="0.00"
value={form.price}
onChange={(e) => handleChange("price", e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="prod_currency">Currency</Label>
<Input
id="prod_currency"
maxLength={3}
placeholder="USD"
value={form.currency}
onChange={(e) => handleChange("currency", e.target.value.toUpperCase())}
/>
</div>
<div className="space-y-1.5 col-span-2">
<Label htmlFor="prod_access">Access Duration (days)</Label>
<Input
id="prod_access"
type="number"
min={1}
placeholder="Leave blank for lifetime access"
value={form.access_days}
onChange={(e) => handleChange("access_days", e.target.value)}
/>
</div>
</div>
<div className="flex items-center justify-between pt-1">
<div>
<Label>Listed for Purchase</Label>
<p className="text-xs text-muted-foreground">Show a "Buy" button to learners.</p>
</div>
<Switch
checked={form.is_active}
onCheckedChange={(v) => handleChange("is_active", v)}
/>
</div>
<div className="flex items-center justify-between pt-2 border-t">
{product && (
<Button
type="button"
size="sm"
variant="outline"
className="text-destructive border-destructive/50 hover:bg-destructive/5"
disabled={saving}
onClick={handleRemove}
>
{saving && <Spinner className="h-3 w-3 mr-1.5" />}
Remove Listing
</Button>
)}
<Button
type="button"
size="sm"
className="ml-auto"
disabled={!dirty || saving || !form.price}
onClick={handleSave}
>
{saving && <Spinner className="h-3 w-3 mr-1.5" />}
<Save className="h-3 w-3 mr-1.5" />
{product ? "Update Listing" : "Create Listing"}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,294 @@
import { useState, useEffect, useMemo } from "react";
import { ChevronsUpDown, Check, FileText, AlertTriangle } from "lucide-react";
import api from "@/utils/api.util";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandInput, CommandEmpty, CommandList, CommandItem } from "@/components/ui/command";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
/**
* LessonPicker — mirrors CoursePicker.jsx exactly, for bundling standalone
* Lessons (their own `subscription` field) into a tier plan.
*
* Props: same contract as CoursePicker (subscription, selectedIds, onChange,
* isPreloaded, currentPlanId, onConflictsChange) — see CoursePicker.jsx for
* the full doc comment, not repeated here.
*/
export function LessonPicker({ subscription, selectedIds, onChange, isPreloaded = false, currentPlanId, onConflictsChange }) {
const [lessons, setLessons] = useState([]);
const [loading, setLoading] = useState(false);
const [bundleAll, setBundleAll] = useState(true);
const [popoverOpen, setPopoverOpen] = useState(false);
const [search, setSearch] = useState("");
useEffect(() => {
if (!subscription) { setLessons([]); setBundleAll(true); return; }
setLoading(true);
setSearch("");
setBundleAll(true); // reset question to "Yes" whenever subscription changes
api.get(`/admin/lessons/by-subscription?slug=${encodeURIComponent(subscription)}`)
.then(({ data }) => {
const loaded = data.data ?? [];
setLessons(loaded);
if (!isPreloaded) {
// AddPlan: bundle all by default
setBundleAll(true);
onChange(new Set(loaded.map((l) => String(l.lesson_id))));
} else {
// EditPlan: LessonPicker mounts only after assignments loaded into selectedIds.
// Detect initial mode from current selectedIds vs total lessons.
const size = selectedIds.size;
if (size > 0 && size < loaded.length) {
// Partial selection saved previously → specific mode
setBundleAll(false);
} else {
// All selected, or none (no lessons assigned yet) → bundle all
setBundleAll(true);
onChange(new Set(loaded.map((l) => String(l.lesson_id))));
}
}
})
.catch(() => setLessons([]))
.finally(() => setLoading(false));
}, [subscription]); // eslint-disable-line react-hooks/exhaustive-deps
const filtered = useMemo(() => {
const q = search.toLowerCase();
if (!q) return lessons;
return lessons.filter(
(l) =>
l.title?.toLowerCase().includes(q) ||
l.description?.toLowerCase().includes(q)
);
}, [lessons, search]);
// Lessons already owned by a DIFFERENT plan — selecting them here will move them.
const isConflict = (lesson) =>
lesson.assigned_plan && String(lesson.assigned_plan.plan_id) !== String(currentPlanId ?? "");
// Only lessons actually SELECTED matter — unchecking a conflicting lesson clears it.
const conflicts = useMemo(
() => lessons.filter((l) => isConflict(l) && selectedIds.has(String(l.lesson_id))),
[lessons, currentPlanId, selectedIds] // eslint-disable-line react-hooks/exhaustive-deps
);
const conflictsByPlan = useMemo(() => {
const map = new Map();
conflicts.forEach((l) => {
const label = l.assigned_plan.label;
map.set(label, (map.get(label) ?? 0) + 1);
});
return [...map.entries()];
}, [conflicts]);
useEffect(() => {
onConflictsChange?.(conflicts.length);
}, [conflicts.length]); // eslint-disable-line react-hooks/exhaustive-deps
const toggle = (id) => {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
onChange(next);
};
const checkAll = () => onChange(new Set(lessons.map((l) => String(l.lesson_id))));
const resetAll = () => onChange(new Set());
// "Yes, include all" clicked
const handleBundleAll = () => {
setBundleAll(true);
setPopoverOpen(false);
onChange(new Set(lessons.map((l) => String(l.lesson_id))));
};
// "No, choose specific" clicked — keeps current selection (all) so admin can uncheck specific ones
const handleSelectSpecific = () => {
setBundleAll(false);
};
const total = lessons.length;
const selectedCount = selectedIds.size;
if (!subscription) return null;
return (
<div className="space-y-4">
{/* ── Bundle question ──────────────────────────────────────────── */}
{loading ? (
<div className="flex gap-2">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-8 w-40" />
</div>
) : (
<div className="space-y-2.5">
<p className="text-sm">
Bundle <span className="font-semibold capitalize">{subscription}</span> lessons with this plan?
</p>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={bundleAll ? "default" : "outline"}
onClick={handleBundleAll}
disabled={total === 0}
>
<Check className="size-3.5 mr-1.5" />
Yes, include all
</Button>
<Button
type="button"
size="sm"
variant={!bundleAll ? "default" : "outline"}
onClick={handleSelectSpecific}
disabled={total === 0}
>
No, choose specific
</Button>
</div>
</div>
)}
{/* ── Bundle all summary ───────────────────────────────────────── */}
{!loading && bundleAll && total > 0 && (
<p className="text-xs text-muted-foreground">
All {total} <span className="capitalize">{subscription}</span> lesson{total !== 1 ? "s" : ""} will be included.
</p>
)}
{/* ── Already-assigned-elsewhere warning ──────────────────────── */}
{!loading && conflicts.length > 0 && (
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-800 p-3 text-xs text-amber-800 dark:text-amber-300">
<AlertTriangle className="size-4 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="font-medium">
{conflicts.length} lesson{conflicts.length !== 1 ? "s" : ""} already belong{conflicts.length === 1 ? "s" : ""} to another plan.
</p>
<p>
A lesson can only belong to one plan at a time. Assigning {conflicts.length === 1 ? "it" : "them"} here will move {conflicts.length === 1 ? "it" : "them"} out of{" "}
{conflictsByPlan.map(([label, count], i) => (
<span key={label}>
<span className="font-medium">{label}</span> ({count}){i < conflictsByPlan.length - 1 ? ", " : ""}
</span>
))}. Uncheck them below if that's not what you want.
</p>
</div>
</div>
)}
{/* ── No lessons in tier ───────────────────────────────────────── */}
{!loading && total === 0 && (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<FileText className="size-4 shrink-0" />
No <span className="capitalize mx-1 font-medium">{subscription}</span> lessons found. Add lessons with this subscription first.
</div>
)}
{/* ── Specific picker (Popover) ─────────────────────────────────── */}
{!loading && !bundleAll && total > 0 && (
<div className="space-y-1.5">
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={cn(
"w-full justify-between gap-2",
selectedCount === 0 && "border-destructive text-destructive hover:border-destructive"
)}
>
{selectedCount === 0
? "No lessons selected"
: `${selectedCount} of ${total} lesson${total !== 1 ? "s" : ""} selected`
}
<ChevronsUpDown className="size-4 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search lessons…"
value={search}
onValueChange={setSearch}
/>
<CommandList>
{filtered.length === 0 ? (
<CommandEmpty>No lessons match your search.</CommandEmpty>
) : (
<ScrollArea className="h-64">
{filtered.map((lesson) => {
const id = String(lesson.lesson_id);
const checked = selectedIds.has(id);
const conflict = isConflict(lesson);
return (
<CommandItem
key={id}
value={id}
onSelect={() => toggle(id)}
className="flex items-start gap-3 px-3 py-2.5 cursor-pointer"
>
<Checkbox
checked={checked}
onCheckedChange={() => toggle(id)}
className="mt-0.5 shrink-0"
onClick={(e) => e.stopPropagation()}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium leading-snug">{lesson.title}</span>
{lesson.description && (
<span className="text-xs text-muted-foreground line-clamp-1">
{lesson.description}
</span>
)}
{conflict && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 rounded px-1.5 py-0.5 w-fit mt-0.5">
<AlertTriangle className="size-3" />
In "{lesson.assigned_plan.label}"
</span>
)}
</div>
</CommandItem>
);
})}
</ScrollArea>
)}
</CommandList>
{/* Popover footer */}
<div className="border-t px-3 py-2 flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{selectedCount} of {total} selected
</span>
<div className="flex items-center gap-2">
<Checkbox
checked={selectedCount === total ? true : selectedCount > 0 ? "indeterminate" : false}
onCheckedChange={(v) => v ? checkAll() : resetAll()}
/>
<span className="text-xs text-muted-foreground select-none">
{selectedCount === total ? "Deselect all" : "Select all"}
</span>
</div>
</div>
</Command>
</PopoverContent>
</Popover>
{selectedCount === 0 && (
<p className="flex items-center gap-1.5 text-xs text-destructive">
<AlertTriangle className="size-3.5 shrink-0" />
Select at least one lesson to bundle with this plan.
</p>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,294 @@
import { useState, useEffect, useMemo } from "react";
import { ChevronsUpDown, Check, BookOpen, AlertTriangle } from "lucide-react";
import api from "@/utils/api.util";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandInput, CommandEmpty, CommandList, CommandItem } from "@/components/ui/command";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
/**
* UnitPicker — mirrors CoursePicker.jsx exactly, for bundling standalone
* Units (their own `subscription` field) into a tier plan.
*
* Props: same contract as CoursePicker (subscription, selectedIds, onChange,
* isPreloaded, currentPlanId, onConflictsChange) — see CoursePicker.jsx for
* the full doc comment, not repeated here.
*/
export function UnitPicker({ subscription, selectedIds, onChange, isPreloaded = false, currentPlanId, onConflictsChange }) {
const [units, setUnits] = useState([]);
const [loading, setLoading] = useState(false);
const [bundleAll, setBundleAll] = useState(true);
const [popoverOpen, setPopoverOpen] = useState(false);
const [search, setSearch] = useState("");
useEffect(() => {
if (!subscription) { setUnits([]); setBundleAll(true); return; }
setLoading(true);
setSearch("");
setBundleAll(true); // reset question to "Yes" whenever subscription changes
api.get(`/admin/units/by-subscription?slug=${encodeURIComponent(subscription)}`)
.then(({ data }) => {
const loaded = data.data ?? [];
setUnits(loaded);
if (!isPreloaded) {
// AddPlan: bundle all by default
setBundleAll(true);
onChange(new Set(loaded.map((u) => String(u.unit_id))));
} else {
// EditPlan: UnitPicker mounts only after assignments loaded into selectedIds.
// Detect initial mode from current selectedIds vs total units.
const size = selectedIds.size;
if (size > 0 && size < loaded.length) {
// Partial selection saved previously → specific mode
setBundleAll(false);
} else {
// All selected, or none (no units assigned yet) → bundle all
setBundleAll(true);
onChange(new Set(loaded.map((u) => String(u.unit_id))));
}
}
})
.catch(() => setUnits([]))
.finally(() => setLoading(false));
}, [subscription]); // eslint-disable-line react-hooks/exhaustive-deps
const filtered = useMemo(() => {
const q = search.toLowerCase();
if (!q) return units;
return units.filter(
(u) =>
u.title?.toLowerCase().includes(q) ||
u.description?.toLowerCase().includes(q)
);
}, [units, search]);
// Units already owned by a DIFFERENT plan — selecting them here will move them.
const isConflict = (unit) =>
unit.assigned_plan && String(unit.assigned_plan.plan_id) !== String(currentPlanId ?? "");
// Only units actually SELECTED matter — unchecking a conflicting unit clears it.
const conflicts = useMemo(
() => units.filter((u) => isConflict(u) && selectedIds.has(String(u.unit_id))),
[units, currentPlanId, selectedIds] // eslint-disable-line react-hooks/exhaustive-deps
);
const conflictsByPlan = useMemo(() => {
const map = new Map();
conflicts.forEach((u) => {
const label = u.assigned_plan.label;
map.set(label, (map.get(label) ?? 0) + 1);
});
return [...map.entries()];
}, [conflicts]);
useEffect(() => {
onConflictsChange?.(conflicts.length);
}, [conflicts.length]); // eslint-disable-line react-hooks/exhaustive-deps
const toggle = (id) => {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
onChange(next);
};
const checkAll = () => onChange(new Set(units.map((u) => String(u.unit_id))));
const resetAll = () => onChange(new Set());
// "Yes, include all" clicked
const handleBundleAll = () => {
setBundleAll(true);
setPopoverOpen(false);
onChange(new Set(units.map((u) => String(u.unit_id))));
};
// "No, choose specific" clicked — keeps current selection (all) so admin can uncheck specific ones
const handleSelectSpecific = () => {
setBundleAll(false);
};
const total = units.length;
const selectedCount = selectedIds.size;
if (!subscription) return null;
return (
<div className="space-y-4">
{/* ── Bundle question ──────────────────────────────────────────── */}
{loading ? (
<div className="flex gap-2">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-8 w-40" />
</div>
) : (
<div className="space-y-2.5">
<p className="text-sm">
Bundle <span className="font-semibold capitalize">{subscription}</span> units with this plan?
</p>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={bundleAll ? "default" : "outline"}
onClick={handleBundleAll}
disabled={total === 0}
>
<Check className="size-3.5 mr-1.5" />
Yes, include all
</Button>
<Button
type="button"
size="sm"
variant={!bundleAll ? "default" : "outline"}
onClick={handleSelectSpecific}
disabled={total === 0}
>
No, choose specific
</Button>
</div>
</div>
)}
{/* ── Bundle all summary ───────────────────────────────────────── */}
{!loading && bundleAll && total > 0 && (
<p className="text-xs text-muted-foreground">
All {total} <span className="capitalize">{subscription}</span> unit{total !== 1 ? "s" : ""} will be included.
</p>
)}
{/* ── Already-assigned-elsewhere warning ──────────────────────── */}
{!loading && conflicts.length > 0 && (
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-800 p-3 text-xs text-amber-800 dark:text-amber-300">
<AlertTriangle className="size-4 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="font-medium">
{conflicts.length} unit{conflicts.length !== 1 ? "s" : ""} already belong{conflicts.length === 1 ? "s" : ""} to another plan.
</p>
<p>
A unit can only belong to one plan at a time. Assigning {conflicts.length === 1 ? "it" : "them"} here will move {conflicts.length === 1 ? "it" : "them"} out of{" "}
{conflictsByPlan.map(([label, count], i) => (
<span key={label}>
<span className="font-medium">{label}</span> ({count}){i < conflictsByPlan.length - 1 ? ", " : ""}
</span>
))}. Uncheck them below if that's not what you want.
</p>
</div>
</div>
)}
{/* ── No units in tier ─────────────────────────────────────────── */}
{!loading && total === 0 && (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<BookOpen className="size-4 shrink-0" />
No <span className="capitalize mx-1 font-medium">{subscription}</span> units found. Add units with this subscription first.
</div>
)}
{/* ── Specific picker (Popover) ─────────────────────────────────── */}
{!loading && !bundleAll && total > 0 && (
<div className="space-y-1.5">
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={cn(
"w-full justify-between gap-2",
selectedCount === 0 && "border-destructive text-destructive hover:border-destructive"
)}
>
{selectedCount === 0
? "No units selected"
: `${selectedCount} of ${total} unit${total !== 1 ? "s" : ""} selected`
}
<ChevronsUpDown className="size-4 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search units…"
value={search}
onValueChange={setSearch}
/>
<CommandList>
{filtered.length === 0 ? (
<CommandEmpty>No units match your search.</CommandEmpty>
) : (
<ScrollArea className="h-64">
{filtered.map((unit) => {
const id = String(unit.unit_id);
const checked = selectedIds.has(id);
const conflict = isConflict(unit);
return (
<CommandItem
key={id}
value={id}
onSelect={() => toggle(id)}
className="flex items-start gap-3 px-3 py-2.5 cursor-pointer"
>
<Checkbox
checked={checked}
onCheckedChange={() => toggle(id)}
className="mt-0.5 shrink-0"
onClick={(e) => e.stopPropagation()}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium leading-snug">{unit.title}</span>
{unit.description && (
<span className="text-xs text-muted-foreground line-clamp-1">
{unit.description}
</span>
)}
{conflict && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 rounded px-1.5 py-0.5 w-fit mt-0.5">
<AlertTriangle className="size-3" />
In "{unit.assigned_plan.label}"
</span>
)}
</div>
</CommandItem>
);
})}
</ScrollArea>
)}
</CommandList>
{/* Popover footer */}
<div className="border-t px-3 py-2 flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{selectedCount} of {total} selected
</span>
<div className="flex items-center gap-2">
<Checkbox
checked={selectedCount === total ? true : selectedCount > 0 ? "indeterminate" : false}
onCheckedChange={(v) => v ? checkAll() : resetAll()}
/>
<span className="text-xs text-muted-foreground select-none">
{selectedCount === total ? "Deselect all" : "Select all"}
</span>
</div>
</div>
</Command>
</PopoverContent>
</Popover>
{selectedCount === 0 && (
<p className="flex items-center gap-1.5 text-xs text-destructive">
<AlertTriangle className="size-3.5 shrink-0" />
Select at least one unit to bundle with this plan.
</p>
)}
</div>
)}
</div>
);
}
@@ -1,14 +1,15 @@
// modules/admin/config/assets/rowActions.config.jsx
import { Eye, Pencil, Archive } from "lucide-react";
import { Eye, ScanEye, Pencil, Archive } from "lucide-react";
/**
* @param {Object} deps
* @param {Function} deps.onView (row) → void — navigate to view page
* @param {Function} deps.onPreview (row) → void — open the quick-look preview dialog
* @param {Function} deps.onEdit (row) → void — navigate to edit page
* @param {Function} deps.onArchive (row) → void — open archive dialog
*/
export function buildRowActions({ onView, onEdit, onArchive }) {
export function buildRowActions({ onView, onPreview, onEdit, onArchive }) {
return [
{
key: "view",
@@ -16,6 +17,12 @@ export function buildRowActions({ onView, onEdit, onArchive }) {
icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => onView(row),
},
{
key: "preview",
label: "Preview",
icon: <ScanEye className="h-3.5 w-3.5" />,
onClick: (row) => onPreview(row),
},
{
key: "edit",
label: "Edit Info",
+5 -4
View File
@@ -9,7 +9,8 @@ import { ArrowLeft, ArrowRight, UploadCloud, X, FileVideo, FileText, Image, File
import { useAssets } from "@/contexts/AdminAssetsContext";
import { useAuth } from "@/contexts/AuthContext";
import { MAX_ASSET_FILE_SIZE, MAX_ASSET_FILE_SIZE_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 { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -83,7 +84,7 @@ function DropZone({ label, accept, file, onFile, onClear, error }) {
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{file.name}</p>
<p className="text-xs text-muted-foreground">
{(file.size / 1024).toFixed(1)} KB · {file.type}
{formatFileSize(file.size)} · {file.type}
</p>
</div>
<Button
@@ -157,8 +158,8 @@ export default function AddAsset() {
const fileType = file ? resolveFileType(file.type) : null;
const setFile = (f) => {
if (f.size > MAX_ASSET_FILE_SIZE) {
setError("_file", { message: `File exceeds the ${MAX_ASSET_FILE_SIZE_LABEL} size limit.` });
if (f.size > MAX_ASSET_FILE_SIZE_SINGLE) {
setError("_file", { message: `File exceeds the ${MAX_ASSET_FILE_SIZE_SINGLE_LABEL} size limit.` });
return;
}
fileRef.current = f;
@@ -10,6 +10,7 @@ import {
import { useAssets } from "@/contexts/AdminAssetsContext";
import { useUploadQueue } from "@/contexts/UploadQueueContext";
import { useAuth } from "@/contexts/AuthContext";
import { formatFileSize } from "@/utils/format.util";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
@@ -29,12 +30,6 @@ function FileTypeIcon({ mime = "" }) {
return <FileText className="h-8 w-8 text-orange-400 shrink-0" />;
}
function formatSize(bytes = 0) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// ─── Drop zone ────────────────────────────────────────────────────────────────
function DropZone({ onFiles }) {
@@ -93,7 +88,7 @@ function FileRow({ job, onRetry, onRemove }) {
<div className="flex-1 min-w-0 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium truncate">{job.name}</p>
<span className="text-xs text-muted-foreground shrink-0">{formatSize(job.size)}</span>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(job.size)}</span>
</div>
<Progress value={job.progress} className={barClass} />
<div className="flex items-center gap-1.5 text-xs">
+2 -7
View File
@@ -9,6 +9,7 @@ import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, RefreshCw } from
import { useAssets } from "@/contexts/AdminAssetsContext";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { formatFileSize } from "@/utils/format.util";
import { useAuth } from "@/contexts/AuthContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { Button } from "@/components/ui/button";
@@ -46,12 +47,6 @@ function FieldError({ message }) {
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
function formatBytes(bytes) {
if (!bytes) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
// ─── Thumbnail Drop Zone ──────────────────────────────────────────────────────
@@ -249,7 +244,7 @@ export default function EditAsset() {
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="secondary" className="text-xs">{asset.file_type}</Badge>
<Badge variant="outline" className="text-xs">{asset.extension?.toUpperCase()}</Badge>
<span className="text-xs text-muted-foreground">{formatBytes(asset.file_size)}</span>
<span className="text-xs text-muted-foreground">{formatFileSize(asset.file_size) ?? "—"}</span>
{asset.resolution && (
<span className="text-xs text-muted-foreground">{asset.resolution}</span>
)}
@@ -1,12 +1,12 @@
// modules/admin/pages/assets/ViewAudioAsset.jsx
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, Music2, Download } from "lucide-react";
import { ArrowLeft, Lock, Globe, Music2 } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { downloadAsset } from "@/utils/media.util";
import { formatFileSize } from "@/utils/format.util";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
@@ -71,9 +71,6 @@ export default function ViewAudioAsset() {
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
</div>
<Button variant="outline" size="sm" onClick={() => downloadAsset(a, { scope: "admin" })}>
<Download className="h-4 w-4" /> Download
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
@@ -93,7 +90,7 @@ export default function ViewAudioAsset() {
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
<MetaRow label="Original Name" value={a.original_name} />
<MetaRow label="Extension" value={a.extension} />
<MetaRow label="File Size" value={a.file_size ? `${(a.file_size / 1024).toFixed(1)} KB` : null} />
<MetaRow label="File Size" value={formatFileSize(a.file_size)} />
<MetaRow label="MIME Type" value={a.mime_type} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
@@ -1,17 +1,16 @@
// modules/admin/pages/assets/ViewDocumentAsset.jsx
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, FileText, Download } from "lucide-react";
import { ArrowLeft, Lock, Globe, FileText } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { downloadAsset } from "@/utils/media.util";
import { Spinner } from "@/components/ui/spinner";
import { formatFileSize } from "@/utils/format.util";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { MediaFallback } from "@/components/generic/MediaFallback";
import FileZoomViewer from "@/components/generic/FileZoomViewer";
import AssetPageLoader from "@/components/generic/AssetLoader";
function MetaRow({ label, value }) {
@@ -32,7 +31,7 @@ export default function ViewDocumentAsset() {
const { fmtDateTime } = useDateFormat();
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
const { src: streamUrl, loading: previewLoading } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
if (loading) {
return <AssetPageLoader />;
@@ -48,7 +47,9 @@ export default function ViewDocumentAsset() {
}
const a = selectedAsset;
const canPreview = PREVIEWABLE.includes((a.extension ?? "").toLowerCase());
const ext = (a.extension ?? "").toLowerCase();
const canPreview = PREVIEWABLE.includes(ext);
const isPdf = ext === "pdf";
return (
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
@@ -62,16 +63,22 @@ export default function ViewDocumentAsset() {
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
</div>
<Button variant="outline" size="sm" onClick={() => downloadAsset(a, { scope: "admin" })}>
<Download className="h-4 w-4" /> Download
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* ── Document preview ── */}
<div className="lg:col-span-3">
{canPreview && streamUrl ? (
{isPdf ? (
<div className="rounded-lg border overflow-hidden">
<FileZoomViewer
src={streamUrl}
mimeType={a.mime_type}
fileName={a.display_name ?? a.original_name}
loading={previewLoading}
/>
</div>
) : canPreview && streamUrl ? (
<div className="rounded-lg border overflow-hidden bg-white" style={{ height: 600 }}>
<iframe
src={streamUrl}
@@ -96,7 +103,7 @@ export default function ViewDocumentAsset() {
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
<MetaRow label="Original Name" value={a.original_name} />
<MetaRow label="Extension" value={a.extension} />
<MetaRow label="File Size" value={a.file_size ? `${(a.file_size / 1024).toFixed(1)} KB` : null} />
<MetaRow label="File Size" value={formatFileSize(a.file_size)} />
<MetaRow label="MIME Type" value={a.mime_type} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
@@ -1,16 +1,16 @@
// modules/admin/pages/assets/ViewImageAsset.jsx
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, Download } from "lucide-react";
import { ArrowLeft, Lock, Globe } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { downloadAsset } from "@/utils/media.util";
import { formatFileSize } from "@/utils/format.util";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { MediaFallback } from "@/components/generic/MediaFallback";
import FileZoomViewer from "@/components/generic/FileZoomViewer";
import AssetPageLoader from "@/components/generic/AssetLoader";
@@ -30,7 +30,7 @@ export default function ViewImageAsset() {
const { fmtDateTime } = useDateFormat();
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
const { src: streamUrl, loading: previewLoading } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
if (loading) {
return <AssetPageLoader />;
@@ -59,26 +59,18 @@ export default function ViewImageAsset() {
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
</div>
<Button variant="outline" size="sm" onClick={() => downloadAsset(a, { scope: "admin" })}>
<Download className="h-4 w-4" /> Download
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* ── Image preview ── */}
<div className="lg:col-span-3 rounded-lg border bg-muted/30 overflow-hidden flex items-center justify-center min-h-64">
{streamUrl ? (
<img
<div className="lg:col-span-3 rounded-lg border overflow-hidden">
<FileZoomViewer
src={streamUrl}
alt={a.display_name ?? a.original_name}
className="max-w-full max-h-[520px] object-contain"
draggable={false}
onContextMenu={(e) => e.preventDefault()}
mimeType={a.mime_type}
fileName={a.display_name ?? a.original_name}
loading={previewLoading}
/>
) : (
<MediaFallback className="size-full" />
)}
</div>
{/* ── Metadata panel ── */}
@@ -87,7 +79,7 @@ export default function ViewImageAsset() {
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
<MetaRow label="Original Name" value={a.original_name} />
<MetaRow label="Extension" value={a.extension} />
<MetaRow label="File Size" value={a.file_size ? `${(a.file_size / 1024).toFixed(1)} KB` : null} />
<MetaRow label="File Size" value={formatFileSize(a.file_size)} />
<MetaRow label="Resolution" value={a.resolution} />
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
<Separator className="my-2" />
@@ -1,16 +1,16 @@
// modules/admin/pages/assets/ViewVideoAsset.jsx
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, Download } from "lucide-react";
import { ArrowLeft, Lock, Globe } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { downloadAsset } from "@/utils/media.util";
import { formatFileSize } from "@/utils/format.util";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { MediaFallback } from "@/components/generic/MediaFallback";
import { VideoBlock } from "@/components/generic/Blocks/Admin/VideoBlock";
import { TranscodeStatusBanner } from "@/components/generic/TranscodeStatusBanner";
import AssetPageLoader from "@/components/generic/AssetLoader";
function MetaRow({ label, value }) {
@@ -39,7 +39,6 @@ export default function ViewVideoAsset() {
const { fmtDateTime } = useDateFormat();
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
if (loading) {
return <AssetPageLoader />;
@@ -68,33 +67,25 @@ export default function ViewVideoAsset() {
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
</div>
<Button variant="outline" size="sm" onClick={() => downloadAsset(a, { scope: "admin" })}>
<Download className="h-4 w-4" /> Download
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* ── Video player ── */}
<div className="lg:col-span-3 space-y-3">
<div className="rounded-lg border bg-black overflow-hidden aspect-video flex items-center justify-center">
{streamUrl ? (
<video
key={streamUrl}
controls
controlsList="nodownload"
disablePictureInPicture
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full"
poster={a.thumbnail_url ?? undefined}
>
<source src={streamUrl} type={a.mime_type ?? "video/mp4"} />
Your browser does not support the video tag.
</video>
) : (
<MediaFallback className="size-full" />
)}
</div>
<TranscodeStatusBanner status={a.transcode_status} />
<VideoBlock
readOnly
onUpdate={() => {}}
content={{
asset_id: a.asset_id,
storage_provider: a.storage_provider,
url: a.file_url,
thumbnail_url: a.thumbnail_url,
title: a.display_name ?? a.original_name,
tag: a.extension?.toUpperCase() ?? "",
}}
/>
{/* Thumbnail strip */}
{a.thumbnail_url && (
@@ -117,7 +108,7 @@ export default function ViewVideoAsset() {
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Video Info</p>
<MetaRow label="Original Name" value={a.original_name} />
<MetaRow label="Extension" value={a.extension} />
<MetaRow label="File Size" value={a.file_size ? `${(a.file_size / (1024 * 1024)).toFixed(2)} MB` : null} />
<MetaRow label="File Size" value={formatFileSize(a.file_size)} />
<MetaRow label="Resolution" value={a.resolution} />
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
<MetaRow label="Duration" value={formatDuration(a.duration)} />
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useForm, useWatch } from "react-hook-form";
import { z } from "zod";
@@ -14,12 +14,16 @@ import { useCourses } from "@/contexts/AdminCoursesContext";
import DraftRequirementsEditor, { DraftRequirementsSummary } from "@/modules/admin/components/courses/DraftRequirementsEditor";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import api from "@/utils/api.util";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription,
DrawerFooter, DrawerClose,
@@ -36,10 +40,11 @@ function makeBlock(type) {
const schema = z.object({
title: z.string().min(1, "Title is required."),
description: z.string().optional(),
subscription: z.string().optional(),
blocks: z.array(z.any()).optional(),
});
const DEFAULT_VALUES = { title: "", description: "", blocks: [] };
const DEFAULT_VALUES = { title: "", description: "", subscription: "", blocks: [] };
const STEPS = [
{ id: 0, label: "Lesson", icon: FileText },
@@ -50,7 +55,7 @@ const STEPS = [
// Fields validated with trigger() before advancing past each step.
// Empty array means "validate the whole form" (nothing new to check that step).
const STEP_FIELDS = [["title", "description"], [], []];
const STEP_FIELDS = [["title", "description", "subscription"], [], []];
function FieldError({ message }) {
if (!message) return null;
@@ -58,7 +63,9 @@ function FieldError({ message }) {
}
// ─── Step 1 — Lesson ───────────────────────────────────────────────────────────
function StepLesson({ register, errors }) {
function StepLesson({ register, errors, control, setValue, tierCategories }) {
const watchedSubscr = useWatch({ control, name: "subscription" });
return (
<div className="space-y-5">
<div className="space-y-1.5">
@@ -71,6 +78,29 @@ function StepLesson({ register, errors }) {
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
</div>
<div className="space-y-1.5">
<Label>Subscription</Label>
<Select
value={watchedSubscr || "__open"}
onValueChange={(val) => setValue("subscription", val === "__open" ? "" : val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="No tier gate" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__open">No tier gate (open)</SelectItem>
{tierCategories.map((c) => (
<SelectItem key={c.slug} value={c.slug}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Optional. Gates this lesson directly, independent of any unit it may later be attached to.
</p>
</div>
</div>
);
}
@@ -183,7 +213,9 @@ function SummaryRow({ label, value }) {
);
}
function StepReview({ data, attachUnitId, requirements }) {
function StepReview({ data, attachUnitId, requirements, tierCategories }) {
const tierName = tierCategories.find((c) => c.slug === data.subscription)?.name;
return (
<div className="space-y-4">
<div className="border border-border rounded-lg p-4 space-y-1">
@@ -193,6 +225,7 @@ function StepReview({ data, attachUnitId, requirements }) {
</div>
<SummaryRow label="Title" value={data.title} />
<SummaryRow label="Description" value={data.description} />
<SummaryRow label="Subscription" value={tierName || "No tier gate (open)"} />
<SummaryRow label="Page content" value={`${(data.blocks ?? []).length} block(s)`} />
{attachUnitId && <SummaryRow label="Attaches to" value="The unit you came from" />}
</div>
@@ -215,6 +248,13 @@ export default function AddLibraryLesson() {
const [step, setStep] = useState(0);
const [requirements, setRequirements] = useState([]);
const [tierCategories, setTierCategories] = useState([]);
useEffect(() => {
api.get("/admin/tiers/categories")
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
.catch(() => {});
}, []);
const {
register, control, trigger, getValues, setValue,
@@ -249,6 +289,7 @@ export default function AddLibraryLesson() {
const result = await createLesson({
title: data.title,
description: data.description || null,
subscription: data.subscription || null,
...(attachUnitId ? { unit_id: attachUnitId } : {}),
createdBy: user?.user_id,
});
@@ -332,7 +373,7 @@ export default function AddLibraryLesson() {
<h2 className="text-base font-medium mb-4">{STEPS[step].label}</h2>
{step === 0 && (
<StepLesson register={register} errors={errors} />
<StepLesson register={register} errors={errors} control={control} setValue={setValue} tierCategories={tierCategories} />
)}
{step === 1 && (
<StepPageBuilder control={control} setValue={setValue} getValues={getValues} />
@@ -345,7 +386,7 @@ export default function AddLibraryLesson() {
/>
)}
{step === 3 && (
<StepReview data={getValues()} attachUnitId={attachUnitId} requirements={requirements} />
<StepReview data={getValues()} attachUnitId={attachUnitId} requirements={requirements} tierCategories={tierCategories} />
)}
</div>
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useForm } from "react-hook-form";
import { useForm, useWatch } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft } from "lucide-react";
@@ -9,17 +9,23 @@ import { useLibrary } from "@/contexts/AdminLibraryContext";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import api from "@/utils/api.util";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
import ProductPricingCard from "@/modules/admin/components/products/ProductPricingCard";
const schema = z.object({
title: z.string().min(1, "Title is required."),
description: z.string().optional(),
subscription: z.string().optional(),
});
function FieldError({ message }) {
@@ -30,29 +36,38 @@ function FieldError({ message }) {
export default function EditLibraryLesson() {
const navigate = useNavigate();
const { lessonId } = useParams();
const { fetchLesson, updateLesson, lesson, loading } = useLibrary();
const { fetchLesson, updateLesson, lesson, loading, fetchLessonProduct, saveLessonProduct, removeLessonProduct } = useLibrary();
const { fetchLessonRequirements, syncLessonRequirements } = useCourses();
const { user } = useAuth();
const { register, handleSubmit, reset, formState: { errors, isDirty } } = useForm({
const [tierCategories, setTierCategories] = useState([]);
useEffect(() => {
api.get("/admin/tiers/categories")
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
.catch(() => {});
}, []);
const { register, handleSubmit, reset, control, setValue, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema),
defaultValues: { title: "", description: "" },
defaultValues: { title: "", description: "", subscription: "" },
});
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const watchedSubscr = useWatch({ control, name: "subscription" });
useEffect(() => {
fetchLesson(lessonId);
}, [lessonId]);
useEffect(() => {
if (lesson && String(lesson.lesson_id) === String(lessonId)) {
reset({ title: lesson.title ?? "", description: lesson.description ?? "" });
reset({ title: lesson.title ?? "", description: lesson.description ?? "", subscription: lesson.subscription ?? "" });
}
}, [lesson, lessonId, reset]);
const onSubmit = async (data) => {
const result = await updateLesson(lessonId, { ...data, updatedBy: user?.user_id });
const result = await updateLesson(lessonId, { ...data, subscription: data.subscription || null, updatedBy: user?.user_id });
if (!result) return;
bypassOnce();
navigate(`/admin/lessons/${lessonId}/view`);
@@ -90,6 +105,29 @@ export default function EditLibraryLesson() {
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
</div>
<div className="space-y-1.5">
<Label>Subscription</Label>
<Select
value={watchedSubscr || "__open"}
onValueChange={(val) => setValue("subscription", val === "__open" ? "" : val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="No tier gate" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__open">No tier gate (open)</SelectItem>
{tierCategories.map((c) => (
<SelectItem key={c.slug} value={c.slug}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Optional. Gates this lesson directly, independent of any unit it may later be attached to.
</p>
</div>
</div>
<div className="flex justify-end gap-3">
@@ -115,6 +153,20 @@ export default function EditLibraryLesson() {
args={[null, null, lessonId]}
/>
</div>
<div className="rounded-lg border bg-card p-6 space-y-4 mt-6">
<div>
<h2 className="text-sm font-semibold">Pricing</h2>
<p className="text-xs text-muted-foreground">Optional individual-purchase listing for this lesson.</p>
</div>
<ProductPricingCard
label="this lesson"
fetchFn={fetchLessonProduct}
saveFn={saveLessonProduct}
removeFn={removeLessonProduct}
args={[lessonId]}
/>
</div>
</div>
</div>
@@ -75,7 +75,7 @@ export default function ViewLibraryLesson() {
</div>
{/* Stats row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Blocks</p>
<p className="font-semibold text-sm">{blocks.length}</p>
@@ -97,6 +97,12 @@ export default function ViewLibraryLesson() {
{units.length > 0 ? `${units.length} unit${units.length === 1 ? "" : "s"}` : "Standalone"}
</p>
</div>
<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>
<Badge variant={lesson?.subscription ? "outline" : "secondary"} className="capitalize">
{lesson?.subscription ?? "No tier gate"}
</Badge>
</div>
</div>
{/* Attached units */}
@@ -11,6 +11,7 @@ import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import api from "@/utils/api.util";
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
import ProductPricingCard from "@/modules/admin/components/products/ProductPricingCard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -35,7 +36,7 @@ function FieldError({ message }) {
export default function EditLibraryUnit() {
const navigate = useNavigate();
const { unitId } = useParams();
const { fetchUnit, updateUnit, unit, loading } = useLibrary();
const { fetchUnit, updateUnit, unit, loading, fetchUnitProduct, saveUnitProduct, removeUnitProduct } = useLibrary();
const { fetchUnitRequirements, syncUnitRequirements } = useCourses();
const { user } = useAuth();
@@ -152,6 +153,20 @@ export default function EditLibraryUnit() {
args={[null, unitId]}
/>
</div>
<div className="rounded-lg border bg-card p-6 space-y-4 mt-6">
<div>
<h2 className="text-sm font-semibold">Pricing</h2>
<p className="text-xs text-muted-foreground">Optional individual-purchase listing for this unit.</p>
</div>
<ProductPricingCard
label="this unit"
fetchFn={fetchUnitProduct}
saveFn={saveUnitProduct}
removeFn={removeUnitProduct}
args={[unitId]}
/>
</div>
</div>
</div>
@@ -106,7 +106,7 @@ export default function ViewLibraryUnit() {
</div>
{/* Stats row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Lessons</p>
<p className="font-semibold text-sm">{lessons.length}</p>
@@ -125,6 +125,12 @@ export default function ViewLibraryUnit() {
{courses.length > 0 ? `${courses.length} course${courses.length === 1 ? "" : "s"}` : "Standalone"}
</p>
</div>
<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>
<Badge variant={unit?.subscription ? "outline" : "secondary"} className="capitalize">
{unit?.subscription ?? "No tier gate"}
</Badge>
</div>
</div>
{/* Attached courses */}
+46 -9
View File
@@ -14,6 +14,8 @@ import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { PageMeta } from "@/contexts/MetadataContext";
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
import { UnitPicker } from "@/modules/admin/components/tiers/UnitPicker";
import { LessonPicker } from "@/modules/admin/components/tiers/LessonPicker";
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
import api from "@/utils/api.util";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
@@ -75,7 +77,7 @@ function SectionCard({ title, children }) {
const STEPS = [
{ label: "Category & Label", description: "Tier category, label & description" },
{ label: "Duration & Pricing", description: "Billing period, price & currency" },
{ label: "Assigned Courses", description: "Choose which courses this unlocks" },
{ label: "Bundles", description: "Choose which courses, units & lessons this unlocks" },
];
function StepIndicator({ steps, current, maxStepReached, onStepClick }) {
@@ -144,6 +146,10 @@ export default function AddPlan() {
const [currencies, setCurrencies] = useState([]);
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
const [courseConflicts, setCourseConflicts] = useState(0);
const [selectedUnitIds, setSelectedUnitIds] = useState(new Set());
const [unitConflicts, setUnitConflicts] = useState(0);
const [selectedLessonIds, setSelectedLessonIds] = useState(new Set());
const [lessonConflicts, setLessonConflicts] = useState(0);
useEffect(() => {
api.get("/admin/tiers/categories")
@@ -176,10 +182,11 @@ export default function AddPlan() {
insertFeature(index + 1, lines.slice(1).map((text) => ({ text })));
};
// selectedCourseIds lives outside the form — this is a create page so it
// always starts empty, meaning any selection is a genuine unsaved change.
// selectedCourseIds/selectedUnitIds/selectedLessonIds live outside the form
// — this is a create page so they always start empty, meaning any
// selection is a genuine unsaved change.
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(
isDirty || selectedCourseIds.size > 0
isDirty || selectedCourseIds.size > 0 || selectedUnitIds.size > 0 || selectedLessonIds.size > 0
);
const selectedCategoryId = watch("tier_category_id");
@@ -190,10 +197,14 @@ export default function AddPlan() {
return categories.find((c) => String(c.tier_category_id) === selectedCategoryId)?.slug ?? null;
}, [selectedCategoryId, categories]);
// Reset picker when category changes
// Reset pickers when category changes
useEffect(() => {
setSelectedCourseIds(new Set());
setCourseConflicts(0);
setSelectedUnitIds(new Set());
setUnitConflicts(0);
setSelectedLessonIds(new Set());
setLessonConflicts(0);
}, [categorySlug]);
const STEP_FIELDS = [
@@ -220,12 +231,22 @@ export default function AddPlan() {
const result = await createPlan(values);
if (!result) return;
// Sync selected courses
// Sync selected bundles
if (selectedCourseIds.size > 0) {
await api.post(`/admin/tiers/plans/${result.plan_id}/courses`, {
course_ids: [...selectedCourseIds].map(Number),
}).catch(() => {});
}
if (selectedUnitIds.size > 0) {
await api.post(`/admin/tiers/plans/${result.plan_id}/units`, {
unit_ids: [...selectedUnitIds].map(Number),
}).catch(() => {});
}
if (selectedLessonIds.size > 0) {
await api.post(`/admin/tiers/plans/${result.plan_id}/lessons`, {
lesson_ids: [...selectedLessonIds].map(Number),
}).catch(() => {});
}
bypassOnce();
navigate(`/admin/tiers/plans`);
@@ -401,15 +422,31 @@ export default function AddPlan() {
</SectionCard>
)}
{/* ── Step 2: Assigned Courses ── */}
{/* ── Step 2: Bundles ── */}
{currentStep === 2 && (
<SectionCard title="Assigned Courses" description="Choose which courses this plan unlocks.">
<SectionCard title="Bundles" description="Choose which courses, units, and lessons this plan unlocks.">
<CoursePicker
subscription={categorySlug}
selectedIds={selectedCourseIds}
onChange={setSelectedCourseIds}
onConflictsChange={setCourseConflicts}
/>
<div className="border-t pt-5">
<UnitPicker
subscription={categorySlug}
selectedIds={selectedUnitIds}
onChange={setSelectedUnitIds}
onConflictsChange={setUnitConflicts}
/>
</div>
<div className="border-t pt-5">
<LessonPicker
subscription={categorySlug}
selectedIds={selectedLessonIds}
onChange={setSelectedLessonIds}
onConflictsChange={setLessonConflicts}
/>
</div>
</SectionCard>
)}
@@ -432,7 +469,7 @@ export default function AddPlan() {
<Button
type="button"
onClick={handleSubmit(onSubmit)}
disabled={loading || catLoading || !selectedCategoryId || courseConflicts > 0}
disabled={loading || catLoading || !selectedCategoryId || courseConflicts > 0 || unitConflicts > 0 || lessonConflicts > 0}
>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Plan
+81 -2
View File
@@ -17,6 +17,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { PageMeta } from "@/contexts/MetadataContext";
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
import { UnitPicker } from "@/modules/admin/components/tiers/UnitPicker";
import { LessonPicker } from "@/modules/admin/components/tiers/LessonPicker";
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
import api from "@/utils/api.util";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
@@ -87,6 +89,12 @@ export default function EditPlan() {
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
const [coursesLoaded, setCoursesLoaded] = useState(false);
const [courseConflicts, setCourseConflicts] = useState(0);
const [selectedUnitIds, setSelectedUnitIds] = useState(new Set());
const [unitsLoaded, setUnitsLoaded] = useState(false);
const [unitConflicts, setUnitConflicts] = useState(0);
const [selectedLessonIds, setSelectedLessonIds] = useState(new Set());
const [lessonsLoaded, setLessonsLoaded] = useState(false);
const [lessonConflicts, setLessonConflicts] = useState(0);
const [currencies, setCurrencies] = useState([]);
const [impactDialog, setImpactDialog] = useState(false);
const [impactCount, setImpactCount] = useState(0);
@@ -150,6 +158,29 @@ export default function EditPlan() {
.catch(() => setCoursesLoaded(true));
}, [planId]);
// Load existing assigned units/lessons the same way
useEffect(() => {
if (!planId || unitsLoaded) return;
api.get(`/admin/tiers/${planId}/units`)
.then(({ data }) => {
const ids = (data.data ?? []).map((u) => String(u.unit_id));
setSelectedUnitIds(new Set(ids));
setUnitsLoaded(true);
})
.catch(() => setUnitsLoaded(true));
}, [planId]);
useEffect(() => {
if (!planId || lessonsLoaded) return;
api.get(`/admin/tiers/${planId}/lessons`)
.then(({ data }) => {
const ids = (data.data ?? []).map((l) => String(l.lesson_id));
setSelectedLessonIds(new Set(ids));
setLessonsLoaded(true);
})
.catch(() => setLessonsLoaded(true));
}, [planId]);
const durationChanged = (values) => {
if (!plan) return false;
const originalUnit = plan.duration_unit ?? "day";
@@ -166,6 +197,12 @@ export default function EditPlan() {
await api.post(`/admin/tiers/${planId}/courses`, {
course_ids: [...selectedCourseIds],
}).catch(() => {});
await api.post(`/admin/tiers/${planId}/units`, {
unit_ids: [...selectedUnitIds],
}).catch(() => {});
await api.post(`/admin/tiers/${planId}/lessons`, {
lesson_ids: [...selectedLessonIds],
}).catch(() => {});
bypassOnce();
navigate("/admin/tiers/plans");
};
@@ -339,7 +376,7 @@ export default function EditPlan() {
</SectionCard>
{plan?.tier && (
<SectionCard title="Assigned Courses">
<SectionCard title="Bundles" description="Choose which courses, units, and lessons this plan unlocks.">
{coursesLoaded ? (
<CoursePicker
subscription={plan.tier}
@@ -358,12 +395,54 @@ export default function EditPlan() {
<Skeleton className="h-8 w-52" />
</div>
)}
<div className="border-t pt-5">
{unitsLoaded ? (
<UnitPicker
subscription={plan.tier}
selectedIds={selectedUnitIds}
onChange={setSelectedUnitIds}
isPreloaded={true}
currentPlanId={planId}
onConflictsChange={setUnitConflicts}
/>
) : (
<div className="space-y-3">
<div className="flex gap-2">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-8 w-40" />
</div>
<Skeleton className="h-8 w-52" />
</div>
)}
</div>
<div className="border-t pt-5">
{lessonsLoaded ? (
<LessonPicker
subscription={plan.tier}
selectedIds={selectedLessonIds}
onChange={setSelectedLessonIds}
isPreloaded={true}
currentPlanId={planId}
onConflictsChange={setLessonConflicts}
/>
) : (
<div className="space-y-3">
<div className="flex gap-2">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-8 w-40" />
</div>
<Skeleton className="h-8 w-52" />
</div>
)}
</div>
</SectionCard>
)}
<div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="outline" onClick={() => navigate("/admin/tiers/plans")} disabled={loading || impactLoading}>Cancel</Button>
<Button type="submit" disabled={loading || impactLoading || courseConflicts > 0}>
<Button type="submit" disabled={loading || impactLoading || courseConflicts > 0 || unitConflicts > 0 || lessonConflicts > 0}>
{(loading || impactLoading) && <Spinner className="h-4 w-4 mr-2" />}
Save Changes
</Button>
+82 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useState, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft, CreditCard, Pencil, Tag, BadgeCheck, BookOpen, Clock,
ShieldCheck, Plus, Trash2, Loader2, Receipt, KeyRound, Users, Lock,
ShieldCheck, Plus, Trash2, Loader2, Receipt, KeyRound, Users, Lock, FileText,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -70,7 +70,7 @@ function formatCourseDuration(seconds = 0) {
// ─── Tab: Plan Details ─────────────────────────────────────────────────────────
function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoading }) {
function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoading, assignedUnits, unitsLoading, assignedLessons, lessonsLoading }) {
const { fmtDateTime } = useDateFormat();
if (loading && !plan) {
@@ -159,6 +159,68 @@ function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoadin
)}
</SectionCard>
<SectionCard icon={BookOpen} title="Assigned Units">
{unitsLoading ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : assignedUnits.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<BookOpen className="size-4 shrink-0" />
No units assigned to this plan yet.
</div>
) : (
<div className="space-y-0 divide-y rounded-lg border overflow-hidden">
{assignedUnits.map((unit) => (
<div key={unit.unit_id} className="flex items-center justify-between gap-3 px-4 py-3 bg-card hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center shrink-0">
<BookOpen className="size-4 text-muted-foreground" />
</div>
<p className="text-sm font-medium line-clamp-1">{unit.title}</p>
</div>
</div>
))}
</div>
)}
{!unitsLoading && (
<p className="text-xs text-muted-foreground">
{assignedUnits.length} unit{assignedUnits.length !== 1 ? "s" : ""} assigned
</p>
)}
</SectionCard>
<SectionCard icon={FileText} title="Assigned Lessons">
{lessonsLoading ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : assignedLessons.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<FileText className="size-4 shrink-0" />
No lessons assigned to this plan yet.
</div>
) : (
<div className="space-y-0 divide-y rounded-lg border overflow-hidden">
{assignedLessons.map((lesson) => (
<div key={lesson.lesson_id} className="flex items-center justify-between gap-3 px-4 py-3 bg-card hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center shrink-0">
<FileText className="size-4 text-muted-foreground" />
</div>
<p className="text-sm font-medium line-clamp-1">{lesson.title}</p>
</div>
</div>
))}
</div>
)}
{!lessonsLoading && (
<p className="text-xs text-muted-foreground">
{assignedLessons.length} lesson{assignedLessons.length !== 1 ? "s" : ""} assigned
</p>
)}
</SectionCard>
<SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Created At">{plan.createdAt ? fmtDateTime(plan.createdAt) : "—"}</InfoRow>
@@ -750,6 +812,10 @@ export default function ViewPlan() {
const [assignedCourses, setAssignedCourses] = useState([]);
const [coursesLoading, setCoursesLoading] = useState(false);
const [assignedUnits, setAssignedUnits] = useState([]);
const [unitsLoading, setUnitsLoading] = useState(false);
const [assignedLessons, setAssignedLessons] = useState([]);
const [lessonsLoading, setLessonsLoading] = useState(false);
useEffect(() => {
fetchPlan(planId);
@@ -759,6 +825,16 @@ export default function ViewPlan() {
.then(({ data }) => setAssignedCourses(data.data ?? []))
.catch(() => {})
.finally(() => setCoursesLoading(false));
setUnitsLoading(true);
api.get(`/admin/tiers/${planId}/units`)
.then(({ data }) => setAssignedUnits(data.data ?? []))
.catch(() => {})
.finally(() => setUnitsLoading(false));
setLessonsLoading(true);
api.get(`/admin/tiers/${planId}/lessons`)
.then(({ data }) => setAssignedLessons(data.data ?? []))
.catch(() => {})
.finally(() => setLessonsLoading(false));
}, [planId]);
return (
@@ -835,6 +911,10 @@ export default function ViewPlan() {
tierMap={tierMap}
assignedCourses={assignedCourses}
coursesLoading={coursesLoading}
assignedUnits={assignedUnits}
unitsLoading={unitsLoading}
assignedLessons={assignedLessons}
lessonsLoading={lessonsLoading}
/>
)}
{activeTab === "access" && (
@@ -37,7 +37,7 @@ import { formatDate } from '@/utils/table.util';
import { formatBytes } from './blocks/FileUpload';
import api from '@/utils/api.util';
import { saveBlob } from '@/utils/media.util';
import FileZoomViewer from './FileZoomViewer';
import FileZoomViewer from '@/components/generic/FileZoomViewer';
// ─── Resolve preview kind from mime type ──────────────────────────────────────
const resolveKind = (mimeType = '', fileName = '') => {
@@ -48,7 +48,7 @@ const resolveKind = (mimeType = '', fileName = '') => {
const ext = (fileName.split('.').pop() ?? '').toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
if (['mp4', 'mov', 'webm', 'avi'].includes(ext)) return 'video';
if (['mp4', 'mov', 'mkv', 'webm', 'avi'].includes(ext)) return 'video';
if (['mp3', 'wav', 'm4a', 'ogg'].includes(ext)) return 'audio';
if (ext === 'pdf') return 'pdf';
return 'other';
@@ -140,7 +140,7 @@ const PreviewBody = ({ file, kind, blobUrl, loading, error, downloadUrl, onDownl
if (error) return <ErrorState onDownload={onDownload} downloading={downloading} fileName={file.file_name} />;
return (
<FileZoomViewer
blobUrl={blobUrl}
src={blobUrl}
mimeType={file.mime_type}
fileName={file.file_name}
loading={loading}
@@ -1,37 +1,63 @@
// LessonUpsellModal — shown when a learner clicks a locked standalone Lesson.
// Mirrors UnitUpsellModal.jsx: a Lesson isn't independently purchasable, so this
// lists every course that would unlock it (aggregated across all its attached
// Units, since a Lesson can sit in more than one) plus a generic "View Plans"
// fallback. Shared by LessonsList and Dashboard.
// Mirrors UnitUpsellModal.jsx: unlocks via the lesson's own subscription tier
// (with an optional direct "Buy" listing) and/or any course reachable through
// its attached Units (aggregated across all of them, since a Lesson can sit
// in more than one) plus a generic "View Plans" fallback.
// Shared by LessonsList and Dashboard.
import { useNavigate } from "react-router-dom";
import { LockIcon, BookOpen } from "lucide-react";
import { LockIcon, BookOpen, ShoppingCart } from "lucide-react";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useDateFormat } from "@/hooks/useDateFormat";
import { resolveTierBadge } from "@/utils/tierBadge.util";
export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap = {} }) {
const navigate = useNavigate();
const { fmtCurrency } = useDateFormat();
const courses = lesson?.courses ?? [];
const ownTier = lesson?.subscription ? resolveTierBadge(lesson.subscription, tierMap) : null;
const canBuy = lesson?.product?.is_active && !lesson?.has_purchased;
return (
<ResponsiveModal
open={open}
onOpenChange={onOpenChange}
title={lesson?.title ?? "Lesson Details"}
description="This lesson is part of one or more courses that require a plan upgrade."
description={
ownTier
? "This lesson requires a plan upgrade or individual purchase."
: "This lesson is part of one or more courses that require a plan upgrade."
}
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
<Button onClick={() => { onOpenChange(false); navigate("/plans"); }}>
{canBuy && (
<Button onClick={() => { onOpenChange(false); navigate(`/lessons/${lesson.uuid}/checkout`); }}>
<ShoppingCart /> Buy {fmtCurrency(lesson.product.price ?? 0, lesson.product.currency ?? "USD")}
</Button>
)}
<Button variant={canBuy ? "outline" : "default"} onClick={() => { onOpenChange(false); navigate("/plans"); }}>
<LockIcon /> View Plans
</Button>
</>
}
>
<div className="space-y-3 py-2">
{courses.length === 0 ? (
{ownTier && (
<div className="flex items-center justify-between gap-3 p-4 rounded-xl border bg-muted/40">
<div className="flex items-center gap-2.5 min-w-0">
<LockIcon className="size-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium">Requires</p>
<Badge className={`${ownTier.cls} mt-1`}>{ownTier.label}</Badge>
</div>
</div>
</div>
)}
{courses.length === 0 && !ownTier ? (
<p className="text-sm text-muted-foreground">
Upgrade your plan to access this content.
</p>
@@ -3,14 +3,24 @@
// Distinct from UnitUpsellModal (a dialog triggered from card grids) — this
// renders in place of the page body itself. Shared by UnitDetails and
// LessonDetails.
//
// `item` (optional) is the unit/lesson's own subscription/product info, from
// the 403 body's `item` field — lets a deep-link show the same own-tier
// badge + direct "Buy" option the browse-list upsell modals already do,
// instead of only ever pointing at an attached course.
import { useNavigate } from "react-router-dom";
import { LockIcon, Zap } from "lucide-react";
import { LockIcon, Zap, ShoppingCart } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useDateFormat } from "@/hooks/useDateFormat";
import { resolveTierBadge } from "@/utils/tierBadge.util";
export default function LockedContentPanel({ course, tierMap = {} }) {
export default function LockedContentPanel({ course, item, tierMap = {}, checkoutPath }) {
const navigate = useNavigate();
const { fmtCurrency } = useDateFormat();
const tier = course?.subscription ? tierMap[course.subscription] : null;
const ownTier = item?.subscription ? resolveTierBadge(item.subscription, tierMap) : null;
const canBuy = item?.product?.is_active && !item?.has_purchased && checkoutPath;
return (
<div className="flex flex-col items-center gap-6 py-32 text-center px-4">
@@ -20,15 +30,24 @@ export default function LockedContentPanel({ course, tierMap = {} }) {
<div className="space-y-2 max-w-sm">
<h2 className="text-xl font-semibold">Premium / Exclusive Content</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
{course
{ownTier
? `This content requires the ${ownTier.label} plan${canBuy ? ", or you can purchase it individually" : ""}.`
: course
? `This content is part of "${course.title}"${tier?.name ? ` (${tier.name} plan)` : ""}. Upgrade your plan or view the course to unlock it.`
: "Upgrade your plan to access this content."}
</p>
</div>
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
{canBuy && (
<Button variant="outline" onClick={() => navigate(checkoutPath)} className="gap-1.5">
<ShoppingCart className="size-4" /> Buy {fmtCurrency(item.product.price ?? 0, item.product.currency ?? "USD")}
</Button>
)}
<Button onClick={() => navigate('/plans')} className="gap-1.5">
<Zap className="size-4" /> View Available Plans
</Button>
</div>
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
</div>
</div>
@@ -1,37 +1,63 @@
// UnitUpsellModal — shown when a learner clicks a locked standalone Unit.
// Units aren't independently purchasable (no Product row keyed to unit_id, only
// to course_id), so unlike CourseCard's single "Buy $X" button, this lists every
// course the unit is attached to so the learner can pick one to view/buy, plus a
// generic "View Plans" fallback. Shared by UnitsList, UnitDetails, and Dashboard.
// Unlocks two ways: the unit's own subscription tier (with an optional direct
// "Buy" listing, same PayPal flow as Courses) and/or any course it's attached
// to (a unit may sit under several courses at different tiers — no single
// "Buy" in that case, just links to view/buy the course itself).
// Shared by UnitsList, UnitDetails, and Dashboard.
import { useNavigate } from "react-router-dom";
import { LockIcon, BookOpen } from "lucide-react";
import { LockIcon, BookOpen, ShoppingCart } from "lucide-react";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useDateFormat } from "@/hooks/useDateFormat";
import { resolveTierBadge } from "@/utils/tierBadge.util";
export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {} }) {
const navigate = useNavigate();
const { fmtCurrency } = useDateFormat();
const courses = unit?.courses ?? [];
const ownTier = unit?.subscription ? resolveTierBadge(unit.subscription, tierMap) : null;
const canBuy = unit?.product?.is_active && !unit?.has_purchased;
return (
<ResponsiveModal
open={open}
onOpenChange={onOpenChange}
title={unit?.title ?? "Unit Details"}
description="This unit is part of one or more courses that require a plan upgrade."
description={
ownTier
? "This unit requires a plan upgrade or individual purchase."
: "This unit is part of one or more courses that require a plan upgrade."
}
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
<Button onClick={() => { onOpenChange(false); navigate("/plans"); }}>
{canBuy && (
<Button onClick={() => { onOpenChange(false); navigate(`/units/${unit.uuid}/checkout`); }}>
<ShoppingCart /> Buy {fmtCurrency(unit.product.price ?? 0, unit.product.currency ?? "USD")}
</Button>
)}
<Button variant={canBuy ? "outline" : "default"} onClick={() => { onOpenChange(false); navigate("/plans"); }}>
<LockIcon /> View Plans
</Button>
</>
}
>
<div className="space-y-3 py-2">
{courses.length === 0 ? (
{ownTier && (
<div className="flex items-center justify-between gap-3 p-4 rounded-xl border bg-muted/40">
<div className="flex items-center gap-2.5 min-w-0">
<LockIcon className="size-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium">Requires</p>
<Badge className={`${ownTier.cls} mt-1`}>{ownTier.label}</Badge>
</div>
</div>
</div>
)}
{courses.length === 0 && !ownTier ? (
<p className="text-sm text-muted-foreground">
Upgrade your plan to access this content.
</p>
+8 -7
View File
@@ -405,11 +405,11 @@ const Checkout = () => {
<span>{fmtCurrency(total, effectiveCurrency)}</span>
</div>
{isCurrent ? (
<Button size="lg" className="w-full" variant="outline" disabled>
<Check className="size-4" /> Current Plan
</Button>
) : (
{isCurrent && (
<p className="text-xs text-muted-foreground rounded-md border bg-muted/50 px-3 py-2">
You already have an active {plan.tier} plan — this purchase will extend it by {duration}, instead of starting a new one.
</p>
)}
<Button
size="lg"
className="w-full"
@@ -420,9 +420,10 @@ const Checkout = () => {
? <Loader2 className="size-4 animate-spin" />
: <ShieldCheck className="size-4" />
}
Pay {fmtCurrency(total, effectiveCurrency)} with PayPal
{isCurrent
? `Extend for ${fmtCurrency(total, effectiveCurrency)} with PayPal`
: `Pay ${fmtCurrency(total, effectiveCurrency)} with PayPal`}
</Button>
)}
<div className="text-center text-sm text-muted-foreground space-y-1">
<p className="inline-flex items-center justify-center gap-1">
<ShieldCheck className="size-4" />
+181
View File
@@ -0,0 +1,181 @@
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, FileText, CalendarDays, House, Loader2, ShieldCheck } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { usePurchaseCheckout } from "@/hooks/usePurchaseCheckout";
function formatAccess(days) {
if (!days) return "Lifetime access";
if (days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""} access`;
if (days % 30 === 0) return `${days / 30} month${days / 30 > 1 ? "s" : ""} access`;
return `${days} day access`;
}
const PageSkeleton = () => (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-4xl mx-auto px-6 pb-6 space-y-4">
<Skeleton className="h-5 w-48" />
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-8"><Skeleton className="h-64 w-full rounded-xl" /></div>
<div className="lg:col-span-4"><Skeleton className="h-48 w-full rounded-xl" /></div>
</div>
</div>
</div>
);
export default function LessonCheckout() {
const navigate = useNavigate();
const { uuid } = useParams();
const { fmtCurrency } = useDateFormat();
const { checkoutInfo: lesson, checkoutInfoLoading: lessonLoading, getLessonCheckoutInfo } = useLibrary();
const { capturing, purchaseLoading, buyNow } = usePurchaseCheckout({
targetId: uuid,
loadTarget: getLessonCheckoutInfo,
checkoutPath: `/lessons/${uuid}/checkout`,
});
if (capturing) {
return (
<div className="min-h-screen bg-muted flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="size-10 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Confirming your payment…</p>
</div>
</div>
);
}
if (lessonLoading) return <PageSkeleton />;
const product = lesson?.product ?? null;
if (!product) {
return (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-3xl mx-auto px-6 pb-6 space-y-4">
<Card>
<CardContent className="py-10 text-center space-y-4">
<FileText className="size-10 mx-auto text-muted-foreground/50" />
<div>
<h1 className="text-xl font-semibold">Not available for purchase</h1>
<p className="text-sm text-muted-foreground mt-1">
This lesson doesn't have an individual purchase option.
</p>
</div>
<Button onClick={() => navigate(`/lessons/${uuid}`)}>
<ArrowLeft className="size-4" /> Go Back
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/dashboard" },
{ label: "Lessons", to: "/lessons" },
{ label: lesson?.title ?? "Lesson", to: `/lessons/${uuid}` },
{ label: "Checkout" },
];
return (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-5xl mx-auto px-6 pb-10">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* ── Left ── */}
<div className="lg:col-span-8 space-y-6">
<AppBreadcrumb items={breadcrumbItems} />
<Card>
<CardHeader>
<CardTitle>Lesson Details</CardTitle>
<CardDescription>Review what you're about to purchase.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-lg bg-secondary flex items-center justify-center shrink-0">
<FileText className="size-8 text-secondary-foreground" />
</div>
<div className="flex-1 min-w-0 space-y-1">
<h2 className="text-lg font-semibold leading-snug">{lesson?.title}</h2>
{lesson?.description && (
<p className="text-sm text-muted-foreground line-clamp-2">{lesson.description}</p>
)}
</div>
</div>
<Separator />
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<CalendarDays className="size-4 shrink-0" />
<span>{formatAccess(product.access_days)}</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* ── Right / Summary ── */}
<div className="lg:col-span-4">
<Card className="lg:sticky lg:top-24">
<CardHeader>
<CardTitle>Price Summary</CardTitle>
<CardDescription>Payment processed through PayPal.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Lesson Price</span>
<span className="font-medium">{fmtCurrency(product.price, product.currency)}</span>
</div>
<Separator />
<div className="flex justify-between items-center text-lg font-semibold">
<span>Total</span>
<span>{fmtCurrency(product.price, product.currency)}</span>
</div>
{lesson?.has_purchased ? (
<Button size="lg" className="w-full" variant="outline" disabled>
Already Purchased
</Button>
) : (
<Button
size="lg"
className="w-full"
onClick={() => buyNow(product.id)}
disabled={purchaseLoading}
>
{purchaseLoading
? <Loader2 className="size-4 animate-spin" />
: <ShieldCheck className="size-4" />
}
Pay {fmtCurrency(product.price, product.currency)} with PayPal
</Button>
)}
<div className="text-center text-xs text-muted-foreground space-y-1">
<p className="inline-flex items-center justify-center gap-1">
<ShieldCheck className="size-3.5" />
Secure payment powered by PayPal
</p>
<p>Access starts after successful payment capture.</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}
+8 -1
View File
@@ -139,7 +139,14 @@ const LessonDetails = () => {
// ── Deep-link to a lesson under a locked unit — inline blocked panel ────
if (unitBlocked) {
return <LockedContentPanel course={unitBlockedInfo?.course} tierMap={tierMap} />;
return (
<LockedContentPanel
course={unitBlockedInfo?.course}
item={unitBlockedInfo?.item}
tierMap={tierMap}
checkoutPath={unitBlockedInfo?.item?.uuid ? `/lessons/${unitBlockedInfo.item.uuid}/checkout` : null}
/>
);
}
if (lessonLoading || !lesson) {
+11
View File
@@ -216,6 +216,17 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
>
{plan.tier === "free" ? "Current" : `Get ${tierLabel}`}
</Button>
) : plan.tier !== "free" ? (
// Already holds this tier (from another plan, past the refund
// window) — repurchasing extends the existing grant's expiry
// rather than being blocked, so still offer a way to buy.
<Button
className="flex-1"
variant="outline"
onClick={() => onSelect(plan)}
>
Extend {tierLabel}
</Button>
) : null}
</CardFooter>
</Card>
+181
View File
@@ -0,0 +1,181 @@
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, BookCheck, CalendarDays, House, Loader2, ShieldCheck } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { usePurchaseCheckout } from "@/hooks/usePurchaseCheckout";
function formatAccess(days) {
if (!days) return "Lifetime access";
if (days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""} access`;
if (days % 30 === 0) return `${days / 30} month${days / 30 > 1 ? "s" : ""} access`;
return `${days} day access`;
}
const PageSkeleton = () => (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-4xl mx-auto px-6 pb-6 space-y-4">
<Skeleton className="h-5 w-48" />
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-8"><Skeleton className="h-64 w-full rounded-xl" /></div>
<div className="lg:col-span-4"><Skeleton className="h-48 w-full rounded-xl" /></div>
</div>
</div>
</div>
);
export default function UnitCheckout() {
const navigate = useNavigate();
const { uuid } = useParams();
const { fmtCurrency } = useDateFormat();
const { checkoutInfo: unit, checkoutInfoLoading: unitLoading, getUnitCheckoutInfo } = useLibrary();
const { capturing, purchaseLoading, buyNow } = usePurchaseCheckout({
targetId: uuid,
loadTarget: getUnitCheckoutInfo,
checkoutPath: `/units/${uuid}/checkout`,
});
if (capturing) {
return (
<div className="min-h-screen bg-muted flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="size-10 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Confirming your payment…</p>
</div>
</div>
);
}
if (unitLoading) return <PageSkeleton />;
const product = unit?.product ?? null;
if (!product) {
return (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-3xl mx-auto px-6 pb-6 space-y-4">
<Card>
<CardContent className="py-10 text-center space-y-4">
<BookCheck className="size-10 mx-auto text-muted-foreground/50" />
<div>
<h1 className="text-xl font-semibold">Not available for purchase</h1>
<p className="text-sm text-muted-foreground mt-1">
This unit doesn't have an individual purchase option.
</p>
</div>
<Button onClick={() => navigate(`/units/${uuid}`)}>
<ArrowLeft className="size-4" /> Go Back
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/dashboard" },
{ label: "Units", to: "/units" },
{ label: unit?.title ?? "Unit", to: `/units/${uuid}` },
{ label: "Checkout" },
];
return (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-5xl mx-auto px-6 pb-10">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* ── Left ── */}
<div className="lg:col-span-8 space-y-6">
<AppBreadcrumb items={breadcrumbItems} />
<Card>
<CardHeader>
<CardTitle>Unit Details</CardTitle>
<CardDescription>Review what you're about to purchase.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-lg bg-secondary flex items-center justify-center shrink-0">
<BookCheck className="size-8 text-secondary-foreground" />
</div>
<div className="flex-1 min-w-0 space-y-1">
<h2 className="text-lg font-semibold leading-snug">{unit?.title}</h2>
{unit?.description && (
<p className="text-sm text-muted-foreground line-clamp-2">{unit.description}</p>
)}
</div>
</div>
<Separator />
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<CalendarDays className="size-4 shrink-0" />
<span>{formatAccess(product.access_days)}</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* ── Right / Summary ── */}
<div className="lg:col-span-4">
<Card className="lg:sticky lg:top-24">
<CardHeader>
<CardTitle>Price Summary</CardTitle>
<CardDescription>Payment processed through PayPal.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Unit Price</span>
<span className="font-medium">{fmtCurrency(product.price, product.currency)}</span>
</div>
<Separator />
<div className="flex justify-between items-center text-lg font-semibold">
<span>Total</span>
<span>{fmtCurrency(product.price, product.currency)}</span>
</div>
{unit?.has_purchased ? (
<Button size="lg" className="w-full" variant="outline" disabled>
Already Purchased
</Button>
) : (
<Button
size="lg"
className="w-full"
onClick={() => buyNow(product.id)}
disabled={purchaseLoading}
>
{purchaseLoading
? <Loader2 className="size-4 animate-spin" />
: <ShieldCheck className="size-4" />
}
Pay {fmtCurrency(product.price, product.currency)} with PayPal
</Button>
)}
<div className="text-center text-xs text-muted-foreground space-y-1">
<p className="inline-flex items-center justify-center gap-1">
<ShieldCheck className="size-3.5" />
Secure payment powered by PayPal
</p>
<p>Access starts after successful payment capture.</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}
+8 -1
View File
@@ -140,7 +140,14 @@ const UnitDetails = () => {
// ── Deep-link to a locked unit — inline blocked panel, not a redirect ────
if (unitBlocked) {
return <LockedContentPanel course={unitBlockedInfo?.course} tierMap={tierMap} />;
return (
<LockedContentPanel
course={unitBlockedInfo?.course}
item={unitBlockedInfo?.item}
tierMap={tierMap}
checkoutPath={unitBlockedInfo?.item?.uuid ? `/units/${unitBlockedInfo.item.uuid}/checkout` : null}
/>
);
}
if (unitDetailLoading) {
+10 -1
View File
@@ -21,6 +21,8 @@ import PlanList from '../pages/PlanList'
import ViewPlan from '../pages/ViewPlan'
import ViewTask from '../pages/ViewTask'
import CourseCheckout from '../pages/CourseCheckout'
import UnitCheckout from '../pages/UnitCheckout'
import LessonCheckout from '../pages/LessonCheckout'
import MyCertificates from '../pages/MyCertificates'
import MyAchievements from '../pages/MyAchievements'
import MyCompletedContent from '../pages/MyCompletedContent'
@@ -106,6 +108,7 @@ export const ClientRoutes = {
children: [
{ index: true, element: <UnitDetails /> },
{ path: 'read', element: <UnitReader />, handle: { showFooter: false } },
{ path: 'checkout', element: <UnitCheckout /> },
],
},
],
@@ -116,7 +119,13 @@ export const ClientRoutes = {
element: <Outlet />,
children: [
{ index: true, element: <LessonsList /> },
{ path: ':uuid', element: <LessonDetails /> },
{
path: ':uuid', element: <Outlet />,
children: [
{ index: true, element: <LessonDetails /> },
{ path: 'checkout', element: <LessonCheckout /> },
],
},
],
},
+17 -12
View File
@@ -29,26 +29,31 @@ const ALLOWED_DOCUMENT_EXTENSIONS = new Set([
]);
// Uploads go straight to storage via a presigned URL (see
// presignedUpload.util.js) — this backend never buffers the file. Above 5GB
// (S3-compatible storage's own single-PUT ceiling) uploads switch to real
// multipart automatically; 15GB is the app-level ceiling chosen for future
// assets, well within multipart's own much larger real limit. Rejecting an
// oversized file here still means the queue shows a clear "invalid" reason
// instantly instead of a job that uploads for a while and then fails.
export const MAX_ASSET_FILE_SIZE = 15 * 1024 * 1024 * 1024; // 15 GB
export const MAX_ASSET_FILE_SIZE_LABEL = "15 GB";
// presignedUpload.util.js) — this backend never buffers the file, so these
// ceilings are app-level policy rather than a technical constraint (S3's own
// single-PUT ceiling is a separate, higher 5GB — see s3.service.js). Add
// Asset (single) and Add Assets Bulk enforce different caps, so each flow
// gets its own constant. Rejecting an oversized file here still means the
// queue shows a clear "invalid" reason instantly instead of a job that
// uploads for a while and then fails.
export const MAX_ASSET_FILE_SIZE_SINGLE = 3 * 1024 * 1024 * 1024; // 3 GB
export const MAX_ASSET_FILE_SIZE_SINGLE_LABEL = "3 GB";
export const MAX_ASSET_FILE_SIZE_BULK = 5 * 1024 * 1024 * 1024; // 5 GB
export const MAX_ASSET_FILE_SIZE_BULK_LABEL = "5 GB";
export function fileExtension(filename = "") {
const dot = filename.lastIndexOf(".");
return dot > 0 ? filename.slice(dot + 1).toLowerCase() : "";
}
// { ok: true } or { ok: false, reason }
export function validateAssetFile(file) {
if (file.size > MAX_ASSET_FILE_SIZE) {
// { ok: true } or { ok: false, reason }. Defaults to the Bulk ceiling since
// that's this function's only caller (UploadQueueContext); Add Asset (single)
// checks its own, lower ceiling inline.
export function validateAssetFile(file, maxSize = MAX_ASSET_FILE_SIZE_BULK, maxSizeLabel = MAX_ASSET_FILE_SIZE_BULK_LABEL) {
if (file.size > maxSize) {
return {
ok: false,
reason: `File exceeds the ${MAX_ASSET_FILE_SIZE_LABEL} size limit.`,
reason: `File exceeds the ${maxSizeLabel} size limit.`,
};
}
+28
View File
@@ -0,0 +1,28 @@
// utils/format.util.js
//
// Shared byte-size and player-time formatters. Both were previously
// duplicated ad hoc across the admin asset pages / media block players, each
// copy independently under-scoped: file-size helpers that topped out at "MB"
// with no GB tier (a 1.4GB asset read as "1457.88 MB"), and a time formatter
// that never rolled minutes over into hours (a 76-minute video read as
// "76:57" instead of "1:16:57"). One source of truth from here on.
export function formatFileSize(bytes) {
if (bytes == null) return null;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
}
// m:ss under an hour, h:mm:ss at/above an hour — matches standard player
// conventions (YouTube, etc).
export function formatPlayerTime(seconds) {
if (!seconds || isNaN(seconds)) return "0:00";
const total = Math.floor(seconds);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
return `${m}:${String(s).padStart(2, "0")}`;
}