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