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 { AssetPickerSheet } from "../../AssetPickerSheet";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
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(/\/$/, "");
|
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];
|
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||||
|
|
||||||
// ─── AudioBlock (Admin) ───────────────────────────────────────────────────────
|
// ─── AudioBlock (Admin) ───────────────────────────────────────────────────────
|
||||||
@@ -45,6 +38,10 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
const [volume, setVolume] = useState(1);
|
const [volume, setVolume] = useState(1);
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
const [speedIdx, setSpeedIdx] = useState(2); // 1×
|
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 assetId = content.asset_id ?? null;
|
||||||
const storageProvider = content.storage_provider ?? null;
|
const storageProvider = content.storage_provider ?? null;
|
||||||
@@ -79,6 +76,10 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [assetId, isS3]);
|
}, [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 ──────────────────────────────────────────────────────────
|
// ── Audio events ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const onTimeUpdate = useCallback(() => setCurrentTime(audioRef.current?.currentTime ?? 0), []);
|
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);
|
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 ──────────────────────────────────────────────────────────────
|
// ── Controls ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -186,6 +191,10 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
onLoadedMetadata={onLoadedMeta}
|
onLoadedMetadata={onLoadedMeta}
|
||||||
onEnded={onEnded}
|
onEnded={onEnded}
|
||||||
onProgress={onProgress}
|
onProgress={onProgress}
|
||||||
|
onLoadedData={onLoadedData}
|
||||||
|
onCanPlay={onCanPlay}
|
||||||
|
onWaiting={onWaiting}
|
||||||
|
onPlaying={onPlaying}
|
||||||
preload="metadata"
|
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="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">
|
<div className="shrink-0 w-20 h-20 rounded-md overflow-hidden bg-black/25 flex items-center justify-center">
|
||||||
{thumbnail ? (
|
{mediaLoading ? (
|
||||||
|
<Spinner className="size-6 text-white" />
|
||||||
|
) : thumbnail ? (
|
||||||
<img src={thumbnail} alt={title} className="w-full h-full object-cover" />
|
<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" />
|
||||||
<Music2 className="w-7 h-7 text-white/30" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
<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 { AssetPickerSheet } from "../../AssetPickerSheet";
|
||||||
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
|
||||||
|
|
||||||
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}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── VideoBlock (Admin) ───────────────────────────────────────────────────────
|
// ─── VideoBlock (Admin) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -44,6 +37,13 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
const [volume, setVolume] = useState(1);
|
const [volume, setVolume] = useState(1);
|
||||||
const [overlayVisible,setOverlayVisible]= useState(true);
|
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
|
// Reset player when video changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,6 +52,7 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
setTotalDuration(0);
|
setTotalDuration(0);
|
||||||
setOverlayVisible(true);
|
setOverlayVisible(true);
|
||||||
|
setMediaLoading(true);
|
||||||
}, [src]);
|
}, [src]);
|
||||||
|
|
||||||
// ── Video event listeners ─────────────────────────────────────────────────
|
// ── Video event listeners ─────────────────────────────────────────────────
|
||||||
@@ -64,17 +65,29 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
setCurrentTime(v.currentTime);
|
setCurrentTime(v.currentTime);
|
||||||
if (v.duration) setProgress((v.currentTime / v.duration) * 100);
|
if (v.duration) setProgress((v.currentTime / v.duration) * 100);
|
||||||
};
|
};
|
||||||
const onLoaded = () => setTotalDuration(v.duration);
|
const onLoaded = () => setTotalDuration(v.duration);
|
||||||
const onEnded = () => { setPlaying(false); setOverlayVisible(true); };
|
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("timeupdate", onTimeUpdate);
|
||||||
v.addEventListener("loadedmetadata", onLoaded);
|
v.addEventListener("loadedmetadata", onLoaded);
|
||||||
v.addEventListener("ended", onEnded);
|
v.addEventListener("ended", onEnded);
|
||||||
|
v.addEventListener("loadeddata", onLoadedData);
|
||||||
|
v.addEventListener("canplay", onCanPlay);
|
||||||
|
v.addEventListener("waiting", onWaiting);
|
||||||
|
v.addEventListener("playing", onPlaying);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
v.removeEventListener("timeupdate", onTimeUpdate);
|
v.removeEventListener("timeupdate", onTimeUpdate);
|
||||||
v.removeEventListener("loadedmetadata", onLoaded);
|
v.removeEventListener("loadedmetadata", onLoaded);
|
||||||
v.removeEventListener("ended", onEnded);
|
v.removeEventListener("ended", onEnded);
|
||||||
|
v.removeEventListener("loadeddata", onLoadedData);
|
||||||
|
v.removeEventListener("canplay", onCanPlay);
|
||||||
|
v.removeEventListener("waiting", onWaiting);
|
||||||
|
v.removeEventListener("playing", onPlaying);
|
||||||
};
|
};
|
||||||
}, [src]);
|
}, [src]);
|
||||||
|
|
||||||
@@ -180,22 +193,32 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
className="w-full h-full object-cover"
|
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 */}
|
{/* Play/pause overlay */}
|
||||||
<div
|
{!mediaLoading && (
|
||||||
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${overlayVisible ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
<div
|
||||||
style={{ background: "rgba(0,0,0,0.3)" }}
|
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"
|
|
||||||
>
|
>
|
||||||
{playing
|
<button
|
||||||
? <Pause className="size-4 text-black" />
|
aria-label={playing ? "Pause" : "Play"}
|
||||||
: <Play className="size-4 text-black ml-0.5" />
|
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"
|
||||||
</button>
|
>
|
||||||
</div>
|
{playing
|
||||||
|
? <Pause className="size-4 text-black" />
|
||||||
|
: <Play className="size-4 text-black ml-0.5" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Change video hover hint */}
|
{/* Change video hover hint */}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
|
|||||||
@@ -3,15 +3,7 @@ import { Music2, RotateCcw, RotateCw, Play, Pause, VolumeOff, Volume2 } from "lu
|
|||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||||
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
|
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
|
||||||
|
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
|
||||||
// ─── 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];
|
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 api from "@/utils/api.util";
|
||||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||||
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
|
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
|
||||||
|
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
|
||||||
// ─── 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 PLAYBACK_SPEEDS = ["0.5", "0.75", "Normal", "1.25", "1.5", "2"];
|
const PLAYBACK_SPEEDS = ["0.5", "0.75", "Normal", "1.25", "1.5", "2"];
|
||||||
|
|
||||||
|
|||||||
+21
-14
@@ -1,7 +1,11 @@
|
|||||||
/***********************************************************************************************************************************************************************
|
/***********************************************************************************************************************************************************************
|
||||||
* File Name : FileZoomViewer.jsx
|
* File Name : FileZoomViewer.jsx
|
||||||
* Type : Component (Client)
|
* Type : Component (Generic)
|
||||||
* Description : Zoom/pan/fit viewer for image and PDF files.
|
* 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:
|
* Supported:
|
||||||
* image/jpeg, image/png → <img> with scroll-zoom + drag-pan
|
* image/jpeg, image/png → <img> with scroll-zoom + drag-pan
|
||||||
@@ -10,13 +14,15 @@
|
|||||||
* with page navigation for multi-page PDFs
|
* with page navigation for multi-page PDFs
|
||||||
*
|
*
|
||||||
* Not supported (shows a message instead of attempting render):
|
* 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:
|
* Props:
|
||||||
* blobUrl {string} – object URL (from FilePreview's useBlobUrl)
|
* src {string} – object URL or direct stream URL
|
||||||
* mimeType {string}
|
* mimeType {string}
|
||||||
* fileName {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 { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -154,7 +160,7 @@ const ZoomPanArea = ({ scale, setScale, offset, setOffset, fitFn, children }) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ─── Image viewer ───────────────────────────────────────────────────────────────
|
// ─── Image viewer ───────────────────────────────────────────────────────────────
|
||||||
const ImageViewer = ({ blobUrl, fileName }) => {
|
const ImageViewer = ({ src, fileName }) => {
|
||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
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}>
|
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit}>
|
||||||
<img
|
<img
|
||||||
src={blobUrl}
|
src={src}
|
||||||
alt={fileName}
|
alt={fileName}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
className="max-h-[380px] max-w-none select-none pointer-events-none"
|
className="max-h-[380px] max-w-none select-none pointer-events-none"
|
||||||
/>
|
/>
|
||||||
</ZoomPanArea>
|
</ZoomPanArea>
|
||||||
@@ -185,7 +192,7 @@ const ImageViewer = ({ blobUrl, fileName }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ─── PDF viewer (pdf.js → canvas) ───────────────────────────────────────────────
|
// ─── PDF viewer (pdf.js → canvas) ───────────────────────────────────────────────
|
||||||
const PdfViewer = ({ blobUrl, fileName }) => {
|
const PdfViewer = ({ src, fileName }) => {
|
||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
const [pdfDoc, setPdfDoc] = useState(null);
|
const [pdfDoc, setPdfDoc] = useState(null);
|
||||||
@@ -207,7 +214,7 @@ const PdfViewer = ({ blobUrl, fileName }) => {
|
|||||||
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||||
new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
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;
|
if (cancelled) return;
|
||||||
setPdfDoc(doc);
|
setPdfDoc(doc);
|
||||||
setNumPages(doc.numPages);
|
setNumPages(doc.numPages);
|
||||||
@@ -217,7 +224,7 @@ const PdfViewer = ({ blobUrl, fileName }) => {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [blobUrl]);
|
}, [src]);
|
||||||
|
|
||||||
// ── Render current page to canvas ──────────────────────────────────────────
|
// ── Render current page to canvas ──────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -274,7 +281,7 @@ const PdfViewer = ({ blobUrl, fileName }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ─── Main viewer ────────────────────────────────────────────────────────────────
|
// ─── Main viewer ────────────────────────────────────────────────────────────────
|
||||||
const FileZoomViewer = ({ blobUrl, mimeType, fileName, loading }) => {
|
const FileZoomViewer = ({ src, mimeType, fileName, loading }) => {
|
||||||
const mode = resolveMode(mimeType, fileName);
|
const mode = resolveMode(mimeType, fileName);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -285,12 +292,12 @@ const FileZoomViewer = ({ blobUrl, mimeType, fileName, loading }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!blobUrl || mode === 'unsupported') {
|
if (!src || mode === 'unsupported') {
|
||||||
return <UnsupportedMessage fileName={fileName} />;
|
return <UnsupportedMessage fileName={fileName} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'image') return <ImageViewer blobUrl={blobUrl} fileName={fileName} />;
|
if (mode === 'image') return <ImageViewer src={src} fileName={fileName} />;
|
||||||
if (mode === 'pdf') return <PdfViewer blobUrl={blobUrl} fileName={fileName} />;
|
if (mode === 'pdf') return <PdfViewer src={src} fileName={fileName} />;
|
||||||
|
|
||||||
return <UnsupportedMessage fileName={fileName} />;
|
return <UnsupportedMessage fileName={fileName} />;
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// components/generic/TranscodeStatusBanner.jsx
|
||||||
|
//
|
||||||
|
// Small inline notice for a video asset's background remux (see backend
|
||||||
|
// services/assetTranscode.service.js) — .mov/.mkv uploads get repackaged
|
||||||
|
// into a faststart .mp4 for fast in-browser playback. Non-blocking: the
|
||||||
|
// asset still plays from its original (slower) file while this is pending/
|
||||||
|
// processing, this banner is just a heads-up. Renders nothing once the
|
||||||
|
// asset is "done"/"none" (fast already) — "failed" also renders nothing,
|
||||||
|
// the asset just quietly keeps playing from the original.
|
||||||
|
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export function TranscodeStatusBanner({ status }) {
|
||||||
|
if (status !== "pending" && status !== "processing") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground bg-muted/40 border-b">
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
Optimizing this video for faster playback — still watchable now, will load quicker shortly.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -416,6 +416,54 @@ export function LibraryProvider({ children }) {
|
|||||||
[request],
|
[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 ────────────────────────────────────────────────────────────────
|
// ─── Value ────────────────────────────────────────────────────────────────
|
||||||
const value = {
|
const value = {
|
||||||
// shared table state
|
// shared table state
|
||||||
@@ -430,6 +478,7 @@ export function LibraryProvider({ children }) {
|
|||||||
fetchUnitArchiveImpact, fetchUnitPermanentDeleteImpact,
|
fetchUnitArchiveImpact, fetchUnitPermanentDeleteImpact,
|
||||||
fetchUnitFieldValues,
|
fetchUnitFieldValues,
|
||||||
attachLessonsToUnit, detachLessonFromUnit, reorderUnitLessons,
|
attachLessonsToUnit, detachLessonFromUnit, reorderUnitLessons,
|
||||||
|
fetchUnitProduct, saveUnitProduct, removeUnitProduct,
|
||||||
|
|
||||||
// lesson library
|
// lesson library
|
||||||
lessons, lesson, lessonsFlat,
|
lessons, lesson, lessonsFlat,
|
||||||
@@ -439,6 +488,7 @@ export function LibraryProvider({ children }) {
|
|||||||
permanentlyDeleteLesson, permanentlyDeleteLessons,
|
permanentlyDeleteLesson, permanentlyDeleteLessons,
|
||||||
fetchLessonPermanentDeleteImpact,
|
fetchLessonPermanentDeleteImpact,
|
||||||
fetchLessonFieldValues,
|
fetchLessonFieldValues,
|
||||||
|
fetchLessonProduct, saveLessonProduct, removeLessonProduct,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <LibraryContext.Provider value={value}>{children}</LibraryContext.Provider>;
|
return <LibraryContext.Provider value={value}>{children}</LibraryContext.Provider>;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, useCallback, useContext, useState } from "react";
|
import { createContext, useCallback, useContext, useState } from "react";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { usePurchases } from "@/hooks/usePurchases";
|
||||||
|
|
||||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ export function ClientCoursesProvider({ children }) {
|
|||||||
|
|
||||||
const [purchases, setPurchases] = useState([]);
|
const [purchases, setPurchases] = useState([]);
|
||||||
const [purchasesLoading, setPurchasesLoading] = useState(false);
|
const [purchasesLoading, setPurchasesLoading] = useState(false);
|
||||||
const [purchaseLoading, setPurchaseLoading] = useState(false);
|
const { purchaseLoading, createOrder: createCourseOrder, captureOrder: captureCourseOrder, cancelOrder: cancelCourseOrder } = usePurchases();
|
||||||
|
|
||||||
const getMyPurchases = useCallback(async () => {
|
const getMyPurchases = useCallback(async () => {
|
||||||
setPurchasesLoading(true);
|
setPurchasesLoading(true);
|
||||||
@@ -188,35 +189,6 @@ export function ClientCoursesProvider({ children }) {
|
|||||||
} finally { setPurchasesLoading(false); }
|
} 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 ──────────────────────────────────────────────────────────
|
// ─── Reset helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const resetCourse = useCallback(() => { setCourse(null); setCourseBlocked(false); }, []);
|
const resetCourse = useCallback(() => { setCourse(null); setCourseBlocked(false); }, []);
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ export function ClientLibraryProvider({ children }) {
|
|||||||
const [quiz, setQuiz] = useState(null);
|
const [quiz, setQuiz] = useState(null);
|
||||||
const [quizLoading, setQuizLoading] = useState(false);
|
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 ────────────────────────────────────────────────────────────
|
// ─── Actions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const getUnits = useCallback(async () => {
|
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 ─────────────────────────────────────────────────────────────
|
// ─── Resets ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const resetUnitDetail = useCallback(() => {
|
const resetUnitDetail = useCallback(() => {
|
||||||
@@ -231,6 +257,7 @@ export function ClientLibraryProvider({ children }) {
|
|||||||
}, []);
|
}, []);
|
||||||
const resetLesson = useCallback(() => setLesson(null), []);
|
const resetLesson = useCallback(() => setLesson(null), []);
|
||||||
const resetQuiz = useCallback(() => setQuiz(null), []);
|
const resetQuiz = useCallback(() => setQuiz(null), []);
|
||||||
|
const resetCheckoutInfo = useCallback(() => setCheckoutInfo(null), []);
|
||||||
|
|
||||||
// ─── Value ──────────────────────────────────────────────────────────────
|
// ─── Value ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -240,6 +267,7 @@ export function ClientLibraryProvider({ children }) {
|
|||||||
unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo,
|
unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo,
|
||||||
lesson, lessonLoading,
|
lesson, lessonLoading,
|
||||||
quiz, quizLoading,
|
quiz, quizLoading,
|
||||||
|
checkoutInfo, checkoutInfoLoading,
|
||||||
|
|
||||||
getUnits,
|
getUnits,
|
||||||
getLessons,
|
getLessons,
|
||||||
@@ -251,10 +279,13 @@ export function ClientLibraryProvider({ children }) {
|
|||||||
upsertLessonProgress,
|
upsertLessonProgress,
|
||||||
upsertWatchProgress,
|
upsertWatchProgress,
|
||||||
markComplete,
|
markComplete,
|
||||||
|
getUnitCheckoutInfo,
|
||||||
|
getLessonCheckoutInfo,
|
||||||
|
|
||||||
resetUnitDetail,
|
resetUnitDetail,
|
||||||
resetLesson,
|
resetLesson,
|
||||||
resetQuiz,
|
resetQuiz,
|
||||||
|
resetCheckoutInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
||||||
|
import { AssetPreviewDialog } from "./AssetPreviewDialog";
|
||||||
|
|
||||||
import { buildDataColumns, columnPinning } from "../../config/assets/columns.config";
|
import { buildDataColumns, columnPinning } from "../../config/assets/columns.config";
|
||||||
import { buildToolbarActions } from "../../config/assets/toolbar.config";
|
import { buildToolbarActions } from "../../config/assets/toolbar.config";
|
||||||
@@ -21,6 +22,7 @@ import { formatGeneratedBy } from "@/utils/generatedBy.util";
|
|||||||
export default function AssetsTable() {
|
export default function AssetsTable() {
|
||||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||||
const [archiveIds, setArchiveIds] = useState(null);
|
const [archiveIds, setArchiveIds] = useState(null);
|
||||||
|
const [previewTarget, setPreviewTarget] = useState(null);
|
||||||
|
|
||||||
const tableRefsRef = useRef({
|
const tableRefsRef = useRef({
|
||||||
getFilters: () => [],
|
getFilters: () => [],
|
||||||
@@ -62,6 +64,7 @@ export default function AssetsTable() {
|
|||||||
|
|
||||||
const rowActions = buildRowActions({
|
const rowActions = buildRowActions({
|
||||||
onView: (row) => navigate(resolveViewPath(row)),
|
onView: (row) => navigate(resolveViewPath(row)),
|
||||||
|
onPreview: (row) => setPreviewTarget(row),
|
||||||
onEdit: (row) => navigate(`edit/${row.asset_id}`),
|
onEdit: (row) => navigate(`edit/${row.asset_id}`),
|
||||||
onArchive: (row) => setArchiveTarget(row),
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
});
|
});
|
||||||
@@ -144,6 +147,13 @@ export default function AssetsTable() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleArchiveSuccess}
|
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
|
// 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 {Object} deps
|
||||||
* @param {Function} deps.onView (row) → void — navigate to view page
|
* @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.onEdit (row) → void — navigate to edit page
|
||||||
* @param {Function} deps.onArchive (row) → void — open archive dialog
|
* @param {Function} deps.onArchive (row) → void — open archive dialog
|
||||||
*/
|
*/
|
||||||
export function buildRowActions({ onView, onEdit, onArchive }) {
|
export function buildRowActions({ onView, onPreview, onEdit, onArchive }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "view",
|
key: "view",
|
||||||
@@ -16,6 +17,12 @@ export function buildRowActions({ onView, onEdit, onArchive }) {
|
|||||||
icon: <Eye className="h-3.5 w-3.5" />,
|
icon: <Eye className="h-3.5 w-3.5" />,
|
||||||
onClick: (row) => onView(row),
|
onClick: (row) => onView(row),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "preview",
|
||||||
|
label: "Preview",
|
||||||
|
icon: <ScanEye className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (row) => onPreview(row),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "edit",
|
key: "edit",
|
||||||
label: "Edit Info",
|
label: "Edit Info",
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { ArrowLeft, ArrowRight, UploadCloud, X, FileVideo, FileText, Image, File
|
|||||||
|
|
||||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
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 { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium truncate">{file.name}</p>
|
<p className="text-sm font-medium truncate">{file.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{(file.size / 1024).toFixed(1)} KB · {file.type}
|
{formatFileSize(file.size)} · {file.type}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -157,8 +158,8 @@ export default function AddAsset() {
|
|||||||
const fileType = file ? resolveFileType(file.type) : null;
|
const fileType = file ? resolveFileType(file.type) : null;
|
||||||
|
|
||||||
const setFile = (f) => {
|
const setFile = (f) => {
|
||||||
if (f.size > MAX_ASSET_FILE_SIZE) {
|
if (f.size > MAX_ASSET_FILE_SIZE_SINGLE) {
|
||||||
setError("_file", { message: `File exceeds the ${MAX_ASSET_FILE_SIZE_LABEL} size limit.` });
|
setError("_file", { message: `File exceeds the ${MAX_ASSET_FILE_SIZE_SINGLE_LABEL} size limit.` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fileRef.current = f;
|
fileRef.current = f;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
import { useUploadQueue } from "@/contexts/UploadQueueContext";
|
import { useUploadQueue } from "@/contexts/UploadQueueContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { formatFileSize } from "@/utils/format.util";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import {
|
import {
|
||||||
@@ -29,12 +30,6 @@ function FileTypeIcon({ mime = "" }) {
|
|||||||
return <FileText className="h-8 w-8 text-orange-400 shrink-0" />;
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Drop zone ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DropZone({ onFiles }) {
|
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-1 min-w-0 space-y-1.5">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-sm font-medium truncate">{job.name}</p>
|
<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>
|
</div>
|
||||||
<Progress value={job.progress} className={barClass} />
|
<Progress value={job.progress} className={barClass} />
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<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 { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||||
|
import { formatFileSize } from "@/utils/format.util";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -46,12 +47,6 @@ function FieldError({ message }) {
|
|||||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
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 ──────────────────────────────────────────────────────
|
// ─── Thumbnail Drop Zone ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -249,7 +244,7 @@ export default function EditAsset() {
|
|||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Badge variant="secondary" className="text-xs">{asset.file_type}</Badge>
|
<Badge variant="secondary" className="text-xs">{asset.file_type}</Badge>
|
||||||
<Badge variant="outline" className="text-xs">{asset.extension?.toUpperCase()}</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 && (
|
{asset.resolution && (
|
||||||
<span className="text-xs text-muted-foreground">{asset.resolution}</span>
|
<span className="text-xs text-muted-foreground">{asset.resolution}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// modules/admin/pages/assets/ViewAudioAsset.jsx
|
// modules/admin/pages/assets/ViewAudioAsset.jsx
|
||||||
|
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
||||||
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||||
import { downloadAsset } from "@/utils/media.util";
|
import { formatFileSize } from "@/utils/format.util";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
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>
|
<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>
|
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => downloadAsset(a, { scope: "admin" })}>
|
|
||||||
<Download className="h-4 w-4" /> Download
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
<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>
|
<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="Original Name" value={a.original_name} />
|
||||||
<MetaRow label="Extension" value={a.extension} />
|
<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} />
|
<MetaRow label="MIME Type" value={a.mime_type} />
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
|
<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
|
// modules/admin/pages/assets/ViewDocumentAsset.jsx
|
||||||
|
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
||||||
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||||
import { downloadAsset } from "@/utils/media.util";
|
import { formatFileSize } from "@/utils/format.util";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
import FileZoomViewer from "@/components/generic/FileZoomViewer";
|
||||||
import AssetPageLoader from "@/components/generic/AssetLoader";
|
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||||
|
|
||||||
function MetaRow({ label, value }) {
|
function MetaRow({ label, value }) {
|
||||||
@@ -32,7 +31,7 @@ export default function ViewDocumentAsset() {
|
|||||||
const { fmtDateTime } = useDateFormat();
|
const { fmtDateTime } = useDateFormat();
|
||||||
|
|
||||||
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
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) {
|
if (loading) {
|
||||||
return <AssetPageLoader />;
|
return <AssetPageLoader />;
|
||||||
@@ -48,7 +47,9 @@ export default function ViewDocumentAsset() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const a = selectedAsset;
|
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 (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
<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>
|
<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>
|
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => downloadAsset(a, { scope: "admin" })}>
|
|
||||||
<Download className="h-4 w-4" /> Download
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
|
||||||
{/* ── Document preview ── */}
|
{/* ── Document preview ── */}
|
||||||
<div className="lg:col-span-3">
|
<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 }}>
|
<div className="rounded-lg border overflow-hidden bg-white" style={{ height: 600 }}>
|
||||||
<iframe
|
<iframe
|
||||||
src={streamUrl}
|
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>
|
<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="Original Name" value={a.original_name} />
|
||||||
<MetaRow label="Extension" value={a.extension} />
|
<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} />
|
<MetaRow label="MIME Type" value={a.mime_type} />
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
|
<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
|
// modules/admin/pages/assets/ViewImageAsset.jsx
|
||||||
|
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
||||||
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||||
import { downloadAsset } from "@/utils/media.util";
|
import { formatFileSize } from "@/utils/format.util";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
import FileZoomViewer from "@/components/generic/FileZoomViewer";
|
||||||
import AssetPageLoader from "@/components/generic/AssetLoader";
|
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||||
|
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ export default function ViewImageAsset() {
|
|||||||
const { fmtDateTime } = useDateFormat();
|
const { fmtDateTime } = useDateFormat();
|
||||||
|
|
||||||
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
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) {
|
if (loading) {
|
||||||
return <AssetPageLoader />;
|
return <AssetPageLoader />;
|
||||||
@@ -59,26 +59,18 @@ export default function ViewImageAsset() {
|
|||||||
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
<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>
|
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => downloadAsset(a, { scope: "admin" })}>
|
|
||||||
<Download className="h-4 w-4" /> Download
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
|
||||||
{/* ── Image preview ── */}
|
{/* ── Image preview ── */}
|
||||||
<div className="lg:col-span-3 rounded-lg border bg-muted/30 overflow-hidden flex items-center justify-center min-h-64">
|
<div className="lg:col-span-3 rounded-lg border overflow-hidden">
|
||||||
{streamUrl ? (
|
<FileZoomViewer
|
||||||
<img
|
src={streamUrl}
|
||||||
src={streamUrl}
|
mimeType={a.mime_type}
|
||||||
alt={a.display_name ?? a.original_name}
|
fileName={a.display_name ?? a.original_name}
|
||||||
className="max-w-full max-h-[520px] object-contain"
|
loading={previewLoading}
|
||||||
draggable={false}
|
/>
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<MediaFallback className="size-full" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Metadata panel ── */}
|
{/* ── 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>
|
<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="Original Name" value={a.original_name} />
|
||||||
<MetaRow label="Extension" value={a.extension} />
|
<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="Resolution" value={a.resolution} />
|
||||||
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
|
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
// modules/admin/pages/assets/ViewVideoAsset.jsx
|
// modules/admin/pages/assets/ViewVideoAsset.jsx
|
||||||
|
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
|
||||||
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
import { formatFileSize } from "@/utils/format.util";
|
||||||
import { downloadAsset } from "@/utils/media.util";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
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";
|
import AssetPageLoader from "@/components/generic/AssetLoader";
|
||||||
|
|
||||||
function MetaRow({ label, value }) {
|
function MetaRow({ label, value }) {
|
||||||
@@ -39,7 +39,6 @@ export default function ViewVideoAsset() {
|
|||||||
const { fmtDateTime } = useDateFormat();
|
const { fmtDateTime } = useDateFormat();
|
||||||
|
|
||||||
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
|
||||||
const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <AssetPageLoader />;
|
return <AssetPageLoader />;
|
||||||
@@ -68,33 +67,25 @@ export default function ViewVideoAsset() {
|
|||||||
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
<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>
|
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => downloadAsset(a, { scope: "admin" })}>
|
|
||||||
<Download className="h-4 w-4" /> Download
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
|
||||||
{/* ── Video player ── */}
|
{/* ── Video player ── */}
|
||||||
<div className="lg:col-span-3 space-y-3">
|
<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">
|
<TranscodeStatusBanner status={a.transcode_status} />
|
||||||
{streamUrl ? (
|
<VideoBlock
|
||||||
<video
|
readOnly
|
||||||
key={streamUrl}
|
onUpdate={() => {}}
|
||||||
controls
|
content={{
|
||||||
controlsList="nodownload"
|
asset_id: a.asset_id,
|
||||||
disablePictureInPicture
|
storage_provider: a.storage_provider,
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
url: a.file_url,
|
||||||
className="w-full h-full"
|
thumbnail_url: a.thumbnail_url,
|
||||||
poster={a.thumbnail_url ?? undefined}
|
title: a.display_name ?? a.original_name,
|
||||||
>
|
tag: a.extension?.toUpperCase() ?? "",
|
||||||
<source src={streamUrl} type={a.mime_type ?? "video/mp4"} />
|
}}
|
||||||
Your browser does not support the video tag.
|
/>
|
||||||
</video>
|
|
||||||
) : (
|
|
||||||
<MediaFallback className="size-full" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thumbnail strip */}
|
{/* Thumbnail strip */}
|
||||||
{a.thumbnail_url && (
|
{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>
|
<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="Original Name" value={a.original_name} />
|
||||||
<MetaRow label="Extension" value={a.extension} />
|
<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="Resolution" value={a.resolution} />
|
||||||
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
|
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
|
||||||
<MetaRow label="Duration" value={formatDuration(a.duration)} />
|
<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 { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useForm, useWatch } from "react-hook-form";
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -14,12 +14,16 @@ import { useCourses } from "@/contexts/AdminCoursesContext";
|
|||||||
import DraftRequirementsEditor, { DraftRequirementsSummary } from "@/modules/admin/components/courses/DraftRequirementsEditor";
|
import DraftRequirementsEditor, { DraftRequirementsSummary } from "@/modules/admin/components/courses/DraftRequirementsEditor";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
|
import api from "@/utils/api.util";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription,
|
Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription,
|
||||||
DrawerFooter, DrawerClose,
|
DrawerFooter, DrawerClose,
|
||||||
@@ -36,10 +40,11 @@ function makeBlock(type) {
|
|||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
title: z.string().min(1, "Title is required."),
|
title: z.string().min(1, "Title is required."),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
|
subscription: z.string().optional(),
|
||||||
blocks: z.array(z.any()).optional(),
|
blocks: z.array(z.any()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const DEFAULT_VALUES = { title: "", description: "", blocks: [] };
|
const DEFAULT_VALUES = { title: "", description: "", subscription: "", blocks: [] };
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ id: 0, label: "Lesson", icon: FileText },
|
{ id: 0, label: "Lesson", icon: FileText },
|
||||||
@@ -50,7 +55,7 @@ const STEPS = [
|
|||||||
|
|
||||||
// Fields validated with trigger() before advancing past each step.
|
// Fields validated with trigger() before advancing past each step.
|
||||||
// Empty array means "validate the whole form" (nothing new to check that 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 }) {
|
function FieldError({ message }) {
|
||||||
if (!message) return null;
|
if (!message) return null;
|
||||||
@@ -58,7 +63,9 @@ function FieldError({ message }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Step 1 — Lesson ───────────────────────────────────────────────────────────
|
// ─── Step 1 — Lesson ───────────────────────────────────────────────────────────
|
||||||
function StepLesson({ register, errors }) {
|
function StepLesson({ register, errors, control, setValue, tierCategories }) {
|
||||||
|
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -71,6 +78,29 @@ function StepLesson({ register, errors }) {
|
|||||||
<Label htmlFor="description">Description</Label>
|
<Label htmlFor="description">Description</Label>
|
||||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="border border-border rounded-lg p-4 space-y-1">
|
<div className="border border-border rounded-lg p-4 space-y-1">
|
||||||
@@ -193,6 +225,7 @@ function StepReview({ data, attachUnitId, requirements }) {
|
|||||||
</div>
|
</div>
|
||||||
<SummaryRow label="Title" value={data.title} />
|
<SummaryRow label="Title" value={data.title} />
|
||||||
<SummaryRow label="Description" value={data.description} />
|
<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)`} />
|
<SummaryRow label="Page content" value={`${(data.blocks ?? []).length} block(s)`} />
|
||||||
{attachUnitId && <SummaryRow label="Attaches to" value="The unit you came from" />}
|
{attachUnitId && <SummaryRow label="Attaches to" value="The unit you came from" />}
|
||||||
</div>
|
</div>
|
||||||
@@ -215,6 +248,13 @@ export default function AddLibraryLesson() {
|
|||||||
|
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [requirements, setRequirements] = useState([]);
|
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 {
|
const {
|
||||||
register, control, trigger, getValues, setValue,
|
register, control, trigger, getValues, setValue,
|
||||||
@@ -249,6 +289,7 @@ export default function AddLibraryLesson() {
|
|||||||
const result = await createLesson({
|
const result = await createLesson({
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description || null,
|
description: data.description || null,
|
||||||
|
subscription: data.subscription || null,
|
||||||
...(attachUnitId ? { unit_id: attachUnitId } : {}),
|
...(attachUnitId ? { unit_id: attachUnitId } : {}),
|
||||||
createdBy: user?.user_id,
|
createdBy: user?.user_id,
|
||||||
});
|
});
|
||||||
@@ -332,7 +373,7 @@ export default function AddLibraryLesson() {
|
|||||||
<h2 className="text-base font-medium mb-4">{STEPS[step].label}</h2>
|
<h2 className="text-base font-medium mb-4">{STEPS[step].label}</h2>
|
||||||
|
|
||||||
{step === 0 && (
|
{step === 0 && (
|
||||||
<StepLesson register={register} errors={errors} />
|
<StepLesson register={register} errors={errors} control={control} setValue={setValue} tierCategories={tierCategories} />
|
||||||
)}
|
)}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<StepPageBuilder control={control} setValue={setValue} getValues={getValues} />
|
<StepPageBuilder control={control} setValue={setValue} getValues={getValues} />
|
||||||
@@ -345,7 +386,7 @@ export default function AddLibraryLesson() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<StepReview data={getValues()} attachUnitId={attachUnitId} requirements={requirements} />
|
<StepReview data={getValues()} attachUnitId={attachUnitId} requirements={requirements} tierCategories={tierCategories} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
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 { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
@@ -9,17 +9,23 @@ import { useLibrary } from "@/contexts/AdminLibraryContext";
|
|||||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
|
import api from "@/utils/api.util";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
|
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
|
||||||
|
import ProductPricingCard from "@/modules/admin/components/products/ProductPricingCard";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
title: z.string().min(1, "Title is required."),
|
title: z.string().min(1, "Title is required."),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
|
subscription: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function FieldError({ message }) {
|
function FieldError({ message }) {
|
||||||
@@ -30,29 +36,38 @@ function FieldError({ message }) {
|
|||||||
export default function EditLibraryLesson() {
|
export default function EditLibraryLesson() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { lessonId } = useParams();
|
const { lessonId } = useParams();
|
||||||
const { fetchLesson, updateLesson, lesson, loading } = useLibrary();
|
const { fetchLesson, updateLesson, lesson, loading, fetchLessonProduct, saveLessonProduct, removeLessonProduct } = useLibrary();
|
||||||
const { fetchLessonRequirements, syncLessonRequirements } = useCourses();
|
const { fetchLessonRequirements, syncLessonRequirements } = useCourses();
|
||||||
const { user } = useAuth();
|
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),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: { title: "", description: "" },
|
defaultValues: { title: "", description: "", subscription: "" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
||||||
|
|
||||||
|
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLesson(lessonId);
|
fetchLesson(lessonId);
|
||||||
}, [lessonId]);
|
}, [lessonId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lesson && String(lesson.lesson_id) === String(lessonId)) {
|
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]);
|
}, [lesson, lessonId, reset]);
|
||||||
|
|
||||||
const onSubmit = async (data) => {
|
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;
|
if (!result) return;
|
||||||
bypassOnce();
|
bypassOnce();
|
||||||
navigate(`/admin/lessons/${lessonId}/view`);
|
navigate(`/admin/lessons/${lessonId}/view`);
|
||||||
@@ -90,6 +105,29 @@ export default function EditLibraryLesson() {
|
|||||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
@@ -115,6 +153,20 @@ export default function EditLibraryLesson() {
|
|||||||
args={[null, null, lessonId]}
|
args={[null, null, lessonId]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export default function ViewLibraryLesson() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats row */}
|
{/* 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">
|
<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="text-xs text-muted-foreground uppercase tracking-wide font-medium">Blocks</p>
|
||||||
<p className="font-semibold text-sm">{blocks.length}</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"}
|
{units.length > 0 ? `${units.length} unit${units.length === 1 ? "" : "s"}` : "Standalone"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Attached units */}
|
{/* Attached units */}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
|||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
|
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
|
||||||
|
import ProductPricingCard from "@/modules/admin/components/products/ProductPricingCard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -35,7 +36,7 @@ function FieldError({ message }) {
|
|||||||
export default function EditLibraryUnit() {
|
export default function EditLibraryUnit() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { unitId } = useParams();
|
const { unitId } = useParams();
|
||||||
const { fetchUnit, updateUnit, unit, loading } = useLibrary();
|
const { fetchUnit, updateUnit, unit, loading, fetchUnitProduct, saveUnitProduct, removeUnitProduct } = useLibrary();
|
||||||
const { fetchUnitRequirements, syncUnitRequirements } = useCourses();
|
const { fetchUnitRequirements, syncUnitRequirements } = useCourses();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
@@ -152,6 +153,20 @@ export default function EditLibraryUnit() {
|
|||||||
args={[null, unitId]}
|
args={[null, unitId]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export default function ViewLibraryUnit() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats row */}
|
{/* 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">
|
<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="text-xs text-muted-foreground uppercase tracking-wide font-medium">Lessons</p>
|
||||||
<p className="font-semibold text-sm">{lessons.length}</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"}
|
{courses.length > 0 ? `${courses.length} course${courses.length === 1 ? "" : "s"}` : "Standalone"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Attached courses */}
|
{/* Attached courses */}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { Spinner } from "@/components/ui/spinner";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
|
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 { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
@@ -75,7 +77,7 @@ function SectionCard({ title, children }) {
|
|||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ label: "Category & Label", description: "Tier category, label & description" },
|
{ label: "Category & Label", description: "Tier category, label & description" },
|
||||||
{ label: "Duration & Pricing", description: "Billing period, price & currency" },
|
{ 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 }) {
|
function StepIndicator({ steps, current, maxStepReached, onStepClick }) {
|
||||||
@@ -144,6 +146,10 @@ export default function AddPlan() {
|
|||||||
const [currencies, setCurrencies] = useState([]);
|
const [currencies, setCurrencies] = useState([]);
|
||||||
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
||||||
const [courseConflicts, setCourseConflicts] = useState(0);
|
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(() => {
|
useEffect(() => {
|
||||||
api.get("/admin/tiers/categories")
|
api.get("/admin/tiers/categories")
|
||||||
@@ -176,10 +182,11 @@ export default function AddPlan() {
|
|||||||
insertFeature(index + 1, lines.slice(1).map((text) => ({ text })));
|
insertFeature(index + 1, lines.slice(1).map((text) => ({ text })));
|
||||||
};
|
};
|
||||||
|
|
||||||
// selectedCourseIds lives outside the form — this is a create page so it
|
// selectedCourseIds/selectedUnitIds/selectedLessonIds live outside the form
|
||||||
// always starts empty, meaning any selection is a genuine unsaved change.
|
// — this is a create page so they always start empty, meaning any
|
||||||
|
// selection is a genuine unsaved change.
|
||||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(
|
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");
|
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;
|
return categories.find((c) => String(c.tier_category_id) === selectedCategoryId)?.slug ?? null;
|
||||||
}, [selectedCategoryId, categories]);
|
}, [selectedCategoryId, categories]);
|
||||||
|
|
||||||
// Reset picker when category changes
|
// Reset pickers when category changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedCourseIds(new Set());
|
setSelectedCourseIds(new Set());
|
||||||
setCourseConflicts(0);
|
setCourseConflicts(0);
|
||||||
|
setSelectedUnitIds(new Set());
|
||||||
|
setUnitConflicts(0);
|
||||||
|
setSelectedLessonIds(new Set());
|
||||||
|
setLessonConflicts(0);
|
||||||
}, [categorySlug]);
|
}, [categorySlug]);
|
||||||
|
|
||||||
const STEP_FIELDS = [
|
const STEP_FIELDS = [
|
||||||
@@ -220,12 +231,22 @@ export default function AddPlan() {
|
|||||||
const result = await createPlan(values);
|
const result = await createPlan(values);
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
// Sync selected courses
|
// Sync selected bundles
|
||||||
if (selectedCourseIds.size > 0) {
|
if (selectedCourseIds.size > 0) {
|
||||||
await api.post(`/admin/tiers/plans/${result.plan_id}/courses`, {
|
await api.post(`/admin/tiers/plans/${result.plan_id}/courses`, {
|
||||||
course_ids: [...selectedCourseIds].map(Number),
|
course_ids: [...selectedCourseIds].map(Number),
|
||||||
}).catch(() => {});
|
}).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();
|
bypassOnce();
|
||||||
navigate(`/admin/tiers/plans`);
|
navigate(`/admin/tiers/plans`);
|
||||||
@@ -401,15 +422,31 @@ export default function AddPlan() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Step 2: Assigned Courses ── */}
|
{/* ── Step 2: Bundles ── */}
|
||||||
{currentStep === 2 && (
|
{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
|
<CoursePicker
|
||||||
subscription={categorySlug}
|
subscription={categorySlug}
|
||||||
selectedIds={selectedCourseIds}
|
selectedIds={selectedCourseIds}
|
||||||
onChange={setSelectedCourseIds}
|
onChange={setSelectedCourseIds}
|
||||||
onConflictsChange={setCourseConflicts}
|
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>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -432,7 +469,7 @@ export default function AddPlan() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit(onSubmit)}
|
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" />}
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
Create Plan
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
|
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 { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
@@ -87,6 +89,12 @@ export default function EditPlan() {
|
|||||||
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
||||||
const [coursesLoaded, setCoursesLoaded] = useState(false);
|
const [coursesLoaded, setCoursesLoaded] = useState(false);
|
||||||
const [courseConflicts, setCourseConflicts] = useState(0);
|
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 [currencies, setCurrencies] = useState([]);
|
||||||
const [impactDialog, setImpactDialog] = useState(false);
|
const [impactDialog, setImpactDialog] = useState(false);
|
||||||
const [impactCount, setImpactCount] = useState(0);
|
const [impactCount, setImpactCount] = useState(0);
|
||||||
@@ -150,6 +158,29 @@ export default function EditPlan() {
|
|||||||
.catch(() => setCoursesLoaded(true));
|
.catch(() => setCoursesLoaded(true));
|
||||||
}, [planId]);
|
}, [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) => {
|
const durationChanged = (values) => {
|
||||||
if (!plan) return false;
|
if (!plan) return false;
|
||||||
const originalUnit = plan.duration_unit ?? "day";
|
const originalUnit = plan.duration_unit ?? "day";
|
||||||
@@ -166,6 +197,12 @@ export default function EditPlan() {
|
|||||||
await api.post(`/admin/tiers/${planId}/courses`, {
|
await api.post(`/admin/tiers/${planId}/courses`, {
|
||||||
course_ids: [...selectedCourseIds],
|
course_ids: [...selectedCourseIds],
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
await api.post(`/admin/tiers/${planId}/units`, {
|
||||||
|
unit_ids: [...selectedUnitIds],
|
||||||
|
}).catch(() => {});
|
||||||
|
await api.post(`/admin/tiers/${planId}/lessons`, {
|
||||||
|
lesson_ids: [...selectedLessonIds],
|
||||||
|
}).catch(() => {});
|
||||||
bypassOnce();
|
bypassOnce();
|
||||||
navigate("/admin/tiers/plans");
|
navigate("/admin/tiers/plans");
|
||||||
};
|
};
|
||||||
@@ -339,7 +376,7 @@ export default function EditPlan() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{plan?.tier && (
|
{plan?.tier && (
|
||||||
<SectionCard title="Assigned Courses">
|
<SectionCard title="Bundles" description="Choose which courses, units, and lessons this plan unlocks.">
|
||||||
{coursesLoaded ? (
|
{coursesLoaded ? (
|
||||||
<CoursePicker
|
<CoursePicker
|
||||||
subscription={plan.tier}
|
subscription={plan.tier}
|
||||||
@@ -358,12 +395,54 @@ export default function EditPlan() {
|
|||||||
<Skeleton className="h-8 w-52" />
|
<Skeleton className="h-8 w-52" />
|
||||||
</div>
|
</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>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-1">
|
<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="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" />}
|
{(loading || impactLoading) && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useMemo } from "react";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ArrowLeft, CreditCard, Pencil, Tag, BadgeCheck, BookOpen, Clock,
|
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";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -70,7 +70,7 @@ function formatCourseDuration(seconds = 0) {
|
|||||||
|
|
||||||
// ─── Tab: Plan Details ─────────────────────────────────────────────────────────
|
// ─── Tab: Plan Details ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoading }) {
|
function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoading, assignedUnits, unitsLoading, assignedLessons, lessonsLoading }) {
|
||||||
const { fmtDateTime } = useDateFormat();
|
const { fmtDateTime } = useDateFormat();
|
||||||
|
|
||||||
if (loading && !plan) {
|
if (loading && !plan) {
|
||||||
@@ -159,6 +159,68 @@ function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoadin
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</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">
|
<SectionCard icon={BadgeCheck} title="Audit">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<InfoRow label="Created At">{plan.createdAt ? fmtDateTime(plan.createdAt) : "—"}</InfoRow>
|
<InfoRow label="Created At">{plan.createdAt ? fmtDateTime(plan.createdAt) : "—"}</InfoRow>
|
||||||
@@ -750,6 +812,10 @@ export default function ViewPlan() {
|
|||||||
|
|
||||||
const [assignedCourses, setAssignedCourses] = useState([]);
|
const [assignedCourses, setAssignedCourses] = useState([]);
|
||||||
const [coursesLoading, setCoursesLoading] = useState(false);
|
const [coursesLoading, setCoursesLoading] = useState(false);
|
||||||
|
const [assignedUnits, setAssignedUnits] = useState([]);
|
||||||
|
const [unitsLoading, setUnitsLoading] = useState(false);
|
||||||
|
const [assignedLessons, setAssignedLessons] = useState([]);
|
||||||
|
const [lessonsLoading, setLessonsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPlan(planId);
|
fetchPlan(planId);
|
||||||
@@ -759,6 +825,16 @@ export default function ViewPlan() {
|
|||||||
.then(({ data }) => setAssignedCourses(data.data ?? []))
|
.then(({ data }) => setAssignedCourses(data.data ?? []))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setCoursesLoading(false));
|
.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]);
|
}, [planId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -835,6 +911,10 @@ export default function ViewPlan() {
|
|||||||
tierMap={tierMap}
|
tierMap={tierMap}
|
||||||
assignedCourses={assignedCourses}
|
assignedCourses={assignedCourses}
|
||||||
coursesLoading={coursesLoading}
|
coursesLoading={coursesLoading}
|
||||||
|
assignedUnits={assignedUnits}
|
||||||
|
unitsLoading={unitsLoading}
|
||||||
|
assignedLessons={assignedLessons}
|
||||||
|
lessonsLoading={lessonsLoading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === "access" && (
|
{activeTab === "access" && (
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { formatDate } from '@/utils/table.util';
|
|||||||
import { formatBytes } from './blocks/FileUpload';
|
import { formatBytes } from './blocks/FileUpload';
|
||||||
import api from '@/utils/api.util';
|
import api from '@/utils/api.util';
|
||||||
import { saveBlob } from '@/utils/media.util';
|
import { saveBlob } from '@/utils/media.util';
|
||||||
import FileZoomViewer from './FileZoomViewer';
|
import FileZoomViewer from '@/components/generic/FileZoomViewer';
|
||||||
|
|
||||||
// ─── Resolve preview kind from mime type ──────────────────────────────────────
|
// ─── Resolve preview kind from mime type ──────────────────────────────────────
|
||||||
const resolveKind = (mimeType = '', fileName = '') => {
|
const resolveKind = (mimeType = '', fileName = '') => {
|
||||||
@@ -48,7 +48,7 @@ const resolveKind = (mimeType = '', fileName = '') => {
|
|||||||
|
|
||||||
const ext = (fileName.split('.').pop() ?? '').toLowerCase();
|
const ext = (fileName.split('.').pop() ?? '').toLowerCase();
|
||||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
|
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 (['mp3', 'wav', 'm4a', 'ogg'].includes(ext)) return 'audio';
|
||||||
if (ext === 'pdf') return 'pdf';
|
if (ext === 'pdf') return 'pdf';
|
||||||
return 'other';
|
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} />;
|
if (error) return <ErrorState onDownload={onDownload} downloading={downloading} fileName={file.file_name} />;
|
||||||
return (
|
return (
|
||||||
<FileZoomViewer
|
<FileZoomViewer
|
||||||
blobUrl={blobUrl}
|
src={blobUrl}
|
||||||
mimeType={file.mime_type}
|
mimeType={file.mime_type}
|
||||||
fileName={file.file_name}
|
fileName={file.file_name}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|||||||
@@ -1,37 +1,63 @@
|
|||||||
// LessonUpsellModal — shown when a learner clicks a locked standalone Lesson.
|
// LessonUpsellModal — shown when a learner clicks a locked standalone Lesson.
|
||||||
// Mirrors UnitUpsellModal.jsx: a Lesson isn't independently purchasable, so this
|
// Mirrors UnitUpsellModal.jsx: unlocks via the lesson's own subscription tier
|
||||||
// lists every course that would unlock it (aggregated across all its attached
|
// (with an optional direct "Buy" listing) and/or any course reachable through
|
||||||
// Units, since a Lesson can sit in more than one) plus a generic "View Plans"
|
// its attached Units (aggregated across all of them, since a Lesson can sit
|
||||||
// fallback. Shared by LessonsList and Dashboard.
|
// in more than one) plus a generic "View Plans" fallback.
|
||||||
|
// Shared by LessonsList and Dashboard.
|
||||||
|
|
||||||
import { useNavigate } from "react-router-dom";
|
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 ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
|
|
||||||
export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap = {} }) {
|
export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap = {} }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { fmtCurrency } = useDateFormat();
|
||||||
const courses = lesson?.courses ?? [];
|
const courses = lesson?.courses ?? [];
|
||||||
|
const ownTier = lesson?.subscription ? resolveTierBadge(lesson.subscription, tierMap) : null;
|
||||||
|
const canBuy = lesson?.product?.is_active && !lesson?.has_purchased;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title={lesson?.title ?? "Lesson Details"}
|
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={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
<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
|
<LockIcon /> View Plans
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-3 py-2">
|
<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">
|
<p className="text-sm text-muted-foreground">
|
||||||
Upgrade your plan to access this content.
|
Upgrade your plan to access this content.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -3,14 +3,24 @@
|
|||||||
// Distinct from UnitUpsellModal (a dialog triggered from card grids) — this
|
// Distinct from UnitUpsellModal (a dialog triggered from card grids) — this
|
||||||
// renders in place of the page body itself. Shared by UnitDetails and
|
// renders in place of the page body itself. Shared by UnitDetails and
|
||||||
// LessonDetails.
|
// 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 { 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 { 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 navigate = useNavigate();
|
||||||
|
const { fmtCurrency } = useDateFormat();
|
||||||
const tier = course?.subscription ? tierMap[course.subscription] : null;
|
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 (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6 py-32 text-center px-4">
|
<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">
|
<div className="space-y-2 max-w-sm">
|
||||||
<h2 className="text-xl font-semibold">Premium / Exclusive Content</h2>
|
<h2 className="text-xl font-semibold">Premium / Exclusive Content</h2>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<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.`
|
? `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."}
|
: "Upgrade your plan to access this content."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<Button onClick={() => navigate('/plans')} className="gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<Zap className="size-4" /> View Available Plans
|
{canBuy && (
|
||||||
</Button>
|
<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>
|
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,37 +1,63 @@
|
|||||||
// UnitUpsellModal — shown when a learner clicks a locked standalone Unit.
|
// UnitUpsellModal — shown when a learner clicks a locked standalone Unit.
|
||||||
// Units aren't independently purchasable (no Product row keyed to unit_id, only
|
// Unlocks two ways: the unit's own subscription tier (with an optional direct
|
||||||
// to course_id), so unlike CourseCard's single "Buy $X" button, this lists every
|
// "Buy" listing, same PayPal flow as Courses) and/or any course it's attached
|
||||||
// course the unit is attached to so the learner can pick one to view/buy, plus a
|
// to (a unit may sit under several courses at different tiers — no single
|
||||||
// generic "View Plans" fallback. Shared by UnitsList, UnitDetails, and Dashboard.
|
// "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 { 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 ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
|
|
||||||
export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {} }) {
|
export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {} }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { fmtCurrency } = useDateFormat();
|
||||||
const courses = unit?.courses ?? [];
|
const courses = unit?.courses ?? [];
|
||||||
|
const ownTier = unit?.subscription ? resolveTierBadge(unit.subscription, tierMap) : null;
|
||||||
|
const canBuy = unit?.product?.is_active && !unit?.has_purchased;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title={unit?.title ?? "Unit Details"}
|
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={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
<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
|
<LockIcon /> View Plans
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-3 py-2">
|
<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">
|
<p className="text-sm text-muted-foreground">
|
||||||
Upgrade your plan to access this content.
|
Upgrade your plan to access this content.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -405,24 +405,25 @@ const Checkout = () => {
|
|||||||
<span>{fmtCurrency(total, effectiveCurrency)}</span>
|
<span>{fmtCurrency(total, effectiveCurrency)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isCurrent ? (
|
{isCurrent && (
|
||||||
<Button size="lg" className="w-full" variant="outline" disabled>
|
<p className="text-xs text-muted-foreground rounded-md border bg-muted/50 px-3 py-2">
|
||||||
<Check className="size-4" /> Current Plan
|
You already have an active {plan.tier} plan — this purchase will extend it by {duration}, instead of starting a new one.
|
||||||
</Button>
|
</p>
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
<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">
|
<div className="text-center text-sm text-muted-foreground space-y-1">
|
||||||
<p className="inline-flex items-center justify-center gap-1">
|
<p className="inline-flex items-center justify-center gap-1">
|
||||||
<ShieldCheck className="size-4" />
|
<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 ────
|
// ── Deep-link to a lesson under a locked unit — inline blocked panel ────
|
||||||
if (unitBlocked) {
|
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) {
|
if (lessonLoading || !lesson) {
|
||||||
|
|||||||
@@ -216,6 +216,17 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
|
|||||||
>
|
>
|
||||||
{plan.tier === "free" ? "Current" : `Get ${tierLabel}`}
|
{plan.tier === "free" ? "Current" : `Get ${tierLabel}`}
|
||||||
</Button>
|
</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}
|
) : null}
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</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 ────
|
// ── Deep-link to a locked unit — inline blocked panel, not a redirect ────
|
||||||
if (unitBlocked) {
|
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) {
|
if (unitDetailLoading) {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import PlanList from '../pages/PlanList'
|
|||||||
import ViewPlan from '../pages/ViewPlan'
|
import ViewPlan from '../pages/ViewPlan'
|
||||||
import ViewTask from '../pages/ViewTask'
|
import ViewTask from '../pages/ViewTask'
|
||||||
import CourseCheckout from '../pages/CourseCheckout'
|
import CourseCheckout from '../pages/CourseCheckout'
|
||||||
|
import UnitCheckout from '../pages/UnitCheckout'
|
||||||
|
import LessonCheckout from '../pages/LessonCheckout'
|
||||||
import MyCertificates from '../pages/MyCertificates'
|
import MyCertificates from '../pages/MyCertificates'
|
||||||
import MyAchievements from '../pages/MyAchievements'
|
import MyAchievements from '../pages/MyAchievements'
|
||||||
import MyCompletedContent from '../pages/MyCompletedContent'
|
import MyCompletedContent from '../pages/MyCompletedContent'
|
||||||
@@ -106,6 +108,7 @@ export const ClientRoutes = {
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, element: <UnitDetails /> },
|
{ index: true, element: <UnitDetails /> },
|
||||||
{ path: 'read', element: <UnitReader />, handle: { showFooter: false } },
|
{ path: 'read', element: <UnitReader />, handle: { showFooter: false } },
|
||||||
|
{ path: 'checkout', element: <UnitCheckout /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -116,7 +119,13 @@ export const ClientRoutes = {
|
|||||||
element: <Outlet />,
|
element: <Outlet />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <LessonsList /> },
|
{ 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
|
// Uploads go straight to storage via a presigned URL (see
|
||||||
// presignedUpload.util.js) — this backend never buffers the file. Above 5GB
|
// presignedUpload.util.js) — this backend never buffers the file, so these
|
||||||
// (S3-compatible storage's own single-PUT ceiling) uploads switch to real
|
// ceilings are app-level policy rather than a technical constraint (S3's own
|
||||||
// multipart automatically; 15GB is the app-level ceiling chosen for future
|
// single-PUT ceiling is a separate, higher 5GB — see s3.service.js). Add
|
||||||
// assets, well within multipart's own much larger real limit. Rejecting an
|
// Asset (single) and Add Assets Bulk enforce different caps, so each flow
|
||||||
// oversized file here still means the queue shows a clear "invalid" reason
|
// gets its own constant. Rejecting an oversized file here still means the
|
||||||
// instantly instead of a job that uploads for a while and then fails.
|
// queue shows a clear "invalid" reason instantly instead of a job that
|
||||||
export const MAX_ASSET_FILE_SIZE = 15 * 1024 * 1024 * 1024; // 15 GB
|
// uploads for a while and then fails.
|
||||||
export const MAX_ASSET_FILE_SIZE_LABEL = "15 GB";
|
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 = "") {
|
export function fileExtension(filename = "") {
|
||||||
const dot = filename.lastIndexOf(".");
|
const dot = filename.lastIndexOf(".");
|
||||||
return dot > 0 ? filename.slice(dot + 1).toLowerCase() : "";
|
return dot > 0 ? filename.slice(dot + 1).toLowerCase() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// { ok: true } or { ok: false, reason }
|
// { ok: true } or { ok: false, reason }. Defaults to the Bulk ceiling since
|
||||||
export function validateAssetFile(file) {
|
// that's this function's only caller (UploadQueueContext); Add Asset (single)
|
||||||
if (file.size > MAX_ASSET_FILE_SIZE) {
|
// 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 {
|
return {
|
||||||
ok: false,
|
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