From a34c6feb84cdf792bc6f2ae4e0b4cd734b5757bd Mon Sep 17 00:00:00 2001 From: rgrgogu Date: Sat, 1 Aug 2026 17:44:12 +0800 Subject: [PATCH] assets and tier plans revamp --- .../generic/Blocks/Admin/AudioBlock.jsx | 37 ++- .../generic/Blocks/Admin/VideoBlock.jsx | 73 +++-- .../generic/Blocks/Client/AudioBlock.jsx | 10 +- .../generic/Blocks/Client/VideoBlock.jsx | 10 +- .../generic}/FileZoomViewer.jsx | 37 ++- .../generic/TranscodeStatusBanner.jsx | 22 ++ src/contexts/AdminLibraryContext.jsx | 50 +++ src/contexts/ClientCoursesContext.jsx | 32 +- src/contexts/ClientLibraryContext.jsx | 31 ++ src/hooks/usePurchaseCheckout.js | 71 +++++ src/hooks/usePurchases.js | 43 +++ .../components/assets/AssetPreviewDialog.jsx | 114 +++++++ .../admin/components/assets/AssetsTable.jsx | 10 + .../products/ProductPricingCard.jsx | 173 +++++++++++ .../admin/components/tiers/LessonPicker.jsx | 294 ++++++++++++++++++ .../admin/components/tiers/UnitPicker.jsx | 294 ++++++++++++++++++ .../admin/config/assets/rowActions.config.jsx | 11 +- src/modules/admin/pages/assets/AddAsset.jsx | 9 +- .../admin/pages/assets/AddAssetsBulk.jsx | 9 +- src/modules/admin/pages/assets/EditAsset.jsx | 9 +- .../admin/pages/assets/ViewAudioAsset.jsx | 9 +- .../admin/pages/assets/ViewDocumentAsset.jsx | 29 +- .../admin/pages/assets/ViewImageAsset.jsx | 32 +- .../admin/pages/assets/ViewVideoAsset.jsx | 45 ++- .../library/lessons/AddLibraryLesson.jsx | 55 +++- .../library/lessons/EditLibraryLesson.jsx | 66 +++- .../library/lessons/ViewLibraryLesson.jsx | 8 +- .../pages/library/units/EditLibraryUnit.jsx | 17 +- .../pages/library/units/ViewLibraryUnit.jsx | 8 +- src/modules/admin/pages/tiers/AddPlan.jsx | 55 +++- src/modules/admin/pages/tiers/EditPlan.jsx | 83 ++++- src/modules/admin/pages/tiers/ViewPlan.jsx | 84 ++++- src/modules/client/components/FilePreview.jsx | 6 +- .../client/components/LessonUpsellModal.jsx | 42 ++- .../client/components/LockedContentPanel.jsx | 31 +- .../client/components/UnitUpsellModal.jsx | 42 ++- src/modules/client/pages/Checkout.jsx | 35 ++- src/modules/client/pages/LessonCheckout.jsx | 181 +++++++++++ src/modules/client/pages/LessonDetails.jsx | 9 +- src/modules/client/pages/PlanList.jsx | 11 + src/modules/client/pages/UnitCheckout.jsx | 181 +++++++++++ src/modules/client/pages/UnitDetails.jsx | 9 +- src/modules/client/routes/ClientRoutes.jsx | 11 +- src/utils/assetUpload.util.js | 29 +- src/utils/format.util.js | 28 ++ 45 files changed, 2172 insertions(+), 273 deletions(-) rename src/{modules/client/components => components/generic}/FileZoomViewer.jsx (90%) create mode 100644 src/components/generic/TranscodeStatusBanner.jsx create mode 100644 src/hooks/usePurchaseCheckout.js create mode 100644 src/hooks/usePurchases.js create mode 100644 src/modules/admin/components/assets/AssetPreviewDialog.jsx create mode 100644 src/modules/admin/components/products/ProductPricingCard.jsx create mode 100644 src/modules/admin/components/tiers/LessonPicker.jsx create mode 100644 src/modules/admin/components/tiers/UnitPicker.jsx create mode 100644 src/modules/client/pages/LessonCheckout.jsx create mode 100644 src/modules/client/pages/UnitCheckout.jsx create mode 100644 src/utils/format.util.js diff --git a/src/components/generic/Blocks/Admin/AudioBlock.jsx b/src/components/generic/Blocks/Admin/AudioBlock.jsx index 903c488..82b5e4e 100644 --- a/src/components/generic/Blocks/Admin/AudioBlock.jsx +++ b/src/components/generic/Blocks/Admin/AudioBlock.jsx @@ -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 }) { )}
-
- {thumbnail ? ( +
+ {mediaLoading ? ( + + ) : thumbnail ? ( {title} ) : ( -
- -
+ )}
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 */} -
- -
+ +
+ )} {/* 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 }) => { /> {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. +

+ +
+
+ + handleChange("name", e.target.value)} + /> +

Defaults to the title if left blank.

+
+
+ + handleChange("price", e.target.value)} + /> +
+
+ + handleChange("currency", e.target.value.toUpperCase())} + /> +
+
+ + handleChange("access_days", e.target.value)} + /> +
+
+ +
+
+ +

Show a "Buy" button to learners.

+
+ handleChange("is_active", v)} + /> +
+ +
+ {product && ( + + )} + +
+
+ ); +} 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? +

+
+ + +
+
+ )} + + {/* ── 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 && ( +
+ + + + + + + + + + {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? +

+
+ + +
+
+ )} + + {/* ── 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 && ( +
+ + + + + + + + + + {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}

@@ -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}

-
{/* ── Document preview ── */}
- {canPreview && streamUrl ? ( + {isPdf ? ( +
+ +
+ ) : canPreview && streamUrl ? (