assets and tier plans revamp

This commit is contained in:
rgrgogu
2026-08-01 17:44:12 +08:00
parent 4f738a691d
commit a34c6feb84
45 changed files with 2172 additions and 273 deletions
@@ -37,7 +37,7 @@ import { formatDate } from '@/utils/table.util';
import { formatBytes } from './blocks/FileUpload';
import api from '@/utils/api.util';
import { saveBlob } from '@/utils/media.util';
import FileZoomViewer from './FileZoomViewer';
import FileZoomViewer from '@/components/generic/FileZoomViewer';
// ─── Resolve preview kind from mime type ──────────────────────────────────────
const resolveKind = (mimeType = '', fileName = '') => {
@@ -48,7 +48,7 @@ const resolveKind = (mimeType = '', fileName = '') => {
const ext = (fileName.split('.').pop() ?? '').toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
if (['mp4', 'mov', 'webm', 'avi'].includes(ext)) return 'video';
if (['mp4', 'mov', 'mkv', 'webm', 'avi'].includes(ext)) return 'video';
if (['mp3', 'wav', 'm4a', 'ogg'].includes(ext)) return 'audio';
if (ext === 'pdf') return 'pdf';
return 'other';
@@ -140,7 +140,7 @@ const PreviewBody = ({ file, kind, blobUrl, loading, error, downloadUrl, onDownl
if (error) return <ErrorState onDownload={onDownload} downloading={downloading} fileName={file.file_name} />;
return (
<FileZoomViewer
blobUrl={blobUrl}
src={blobUrl}
mimeType={file.mime_type}
fileName={file.file_name}
loading={loading}
@@ -1,298 +0,0 @@
/***********************************************************************************************************************************************************************
* File Name : FileZoomViewer.jsx
* Type : Component (Client)
* Description : Zoom/pan/fit viewer for image and PDF files.
*
* Supported:
* image/jpeg, image/png → <img> with scroll-zoom + drag-pan
* application/pdf → pdf.js renders the current page to
* <canvas>, same zoom/pan controls,
* with page navigation for multi-page PDFs
*
* Not supported (shows a message instead of attempting render):
* DOCX, video, audio, and any other file type
*
* Props:
* blobUrl {string} – object URL (from FilePreview's useBlobUrl)
* mimeType {string}
* fileName {string}
* loading {boolean} – true while the parent is still fetching the blob
***********************************************************************************************************************************************************************/
import { useState, useRef, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import {
ZoomIn, ZoomOut, Maximize2, ChevronLeft, ChevronRight, FileWarning,
} from 'lucide-react';
import { cn } from '@/lib/utils';
const MIN_SCALE = 0.25;
const MAX_SCALE = 4;
const SCALE_STEP = 0.25;
// ─── Resolve viewer mode from mime type / extension ───────────────────────────
const resolveMode = (mimeType = '', fileName = '') => {
if (mimeType === 'image/jpeg' || mimeType === 'image/png') return 'image';
if (mimeType === 'application/pdf') return 'pdf';
const ext = (fileName.split('.').pop() ?? '').toLowerCase();
if (['jpg', 'jpeg', 'png'].includes(ext)) return 'image';
if (ext === 'pdf') return 'pdf';
return 'unsupported';
};
// ─── Not supported message ─────────────────────────────────────────────────────
const UnsupportedMessage = ({ fileName }) => (
<div className="flex flex-col items-center gap-3 py-16 text-muted-foreground">
<FileWarning className="size-12" />
<p className="text-sm font-medium">Full preview not supported for this file type.</p>
<p className="text-xs max-w-xs text-center">
{fileName ? `"${fileName}" ` : 'This file '}
can't be opened in the zoom viewer. JPEG, PNG, and PDF files are supported.
</p>
</div>
);
// ─── Toolbar ──────────────────────────────────────────────────────────────────
const ZoomToolbar = ({
scale, onZoomIn, onZoomOut, onFit,
page, numPages, onPrevPage, onNextPage,
}) => (
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b bg-muted/40">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="size-8" onClick={onZoomOut} disabled={scale <= MIN_SCALE} aria-label="Zoom out">
<ZoomOut className="size-4" />
</Button>
<span className="text-xs w-12 text-center tabular-nums select-none">{Math.round(scale * 100)}%</span>
<Button variant="ghost" size="icon" className="size-8" onClick={onZoomIn} disabled={scale >= MAX_SCALE} aria-label="Zoom in">
<ZoomIn className="size-4" />
</Button>
<Button variant="ghost" onClick={onFit} aria-label="Fit to screen">
<Maximize2 className="size-4" /> Fit to screen
</Button>
</div>
{numPages > 1 && (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="size-8" onClick={onPrevPage} disabled={page <= 1} aria-label="Previous page">
<ChevronLeft className="size-4" />
</Button>
<span className="text-xs tabular-nums">{page} / {numPages}</span>
<Button variant="ghost" size="icon" className="size-8" onClick={onNextPage} disabled={page >= numPages} aria-label="Next page">
<ChevronRight className="size-4" />
</Button>
</div>
)}
</div>
);
// ─── Shared zoom/pan canvas wrapper ─────────────────────────────────────────────
// Wraps any child (img or canvas) with scroll-to-zoom + drag-to-pan behavior.
const ZoomPanArea = ({ scale, setScale, offset, setOffset, fitFn, children }) => {
const containerRef = useRef(null);
const dragRef = useRef({ dragging: false, startX: 0, startY: 0, origX: 0, origY: 0 });
// ── Scroll to zoom (centered on cursor) ────────────────────────────────────
const onWheel = useCallback((e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -SCALE_STEP : SCALE_STEP;
setScale((s) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, +(s + delta).toFixed(2))));
}, [setScale]);
// ── Drag to pan ───────────────────────────────────────────────────────────
const onPointerDown = (e) => {
dragRef.current = {
dragging: true,
startX: e.clientX,
startY: e.clientY,
origX: offset.x,
origY: offset.y,
};
e.currentTarget.setPointerCapture(e.pointerId);
};
const onPointerMove = (e) => {
if (!dragRef.current.dragging) return;
const dx = e.clientX - dragRef.current.startX;
const dy = e.clientY - dragRef.current.startY;
setOffset({ x: dragRef.current.origX + dx, y: dragRef.current.origY + dy });
};
const onPointerUp = (e) => {
dragRef.current.dragging = false;
e.currentTarget.releasePointerCapture(e.pointerId);
};
useEffect(() => {
const el = containerRef.current;
if (!el) return;
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [onWheel]);
return (
<div
ref={containerRef}
className={cn(
'relative overflow-hidden bg-muted h-[420px] flex items-center justify-center',
scale > 1 ? 'cursor-grab active:cursor-grabbing' : 'cursor-default'
)}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onDoubleClick={fitFn}
>
<div
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
transformOrigin: 'center center',
transition: dragRef.current.dragging ? 'none' : 'transform 0.1s ease-out',
}}
>
{children}
</div>
</div>
);
};
// ─── Image viewer ───────────────────────────────────────────────────────────────
const ImageViewer = ({ blobUrl, fileName }) => {
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const fit = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
return (
<div className="flex flex-col">
<ZoomToolbar
scale={scale}
onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, +(s + SCALE_STEP).toFixed(2)))}
onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, +(s - SCALE_STEP).toFixed(2)))}
onFit={fit}
page={1}
numPages={1}
onPrevPage={() => {}}
onNextPage={() => {}}
/>
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit}>
<img
src={blobUrl}
alt={fileName}
draggable={false}
className="max-h-[380px] max-w-none select-none pointer-events-none"
/>
</ZoomPanArea>
</div>
);
};
// ─── PDF viewer (pdf.js → canvas) ───────────────────────────────────────────────
const PdfViewer = ({ blobUrl, fileName }) => {
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [pdfDoc, setPdfDoc] = useState(null);
const [page, setPage] = useState(1);
const [numPages, setNumPages] = useState(1);
const [rendering, setRendering] = useState(true);
const [loadError, setLoadError] = useState(false);
const canvasRef = useRef(null);
const fit = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
// ── Load the PDF document ──────────────────────────────────────────────────
useEffect(() => {
let cancelled = false;
(async () => {
try {
const pdfjsLib = await import('pdfjs-dist');
pdfjsLib.GlobalWorkerOptions.workerSrc =
new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
const doc = await pdfjsLib.getDocument(blobUrl).promise;
if (cancelled) return;
setPdfDoc(doc);
setNumPages(doc.numPages);
} catch (err) {
if (!cancelled) setLoadError(true);
}
})();
return () => { cancelled = true; };
}, [blobUrl]);
// ── Render current page to canvas ──────────────────────────────────────────
useEffect(() => {
if (!pdfDoc) return;
let cancelled = false;
(async () => {
setRendering(true);
try {
const pdfPage = await pdfDoc.getPage(page);
const viewport = pdfPage.getViewport({ scale: 1.5 }); // base render scale for crispness
const canvas = canvasRef.current;
if (!canvas || cancelled) return;
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
await pdfPage.render({ canvasContext: ctx, viewport }).promise;
} catch {
if (!cancelled) setLoadError(true);
} finally {
if (!cancelled) setRendering(false);
}
})();
return () => { cancelled = true; };
}, [pdfDoc, page]);
if (loadError) return <UnsupportedMessage fileName={fileName} />;
return (
<div className="flex flex-col">
<ZoomToolbar
scale={scale}
onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, +(s + SCALE_STEP).toFixed(2)))}
onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, +(s - SCALE_STEP).toFixed(2)))}
onFit={fit}
page={page}
numPages={numPages}
onPrevPage={() => { setPage((p) => Math.max(1, p - 1)); fit(); }}
onNextPage={() => { setPage((p) => Math.min(numPages, p + 1)); fit(); }}
/>
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit}>
<canvas ref={canvasRef} className="max-h-[380px] select-none" />
</ZoomPanArea>
{rendering && (
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
<Spinner className="size-6" />
</div>
)}
</div>
);
};
// ─── Main viewer ────────────────────────────────────────────────────────────────
const FileZoomViewer = ({ blobUrl, mimeType, fileName, loading }) => {
const mode = resolveMode(mimeType, fileName);
if (loading) {
return (
<div className="flex items-center justify-center h-[420px]">
<Spinner className="size-6" />
</div>
);
}
if (!blobUrl || mode === 'unsupported') {
return <UnsupportedMessage fileName={fileName} />;
}
if (mode === 'image') return <ImageViewer blobUrl={blobUrl} fileName={fileName} />;
if (mode === 'pdf') return <PdfViewer blobUrl={blobUrl} fileName={fileName} />;
return <UnsupportedMessage fileName={fileName} />;
};
export default FileZoomViewer;
@@ -1,37 +1,63 @@
// LessonUpsellModal — shown when a learner clicks a locked standalone Lesson.
// Mirrors UnitUpsellModal.jsx: a Lesson isn't independently purchasable, so this
// lists every course that would unlock it (aggregated across all its attached
// Units, since a Lesson can sit in more than one) plus a generic "View Plans"
// fallback. Shared by LessonsList and Dashboard.
// Mirrors UnitUpsellModal.jsx: unlocks via the lesson's own subscription tier
// (with an optional direct "Buy" listing) and/or any course reachable through
// its attached Units (aggregated across all of them, since a Lesson can sit
// in more than one) plus a generic "View Plans" fallback.
// Shared by LessonsList and Dashboard.
import { useNavigate } from "react-router-dom";
import { LockIcon, BookOpen } from "lucide-react";
import { LockIcon, BookOpen, ShoppingCart } from "lucide-react";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useDateFormat } from "@/hooks/useDateFormat";
import { resolveTierBadge } from "@/utils/tierBadge.util";
export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap = {} }) {
const navigate = useNavigate();
const { fmtCurrency } = useDateFormat();
const courses = lesson?.courses ?? [];
const ownTier = lesson?.subscription ? resolveTierBadge(lesson.subscription, tierMap) : null;
const canBuy = lesson?.product?.is_active && !lesson?.has_purchased;
return (
<ResponsiveModal
open={open}
onOpenChange={onOpenChange}
title={lesson?.title ?? "Lesson Details"}
description="This lesson is part of one or more courses that require a plan upgrade."
description={
ownTier
? "This lesson requires a plan upgrade or individual purchase."
: "This lesson is part of one or more courses that require a plan upgrade."
}
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
<Button onClick={() => { onOpenChange(false); navigate("/plans"); }}>
{canBuy && (
<Button onClick={() => { onOpenChange(false); navigate(`/lessons/${lesson.uuid}/checkout`); }}>
<ShoppingCart /> Buy {fmtCurrency(lesson.product.price ?? 0, lesson.product.currency ?? "USD")}
</Button>
)}
<Button variant={canBuy ? "outline" : "default"} onClick={() => { onOpenChange(false); navigate("/plans"); }}>
<LockIcon /> View Plans
</Button>
</>
}
>
<div className="space-y-3 py-2">
{courses.length === 0 ? (
{ownTier && (
<div className="flex items-center justify-between gap-3 p-4 rounded-xl border bg-muted/40">
<div className="flex items-center gap-2.5 min-w-0">
<LockIcon className="size-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium">Requires</p>
<Badge className={`${ownTier.cls} mt-1`}>{ownTier.label}</Badge>
</div>
</div>
</div>
)}
{courses.length === 0 && !ownTier ? (
<p className="text-sm text-muted-foreground">
Upgrade your plan to access this content.
</p>
@@ -3,14 +3,24 @@
// Distinct from UnitUpsellModal (a dialog triggered from card grids) — this
// renders in place of the page body itself. Shared by UnitDetails and
// LessonDetails.
//
// `item` (optional) is the unit/lesson's own subscription/product info, from
// the 403 body's `item` field — lets a deep-link show the same own-tier
// badge + direct "Buy" option the browse-list upsell modals already do,
// instead of only ever pointing at an attached course.
import { useNavigate } from "react-router-dom";
import { LockIcon, Zap } from "lucide-react";
import { LockIcon, Zap, ShoppingCart } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useDateFormat } from "@/hooks/useDateFormat";
import { resolveTierBadge } from "@/utils/tierBadge.util";
export default function LockedContentPanel({ course, tierMap = {} }) {
export default function LockedContentPanel({ course, item, tierMap = {}, checkoutPath }) {
const navigate = useNavigate();
const { fmtCurrency } = useDateFormat();
const tier = course?.subscription ? tierMap[course.subscription] : null;
const ownTier = item?.subscription ? resolveTierBadge(item.subscription, tierMap) : null;
const canBuy = item?.product?.is_active && !item?.has_purchased && checkoutPath;
return (
<div className="flex flex-col items-center gap-6 py-32 text-center px-4">
@@ -20,15 +30,24 @@ export default function LockedContentPanel({ course, tierMap = {} }) {
<div className="space-y-2 max-w-sm">
<h2 className="text-xl font-semibold">Premium / Exclusive Content</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
{course
{ownTier
? `This content requires the ${ownTier.label} plan${canBuy ? ", or you can purchase it individually" : ""}.`
: course
? `This content is part of "${course.title}"${tier?.name ? ` (${tier.name} plan)` : ""}. Upgrade your plan or view the course to unlock it.`
: "Upgrade your plan to access this content."}
</p>
</div>
<div className="flex flex-col items-center gap-2">
<Button onClick={() => navigate('/plans')} className="gap-1.5">
<Zap className="size-4" /> View Available Plans
</Button>
<div className="flex items-center gap-2">
{canBuy && (
<Button variant="outline" onClick={() => navigate(checkoutPath)} className="gap-1.5">
<ShoppingCart className="size-4" /> Buy {fmtCurrency(item.product.price ?? 0, item.product.currency ?? "USD")}
</Button>
)}
<Button onClick={() => navigate('/plans')} className="gap-1.5">
<Zap className="size-4" /> View Available Plans
</Button>
</div>
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
</div>
</div>
@@ -1,37 +1,63 @@
// UnitUpsellModal — shown when a learner clicks a locked standalone Unit.
// Units aren't independently purchasable (no Product row keyed to unit_id, only
// to course_id), so unlike CourseCard's single "Buy $X" button, this lists every
// course the unit is attached to so the learner can pick one to view/buy, plus a
// generic "View Plans" fallback. Shared by UnitsList, UnitDetails, and Dashboard.
// Unlocks two ways: the unit's own subscription tier (with an optional direct
// "Buy" listing, same PayPal flow as Courses) and/or any course it's attached
// to (a unit may sit under several courses at different tiers — no single
// "Buy" in that case, just links to view/buy the course itself).
// Shared by UnitsList, UnitDetails, and Dashboard.
import { useNavigate } from "react-router-dom";
import { LockIcon, BookOpen } from "lucide-react";
import { LockIcon, BookOpen, ShoppingCart } from "lucide-react";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useDateFormat } from "@/hooks/useDateFormat";
import { resolveTierBadge } from "@/utils/tierBadge.util";
export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {} }) {
const navigate = useNavigate();
const { fmtCurrency } = useDateFormat();
const courses = unit?.courses ?? [];
const ownTier = unit?.subscription ? resolveTierBadge(unit.subscription, tierMap) : null;
const canBuy = unit?.product?.is_active && !unit?.has_purchased;
return (
<ResponsiveModal
open={open}
onOpenChange={onOpenChange}
title={unit?.title ?? "Unit Details"}
description="This unit is part of one or more courses that require a plan upgrade."
description={
ownTier
? "This unit requires a plan upgrade or individual purchase."
: "This unit is part of one or more courses that require a plan upgrade."
}
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
<Button onClick={() => { onOpenChange(false); navigate("/plans"); }}>
{canBuy && (
<Button onClick={() => { onOpenChange(false); navigate(`/units/${unit.uuid}/checkout`); }}>
<ShoppingCart /> Buy {fmtCurrency(unit.product.price ?? 0, unit.product.currency ?? "USD")}
</Button>
)}
<Button variant={canBuy ? "outline" : "default"} onClick={() => { onOpenChange(false); navigate("/plans"); }}>
<LockIcon /> View Plans
</Button>
</>
}
>
<div className="space-y-3 py-2">
{courses.length === 0 ? (
{ownTier && (
<div className="flex items-center justify-between gap-3 p-4 rounded-xl border bg-muted/40">
<div className="flex items-center gap-2.5 min-w-0">
<LockIcon className="size-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium">Requires</p>
<Badge className={`${ownTier.cls} mt-1`}>{ownTier.label}</Badge>
</div>
</div>
</div>
)}
{courses.length === 0 && !ownTier ? (
<p className="text-sm text-muted-foreground">
Upgrade your plan to access this content.
</p>