diff --git a/src/components/generic/Blocks/Admin/VideoBlock.jsx b/src/components/generic/Blocks/Admin/VideoBlock.jsx
index 1ca5e04..8353768 100644
--- a/src/components/generic/Blocks/Admin/VideoBlock.jsx
+++ b/src/components/generic/Blocks/Admin/VideoBlock.jsx
@@ -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 ─────────────────────────────────────────────────
@@ -64,17 +65,29 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
setCurrentTime(v.currentTime);
if (v.duration) setProgress((v.currentTime / v.duration) * 100);
};
- const onLoaded = () => setTotalDuration(v.duration);
- const onEnded = () => { setPlaying(false); setOverlayVisible(true); };
+ 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,22 +193,32 @@ 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 && (
+
+
+
+ )}
+
{/* Play/pause overlay */}
-
-
{ e.stopPropagation(); togglePlay(); }}
- className="w-12 h-12 rounded-full bg-white/90 hover:bg-white flex items-center justify-center transition-transform hover:scale-105"
+ {!mediaLoading && (
+
- {playing
- ?
- :
- }
-
-
+ { e.stopPropagation(); togglePlay(); }}
+ className="w-12 h-12 rounded-full bg-white/90 hover:bg-white flex items-center justify-center transition-transform hover:scale-105"
+ >
+ {playing
+ ?
+ :
+ }
+
+
+ )}
{/* Change video hover hint */}
{!readOnly && (
diff --git a/src/components/generic/Blocks/Client/AudioBlock.jsx b/src/components/generic/Blocks/Client/AudioBlock.jsx
index 575a9a4..24a7411 100644
--- a/src/components/generic/Blocks/Client/AudioBlock.jsx
+++ b/src/components/generic/Blocks/Client/AudioBlock.jsx
@@ -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];
diff --git a/src/components/generic/Blocks/Client/VideoBlock.jsx b/src/components/generic/Blocks/Client/VideoBlock.jsx
index 60f2c58..5fc11e8 100644
--- a/src/components/generic/Blocks/Client/VideoBlock.jsx
+++ b/src/components/generic/Blocks/Client/VideoBlock.jsx
@@ -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"];
diff --git a/src/modules/client/components/FileZoomViewer.jsx b/src/components/generic/FileZoomViewer.jsx
similarity index 90%
rename from src/modules/client/components/FileZoomViewer.jsx
rename to src/components/generic/FileZoomViewer.jsx
index 3e6efc0..b7fd7bb 100644
--- a/src/modules/client/components/FileZoomViewer.jsx
+++ b/src/components/generic/FileZoomViewer.jsx
@@ -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 →
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 }) => {
/>
e.preventDefault()}
className="max-h-[380px] max-w-none select-none pointer-events-none"
/>
@@ -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,14 +292,14 @@ const FileZoomViewer = ({ blobUrl, mimeType, fileName, loading }) => {
);
}
- if (!blobUrl || mode === 'unsupported') {
+ if (!src || mode === 'unsupported') {
return
;
}
- if (mode === 'image') return
;
- if (mode === 'pdf') return
;
+ if (mode === 'image') return
;
+ if (mode === 'pdf') return
;
return
;
};
-export default FileZoomViewer;
\ No newline at end of file
+export default FileZoomViewer;
diff --git a/src/components/generic/TranscodeStatusBanner.jsx b/src/components/generic/TranscodeStatusBanner.jsx
new file mode 100644
index 0000000..a59277d
--- /dev/null
+++ b/src/components/generic/TranscodeStatusBanner.jsx
@@ -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 (
+
+
+ Optimizing this video for faster playback — still watchable now, will load quicker shortly.
+
+ );
+}
diff --git a/src/contexts/AdminLibraryContext.jsx b/src/contexts/AdminLibraryContext.jsx
index 0bfb8a8..2e9e097 100644
--- a/src/contexts/AdminLibraryContext.jsx
+++ b/src/contexts/AdminLibraryContext.jsx
@@ -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
{children} ;
diff --git a/src/contexts/ClientCoursesContext.jsx b/src/contexts/ClientCoursesContext.jsx
index fa99fbd..9d6ae62 100644
--- a/src/contexts/ClientCoursesContext.jsx
+++ b/src/contexts/ClientCoursesContext.jsx
@@ -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); }, []);
diff --git a/src/contexts/ClientLibraryContext.jsx b/src/contexts/ClientLibraryContext.jsx
index fcc124b..af0c81c 100644
--- a/src/contexts/ClientLibraryContext.jsx
+++ b/src/contexts/ClientLibraryContext.jsx
@@ -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 (
diff --git a/src/hooks/usePurchaseCheckout.js b/src/hooks/usePurchaseCheckout.js
new file mode 100644
index 0000000..a08f436
--- /dev/null
+++ b/src/hooks/usePurchaseCheckout.js
@@ -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=
`
+ * (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 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 };
+}
diff --git a/src/hooks/usePurchases.js b/src/hooks/usePurchases.js
new file mode 100644
index 0000000..fb84ee0
--- /dev/null
+++ b/src/hooks/usePurchases.js
@@ -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 };
+}
diff --git a/src/modules/admin/components/assets/AssetPreviewDialog.jsx b/src/modules/admin/components/assets/AssetPreviewDialog.jsx
new file mode 100644
index 0000000..2add3e9
--- /dev/null
+++ b/src/modules/admin/components/assets/AssetPreviewDialog.jsx
@@ -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 (
+
+ );
+ }
+
+ if (asset.file_type === "audio") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+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 (
+
+
+
+
+ {fileName}
+
+
+
+
+
+ {asset.mime_type && (
+
+ {asset.mime_type}
+
+ )}
+ {formatFileSize(asset.file_size) && (
+
+ {formatFileSize(asset.file_size)}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/modules/admin/components/assets/AssetsTable.jsx b/src/modules/admin/components/assets/AssetsTable.jsx
index aadd614..c1dca32 100644
--- a/src/modules/admin/components/assets/AssetsTable.jsx
+++ b/src/modules/admin/components/assets/AssetsTable.jsx
@@ -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 ── */}
+ !v && setPreviewTarget(null)}
+ />
>
);
}
\ No newline at end of file
diff --git a/src/modules/admin/components/products/ProductPricingCard.jsx b/src/modules/admin/components/products/ProductPricingCard.jsx
new file mode 100644
index 0000000..f8fc658
--- /dev/null
+++ b/src/modules/admin/components/products/ProductPricingCard.jsx
@@ -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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+ Allow learners to purchase {label} individually via PayPal, as an alternative to a tier plan.
+
+
+
+
+
Listing Name
+
handleChange("name", e.target.value)}
+ />
+
Defaults to the title if left blank.
+
+
+ Price *
+ handleChange("price", e.target.value)}
+ />
+
+
+ Currency
+ handleChange("currency", e.target.value.toUpperCase())}
+ />
+
+
+ Access Duration (days)
+ handleChange("access_days", e.target.value)}
+ />
+
+
+
+
+
+
Listed for Purchase
+
Show a "Buy" button to learners.
+
+
handleChange("is_active", v)}
+ />
+
+
+
+ {product && (
+
+ {saving && }
+ Remove Listing
+
+ )}
+
+ {saving && }
+
+ {product ? "Update Listing" : "Create Listing"}
+
+
+
+ );
+}
diff --git a/src/modules/admin/components/tiers/LessonPicker.jsx b/src/modules/admin/components/tiers/LessonPicker.jsx
new file mode 100644
index 0000000..31ff244
--- /dev/null
+++ b/src/modules/admin/components/tiers/LessonPicker.jsx
@@ -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 (
+
+
+ {/* ── Bundle question ──────────────────────────────────────────── */}
+ {loading ? (
+
+
+
+
+ ) : (
+
+
+ Bundle {subscription} lessons with this plan?
+
+
+
+
+ Yes, include all
+
+
+ No, choose specific
+
+
+
+ )}
+
+ {/* ── Bundle all summary ───────────────────────────────────────── */}
+ {!loading && bundleAll && total > 0 && (
+
+ All {total} {subscription} lesson{total !== 1 ? "s" : ""} will be included.
+
+ )}
+
+ {/* ── Already-assigned-elsewhere warning ──────────────────────── */}
+ {!loading && conflicts.length > 0 && (
+
+
+
+
+ {conflicts.length} lesson{conflicts.length !== 1 ? "s" : ""} already belong{conflicts.length === 1 ? "s" : ""} to another plan.
+
+
+ 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) => (
+
+ {label} ({count}){i < conflictsByPlan.length - 1 ? ", " : ""}
+
+ ))}. Uncheck them below if that's not what you want.
+
+
+
+ )}
+
+ {/* ── No lessons in tier ───────────────────────────────────────── */}
+ {!loading && total === 0 && (
+
+
+ No {subscription} lessons found. Add lessons with this subscription first.
+
+ )}
+
+ {/* ── Specific picker (Popover) ─────────────────────────────────── */}
+ {!loading && !bundleAll && total > 0 && (
+
+
+
+
+ {selectedCount === 0
+ ? "No lessons selected"
+ : `${selectedCount} of ${total} lesson${total !== 1 ? "s" : ""} selected`
+ }
+
+
+
+
+
+
+
+
+ {filtered.length === 0 ? (
+ No lessons match your search.
+ ) : (
+
+ {filtered.map((lesson) => {
+ const id = String(lesson.lesson_id);
+ const checked = selectedIds.has(id);
+ const conflict = isConflict(lesson);
+ return (
+ toggle(id)}
+ className="flex items-start gap-3 px-3 py-2.5 cursor-pointer"
+ >
+ toggle(id)}
+ className="mt-0.5 shrink-0"
+ onClick={(e) => e.stopPropagation()}
+ />
+
+
{lesson.title}
+ {lesson.description && (
+
+ {lesson.description}
+
+ )}
+ {conflict && (
+
+
+ In "{lesson.assigned_plan.label}"
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* Popover footer */}
+
+
+ {selectedCount} of {total} selected
+
+
+ 0 ? "indeterminate" : false}
+ onCheckedChange={(v) => v ? checkAll() : resetAll()}
+ />
+
+ {selectedCount === total ? "Deselect all" : "Select all"}
+
+
+
+
+
+
+
+ {selectedCount === 0 && (
+
+
+ Select at least one lesson to bundle with this plan.
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/modules/admin/components/tiers/UnitPicker.jsx b/src/modules/admin/components/tiers/UnitPicker.jsx
new file mode 100644
index 0000000..9e8941c
--- /dev/null
+++ b/src/modules/admin/components/tiers/UnitPicker.jsx
@@ -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 (
+
+
+ {/* ── Bundle question ──────────────────────────────────────────── */}
+ {loading ? (
+
+
+
+
+ ) : (
+
+
+ Bundle {subscription} units with this plan?
+
+
+
+
+ Yes, include all
+
+
+ No, choose specific
+
+
+
+ )}
+
+ {/* ── Bundle all summary ───────────────────────────────────────── */}
+ {!loading && bundleAll && total > 0 && (
+
+ All {total} {subscription} unit{total !== 1 ? "s" : ""} will be included.
+
+ )}
+
+ {/* ── Already-assigned-elsewhere warning ──────────────────────── */}
+ {!loading && conflicts.length > 0 && (
+
+
+
+
+ {conflicts.length} unit{conflicts.length !== 1 ? "s" : ""} already belong{conflicts.length === 1 ? "s" : ""} to another plan.
+
+
+ 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) => (
+
+ {label} ({count}){i < conflictsByPlan.length - 1 ? ", " : ""}
+
+ ))}. Uncheck them below if that's not what you want.
+
+
+
+ )}
+
+ {/* ── No units in tier ─────────────────────────────────────────── */}
+ {!loading && total === 0 && (
+
+
+ No {subscription} units found. Add units with this subscription first.
+
+ )}
+
+ {/* ── Specific picker (Popover) ─────────────────────────────────── */}
+ {!loading && !bundleAll && total > 0 && (
+
+
+
+
+ {selectedCount === 0
+ ? "No units selected"
+ : `${selectedCount} of ${total} unit${total !== 1 ? "s" : ""} selected`
+ }
+
+
+
+
+
+
+
+
+ {filtered.length === 0 ? (
+ No units match your search.
+ ) : (
+
+ {filtered.map((unit) => {
+ const id = String(unit.unit_id);
+ const checked = selectedIds.has(id);
+ const conflict = isConflict(unit);
+ return (
+ toggle(id)}
+ className="flex items-start gap-3 px-3 py-2.5 cursor-pointer"
+ >
+ toggle(id)}
+ className="mt-0.5 shrink-0"
+ onClick={(e) => e.stopPropagation()}
+ />
+
+
{unit.title}
+ {unit.description && (
+
+ {unit.description}
+
+ )}
+ {conflict && (
+
+
+ In "{unit.assigned_plan.label}"
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* Popover footer */}
+
+
+ {selectedCount} of {total} selected
+
+
+ 0 ? "indeterminate" : false}
+ onCheckedChange={(v) => v ? checkAll() : resetAll()}
+ />
+
+ {selectedCount === total ? "Deselect all" : "Select all"}
+
+
+
+
+
+
+
+ {selectedCount === 0 && (
+
+
+ Select at least one unit to bundle with this plan.
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/modules/admin/config/assets/rowActions.config.jsx b/src/modules/admin/config/assets/rowActions.config.jsx
index bd474a4..3fb1b32 100644
--- a/src/modules/admin/config/assets/rowActions.config.jsx
+++ b/src/modules/admin/config/assets/rowActions.config.jsx
@@ -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: ,
onClick: (row) => onView(row),
},
+ {
+ key: "preview",
+ label: "Preview",
+ icon: ,
+ onClick: (row) => onPreview(row),
+ },
{
key: "edit",
label: "Edit Info",
diff --git a/src/modules/admin/pages/assets/AddAsset.jsx b/src/modules/admin/pages/assets/AddAsset.jsx
index da5c69e..eddf936 100644
--- a/src/modules/admin/pages/assets/AddAsset.jsx
+++ b/src/modules/admin/pages/assets/AddAsset.jsx
@@ -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 }) {
{file.name}
- {(file.size / 1024).toFixed(1)} KB · {file.type}
+ {formatFileSize(file.size)} · {file.type}
{
- 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;
diff --git a/src/modules/admin/pages/assets/AddAssetsBulk.jsx b/src/modules/admin/pages/assets/AddAssetsBulk.jsx
index ab0f567..8b59b93 100644
--- a/src/modules/admin/pages/assets/AddAssetsBulk.jsx
+++ b/src/modules/admin/pages/assets/AddAssetsBulk.jsx
@@ -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 ;
}
-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 }) {
{job.name}
-
{formatSize(job.size)}
+
{formatFileSize(job.size)}
diff --git a/src/modules/admin/pages/assets/EditAsset.jsx b/src/modules/admin/pages/assets/EditAsset.jsx
index f2a4a4a..1890cfb 100644
--- a/src/modules/admin/pages/assets/EditAsset.jsx
+++ b/src/modules/admin/pages/assets/EditAsset.jsx
@@ -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
{message}
;
}
-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() {
{asset.file_type}
{asset.extension?.toUpperCase()}
-
{formatBytes(asset.file_size)}
+
{formatFileSize(asset.file_size) ?? "—"}
{asset.resolution && (
{asset.resolution}
)}
diff --git a/src/modules/admin/pages/assets/ViewAudioAsset.jsx b/src/modules/admin/pages/assets/ViewAudioAsset.jsx
index 3a9f473..f48b87e 100644
--- a/src/modules/admin/pages/assets/ViewAudioAsset.jsx
+++ b/src/modules/admin/pages/assets/ViewAudioAsset.jsx
@@ -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() {
{a.display_name ?? a.original_name}
{a.mime_type}
-
downloadAsset(a, { scope: "admin" })}>
- Download
-
@@ -93,7 +90,7 @@ export default function ViewAudioAsset() {
File Info
-
+
Storage
diff --git a/src/modules/admin/pages/assets/ViewDocumentAsset.jsx b/src/modules/admin/pages/assets/ViewDocumentAsset.jsx
index 7705c15..90e6dbe 100644
--- a/src/modules/admin/pages/assets/ViewDocumentAsset.jsx
+++ b/src/modules/admin/pages/assets/ViewDocumentAsset.jsx
@@ -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
;
@@ -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 (
@@ -62,16 +63,22 @@ export default function ViewDocumentAsset() {
{a.display_name ?? a.original_name}
{a.mime_type}
-
downloadAsset(a, { scope: "admin" })}>
- Download
-
{/* ── Document preview ── */}
- {canPreview && streamUrl ? (
+ {isPdf ? (
+
+
+
+ ) : canPreview && streamUrl ? (
-
downloadAsset(a, { scope: "admin" })}>
- Download
-
{/* ── Image preview ── */}
-
- {streamUrl ? (
-
e.preventDefault()}
- />
- ) : (
-
- )}
+
+
{/* ── Metadata panel ── */}
@@ -87,7 +79,7 @@ export default function ViewImageAsset() {
File Info
-
+
diff --git a/src/modules/admin/pages/assets/ViewVideoAsset.jsx b/src/modules/admin/pages/assets/ViewVideoAsset.jsx
index f6a10bb..77a2ef8 100644
--- a/src/modules/admin/pages/assets/ViewVideoAsset.jsx
+++ b/src/modules/admin/pages/assets/ViewVideoAsset.jsx
@@ -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
;
@@ -68,33 +67,25 @@ export default function ViewVideoAsset() {
{a.display_name ?? a.original_name}
{a.mime_type}
-
downloadAsset(a, { scope: "admin" })}>
- Download
-
{/* ── Video player ── */}
-
- {streamUrl ? (
- e.preventDefault()}
- className="w-full h-full"
- poster={a.thumbnail_url ?? undefined}
- >
-
- Your browser does not support the video tag.
-
- ) : (
-
- )}
-
+
+
{}}
+ 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() {
Video Info
-
+
diff --git a/src/modules/admin/pages/library/lessons/AddLibraryLesson.jsx b/src/modules/admin/pages/library/lessons/AddLibraryLesson.jsx
index f535804..90f5f94 100644
--- a/src/modules/admin/pages/library/lessons/AddLibraryLesson.jsx
+++ b/src/modules/admin/pages/library/lessons/AddLibraryLesson.jsx
@@ -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 (
@@ -71,6 +78,29 @@ function StepLesson({ register, errors }) {
Description
+
+
+
Subscription
+
setValue("subscription", val === "__open" ? "" : val, { shouldDirty: true })}
+ >
+
+
+
+
+ No tier gate (open)
+ {tierCategories.map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+
+ Optional. Gates this lesson directly, independent of any unit it may later be attached to.
+
+
);
}
@@ -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 (
@@ -193,6 +225,7 @@ function StepReview({ data, attachUnitId, requirements }) {
+
{attachUnitId &&
}
@@ -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() {
{STEPS[step].label}
{step === 0 && (
-
+
)}
{step === 1 && (
@@ -345,7 +386,7 @@ export default function AddLibraryLesson() {
/>
)}
{step === 3 && (
-
+
)}
diff --git a/src/modules/admin/pages/library/lessons/EditLibraryLesson.jsx b/src/modules/admin/pages/library/lessons/EditLibraryLesson.jsx
index 0564369..7814284 100644
--- a/src/modules/admin/pages/library/lessons/EditLibraryLesson.jsx
+++ b/src/modules/admin/pages/library/lessons/EditLibraryLesson.jsx
@@ -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() {
+
+
Subscription
+
setValue("subscription", val === "__open" ? "" : val, { shouldDirty: true })}
+ >
+
+
+
+
+ No tier gate (open)
+ {tierCategories.map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+
+ Optional. Gates this lesson directly, independent of any unit it may later be attached to.
+
+
+
@@ -115,6 +153,20 @@ export default function EditLibraryLesson() {
args={[null, null, lessonId]}
/>
+
+
+
+
Pricing
+
Optional individual-purchase listing for this lesson.
+
+
+
diff --git a/src/modules/admin/pages/library/lessons/ViewLibraryLesson.jsx b/src/modules/admin/pages/library/lessons/ViewLibraryLesson.jsx
index b575c12..2c45293 100644
--- a/src/modules/admin/pages/library/lessons/ViewLibraryLesson.jsx
+++ b/src/modules/admin/pages/library/lessons/ViewLibraryLesson.jsx
@@ -75,7 +75,7 @@ export default function ViewLibraryLesson() {