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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user