mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
client revised
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
// components/admin/advertisements/AdvertisementPreview.jsx
|
||||
|
||||
import { ChevronLeft, ChevronRight, Megaphone } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
// ── AdvertisementPreview ─────────────────────────────────────────────────────
|
||||
/**
|
||||
* Live, presentational preview of a single ad slide as it will actually
|
||||
* render inside the real Hero/Banner carousel (see components/generic/Blocks/
|
||||
* Client/Advertisements/{Hero,Banner}.jsx) — same image + gradient overlay +
|
||||
* bottom-left badge/headline/description layout, just scaled down for the
|
||||
* admin form. The prev/next/progress chrome below the slide is static (no
|
||||
* real carousel behind it, since there's only ever one slide to preview) —
|
||||
* it's there purely so the admin recognizes this as "inside a carousel."
|
||||
*
|
||||
* Props:
|
||||
* format — "hero" | "banner", determines slide height/typography
|
||||
* badgeLabels — string[]
|
||||
* headline — string
|
||||
* description — string
|
||||
* imageSrc — resolved image URL, or null/undefined
|
||||
*/
|
||||
export function AdvertisementPreview({ format = "hero", badgeLabels, headline, description, imageSrc }) {
|
||||
const isHero = format !== "banner";
|
||||
const labels = Array.isArray(badgeLabels) ? badgeLabels.filter(Boolean) : [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Preview — this is how it will appear in the carousel.
|
||||
</p>
|
||||
<Card className={cnRounded(isHero)}>
|
||||
<CardContent className={cnHeight(isHero) + " relative bg-muted flex items-center justify-center p-0"}>
|
||||
{imageSrc ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={headline || "Advertisement preview"}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Megaphone className="size-6 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent" />
|
||||
|
||||
<div className="absolute bottom-0 left-0 p-4 flex flex-col gap-2 max-w-[85%]">
|
||||
{labels.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{labels.map((label, i) => (
|
||||
<Badge key={i} variant="outline" className="pointer-events-none select-none w-fit border-white/30 bg-black/20 text-white">
|
||||
<Megaphone /> {label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{headline && (
|
||||
<div className={(isHero ? "text-xl" : "text-lg") + " font-bold text-white leading-tight tracking-tight"}>
|
||||
{headline}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-gray-200 text-xs leading-relaxed line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Static carousel chrome — communicates "this sits inside a carousel," not functional */}
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon-sm" className="rounded-lg" disabled>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon-sm" className="rounded-lg" disabled>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 max-w-24 ml-4">
|
||||
<Progress value={100} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function cnRounded(isHero) {
|
||||
return "border overflow-hidden pl-0 py-0 " + (isHero ? "rounded-2xl" : "rounded-xl");
|
||||
}
|
||||
|
||||
function cnHeight(isHero) {
|
||||
return isHero ? "h-56" : "h-48";
|
||||
}
|
||||
@@ -211,7 +211,7 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-muted" />
|
||||
<div className="absolute inset-0 bg-primary" />
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex items-center gap-4 p-4 text-white">
|
||||
@@ -291,7 +291,9 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
|
||||
value={muted ? 0 : volume}
|
||||
onChange={handleVolume}
|
||||
aria-label="Volume"
|
||||
className="w-16 h-1.5"
|
||||
className="w-16 h-1.5 accent-primary cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:cursor-pointer
|
||||
[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
const poster = thumbnailUrl ?? content?.thumbnail_url ?? undefined;
|
||||
|
||||
const vidRef = useRef(null);
|
||||
const wrapRef = useRef(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
@@ -38,7 +39,13 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [overlayVisible,setOverlayVisible]= useState(true);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
// Native Fullscreen API works on desktop (all browsers) and Android Chrome.
|
||||
// iOS Safari doesn't support Fullscreen API on arbitrary elements at all, so
|
||||
// pseudoFullscreen is a CSS-only (fixed inset-0) fallback that keeps our
|
||||
// custom controls instead of falling back to native <video> fullscreen.
|
||||
const [nativeFullscreen, setNativeFullscreen] = useState(false);
|
||||
const [pseudoFullscreen, setPseudoFullscreen] = useState(false);
|
||||
const isFullscreen = nativeFullscreen || pseudoFullscreen;
|
||||
// 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
|
||||
@@ -104,6 +111,13 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
else { v.pause(); setPlaying(false); setOverlayVisible(true); }
|
||||
}, []);
|
||||
|
||||
// Safari doesn't focus a <div tabIndex> on click, which silently kills
|
||||
// keyboard shortcuts afterwards — force it explicitly.
|
||||
const handleAreaClick = useCallback(() => {
|
||||
wrapRef.current?.focus({ preventScroll: true });
|
||||
togglePlay();
|
||||
}, [togglePlay]);
|
||||
|
||||
const restart = () => {
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
@@ -134,10 +148,24 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const el = document.getElementById("video-block-wrap");
|
||||
// Uses wrapRef instead of getElementById so this works correctly even if
|
||||
// an admin page ever renders more than one VideoBlock at once (an id
|
||||
// lookup would always grab the first match, toggling the wrong player).
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else el.requestFullscreen?.();
|
||||
if (isFullscreen) {
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else if (document.webkitFullscreenElement) document.webkitExitFullscreen();
|
||||
else setPseudoFullscreen(false);
|
||||
} else if (el.requestFullscreen) {
|
||||
el.requestFullscreen();
|
||||
} else if (el.webkitRequestFullscreen) {
|
||||
// Desktop Safari — supports element fullscreen via the prefixed API.
|
||||
el.webkitRequestFullscreen();
|
||||
} else {
|
||||
// iOS Safari has no element-level Fullscreen API at all.
|
||||
setPseudoFullscreen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Ignore keystrokes aimed at the seek/volume range inputs so arrow keys
|
||||
@@ -145,7 +173,10 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.target.tagName === "INPUT") return;
|
||||
const v = vidRef.current;
|
||||
switch (e.key) {
|
||||
// Fold single-char keys to lowercase so Shift/Caps-Lock doesn't break
|
||||
// letter shortcuts (e.g. Shift+F reporting as "F", not "f").
|
||||
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
switch (key) {
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
@@ -166,20 +197,49 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
break;
|
||||
case "Escape":
|
||||
if (isFullscreen) {
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
}
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}, [togglePlay]);
|
||||
}, [togglePlay, toggleMute, toggleFullscreen, isFullscreen]);
|
||||
|
||||
// #video-block-wrap now wraps both the video area and the controls bar
|
||||
// below it (previously just the video), so fullscreen no longer drops the
|
||||
// seek bar / volume / fullscreen button. isFullscreen also relaxes the
|
||||
// 16/9 + max-height constraints so the video fills the screen properly.
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
// Safari (incl. desktop) still fires the prefixed webkitfullscreenchange
|
||||
// event for element-level fullscreen, so both are listened for.
|
||||
const onChange = () => {
|
||||
const active = !!(document.fullscreenElement || document.webkitFullscreenElement);
|
||||
setNativeFullscreen(active);
|
||||
// Safari doesn't focus a <div tabIndex> on click, so keyboard
|
||||
// shortcuts silently stop working after entering fullscreen unless we
|
||||
// explicitly refocus the wrapper here.
|
||||
if (active) wrapRef.current?.focus({ preventScroll: true });
|
||||
};
|
||||
document.addEventListener("fullscreenchange", onChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onChange);
|
||||
document.addEventListener("webkitfullscreenchange", onChange);
|
||||
return () => {
|
||||
document.removeEventListener("fullscreenchange", onChange);
|
||||
document.removeEventListener("webkitfullscreenchange", onChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// CSS pseudo-fullscreen fallback (iOS Safari) — lock page scroll and
|
||||
// reclaim keyboard focus while it's active, restore on exit/unmount.
|
||||
useEffect(() => {
|
||||
if (!pseudoFullscreen) return;
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
wrapRef.current?.focus({ preventScroll: true });
|
||||
return () => { document.body.style.overflow = prevOverflow; };
|
||||
}, [pseudoFullscreen]);
|
||||
|
||||
// ── Asset picker handler ──────────────────────────────────────────────────
|
||||
//
|
||||
// Saves all metadata needed by the client VideoBlock at render time.
|
||||
@@ -221,16 +281,17 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
) : src ? (
|
||||
<div
|
||||
id="video-block-wrap"
|
||||
ref={wrapRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`overflow-hidden bg-card outline-none focus-visible:ring-2 focus-visible:ring-ring ${isFullscreen ? "fixed inset-0 flex flex-col" : "rounded-lg border"}`}
|
||||
className={`overflow-hidden bg-card outline-none focus-visible:ring-2 focus-visible:ring-ring ${isFullscreen ? "fixed inset-0 z-[100] flex flex-col" : "rounded-lg border"}`}
|
||||
>
|
||||
|
||||
{/* ── Video area ── */}
|
||||
<div
|
||||
className={`relative w-full bg-black cursor-pointer group ${isFullscreen ? "flex-1 min-h-0" : ""}`}
|
||||
style={isFullscreen ? undefined : { aspectRatio: "16/9", maxHeight: "calc(100vh - 260px)" }}
|
||||
onClick={togglePlay}
|
||||
onClick={handleAreaClick}
|
||||
>
|
||||
<video
|
||||
ref={vidRef}
|
||||
|
||||
@@ -25,13 +25,16 @@ function hasLandingPage(ad) {
|
||||
* Banner advertisement carousel — same carousel shell as Hero, so a placement
|
||||
* with several live ads (e.g. tier_plans.banner) rotates through all of them
|
||||
* instead of only ever showing the single highest-priority one.
|
||||
* Each slide's layout is driven by its own content_mode:
|
||||
* "content" — decorative panel with badge/headline/description/CTAs
|
||||
* "image" — full-bleed image, clickable through to the ad's redirect/CTA
|
||||
* Every current-shape ad (content_mode "content") always shows its image with
|
||||
* badge/headline/description layered on top, same as Hero — the whole slide
|
||||
* is clickable through to the ad's redirect_link/landing page whenever it has
|
||||
* no CTA buttons of its own. content_mode "image" is legacy-only (pre-dates
|
||||
* the mandatory badge/headline/description/image/link shape) and keeps
|
||||
* rendering as a bare clickable image with no text overlay.
|
||||
*
|
||||
* Props:
|
||||
* ads — array of advertisement objects { content_mode, badge_labels, headline, description, ctas, image, image_url, advertisement_id }
|
||||
* onCtaClick — (ad, cta) => void. cta is undefined for the whole-banner click on image-only ads.
|
||||
* ads — array of advertisement objects { content_mode, badge_labels, headline, description, ctas, redirect_link, image, image_url, advertisement_id }
|
||||
* onCtaClick — (ad, cta) => void. cta is undefined for the whole-slide click.
|
||||
*/
|
||||
export function Banner({ ads, onCtaClick }) {
|
||||
const [api, setApi] = useState();
|
||||
@@ -63,12 +66,16 @@ export function Banner({ ads, onCtaClick }) {
|
||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||
const badgeLabels = Array.isArray(ad.badge_labels) ? ad.badge_labels : [];
|
||||
|
||||
// Whole-slide click-through only makes sense when there's no CTA
|
||||
// row of its own to carry that responsibility instead — a landing
|
||||
// page (Page Builder content) always wins over the plain redirect.
|
||||
const clickable = ctas.length === 0 && (hasLandingPage(ad) || ad.redirect_link);
|
||||
const handleSlideClick = () => (hasLandingPage(ad) ? setViewAd(ad) : onCtaClick?.(ad, undefined));
|
||||
|
||||
return (
|
||||
<CarouselItem key={ad.advertisement_id}>
|
||||
{ad.content_mode !== "content" ? (
|
||||
// ── Full Image ──
|
||||
// A landing page (Page Builder content) always wins over the CTA
|
||||
// click-through — it's the only destination this ad actually has.
|
||||
// ── Full Image (legacy Image Only ads with no badge/headline/description) ──
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (hasLandingPage(ad) ? setViewAd(ad) : onCtaClick?.(ad, ctas[0]))}
|
||||
@@ -85,21 +92,41 @@ export function Banner({ ads, onCtaClick }) {
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
// ── Content + Image ──
|
||||
<div className="tier_plans_banner w-full rounded-xl xs:p-5 sm:p-4 lg:p-10 text-white">
|
||||
<div className="relative w-full items-start flex justify-between">
|
||||
// ── Image + Content overlay ──
|
||||
<div
|
||||
role={clickable ? "button" : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onClick={clickable ? handleSlideClick : undefined}
|
||||
onKeyDown={clickable ? (e) => { if (e.key === "Enter" || e.key === " ") handleSlideClick(); } : undefined}
|
||||
className={"relative w-full rounded-xl overflow-hidden border text-left h-[clamp(180px,22vw,320px)]" + (clickable ? " cursor-pointer" : "")}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={ad.headline || "Advertisement"}
|
||||
className="absolute inset-0 w-full h-full object-cover pointer-events-none select-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-muted flex items-center justify-center">
|
||||
<Megaphone className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent" />
|
||||
|
||||
<div className="relative h-full w-full flex items-center xs:p-5 sm:p-4 lg:p-10 text-white overflow-hidden">
|
||||
<div className="md:w-2xl space-y-4 xs:p-1.5 lg:p-0">
|
||||
{badgeLabels.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{badgeLabels.map((label, i) => (
|
||||
<Badge key={i} variant="outline" className="border-white/50 bg-white/10 text-white">
|
||||
<Badge key={i} variant="outline" className="pointer-events-none select-none border-white/50 bg-white/10 text-white">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{ad.headline && <div className="lg:text-5xl xs:text-4xl font-tier-ads lg:leading-16">{ad.headline}</div>}
|
||||
{ad.description && <p className="leading-relaxed text-lg">{ad.description}</p>}
|
||||
{ad.headline && <div className="pointer-events-none select-none lg:text-5xl xs:text-4xl font-geist font-bold lg:leading-16">{ad.headline}</div>}
|
||||
{ad.description && <p className="pointer-events-none select-none leading-relaxed text-lg line-clamp-2">{ad.description}</p>}
|
||||
|
||||
{ctas.length > 0 && (
|
||||
<div className="flex gap-3 items-center">
|
||||
@@ -115,17 +142,6 @@ export function Banner({ ads, onCtaClick }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="lg:absolute lg:-top-4 lg:-right-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={hasLandingPage(ad) ? "View advertisement details" : "Advertisement"}
|
||||
className={hasLandingPage(ad) ? undefined : "pointer-events-none"}
|
||||
onClick={() => hasLandingPage(ad) && setViewAd(ad)}
|
||||
>
|
||||
View <Megaphone />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -25,12 +25,14 @@ function hasLandingPage(ad) {
|
||||
/**
|
||||
* Hero advertisement carousel.
|
||||
* Each slide is a full-bleed background image with a bottom gradient overlay,
|
||||
* badge/headline/description/CTAs anchored bottom-left. Renders null when no
|
||||
* ads are given — callers should not fall back to placeholder copy.
|
||||
* badge/headline/description/CTAs anchored bottom-left. The whole slide is
|
||||
* clickable through to the ad's redirect_link/landing page whenever it has no
|
||||
* CTA buttons of its own. Renders null when no ads are given — callers should
|
||||
* not fall back to placeholder copy.
|
||||
*
|
||||
* Props:
|
||||
* ads — array of advertisement objects { badge_labels, headline, description, ctas, image, image_url, advertisement_id }
|
||||
* onCtaClick — (ad, cta) => void, called when any CTA button is clicked
|
||||
* ads — array of advertisement objects { badge_labels, headline, description, ctas, redirect_link, image, image_url, advertisement_id }
|
||||
* onCtaClick — (ad, cta) => void. cta is undefined for the whole-slide click.
|
||||
*/
|
||||
export function Hero({ ads, onCtaClick }) {
|
||||
const [api, setApi] = useState();
|
||||
@@ -62,10 +64,22 @@ export function Hero({ ads, onCtaClick }) {
|
||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||
const badgeLabels = Array.isArray(ad.badge_labels) ? ad.badge_labels : [];
|
||||
|
||||
// Whole-slide click-through only makes sense when there's no CTA
|
||||
// row of its own to carry that responsibility instead — a landing
|
||||
// page (Page Builder content) always wins over the plain redirect.
|
||||
const clickable = ctas.length === 0 && (hasLandingPage(ad) || ad.redirect_link);
|
||||
const handleSlideClick = () => (hasLandingPage(ad) ? setViewAd(ad) : onCtaClick?.(ad, undefined));
|
||||
|
||||
return (
|
||||
<CarouselItem key={ad.advertisement_id}>
|
||||
<Card className="border rounded-2xl overflow-hidden pl-0 py-0">
|
||||
<CardContent className="relative xs:h-64 lg:h-96 bg-muted flex items-center justify-center">
|
||||
<CardContent
|
||||
role={clickable ? "button" : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onClick={clickable ? handleSlideClick : undefined}
|
||||
onKeyDown={clickable ? (e) => { if (e.key === "Enter" || e.key === " ") handleSlideClick(); } : undefined}
|
||||
className={"relative xs:h-64 lg:h-96 bg-muted flex items-center justify-center" + (clickable ? " cursor-pointer" : "")}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
|
||||
@@ -254,7 +254,7 @@ export function AudioBlock({ content, onWatchProgress, resumePercent, antiSkipEn
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-muted" />
|
||||
<div className="absolute inset-0 bg-primary" />
|
||||
)}
|
||||
|
||||
{/* Mobile */}
|
||||
@@ -334,7 +334,15 @@ export function AudioBlock({ content, onWatchProgress, resumePercent, antiSkipEn
|
||||
<button onClick={toggleMute} aria-label={muted ? "Unmute" : "Mute"} className="w-7 h-7 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors">
|
||||
{muted || volume === 0 ? <VolumeOff className="size-4" /> : <Volume2 className="size-4" />}
|
||||
</button>
|
||||
<input type="range" min="0" max="1" step="0.05" value={muted ? 0 : volume} onChange={handleVolume} aria-label="Volume" className="w-16 h-1.5" />
|
||||
<input
|
||||
type="range" min="0" max="1" step="0.05"
|
||||
value={muted ? 0 : volume}
|
||||
onChange={handleVolume}
|
||||
aria-label="Volume"
|
||||
className="w-16 h-1.5 accent-primary cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:cursor-pointer
|
||||
[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center — play controls */}
|
||||
|
||||
@@ -177,7 +177,13 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
|
||||
const [ended, setEnded] = useState(false);
|
||||
const [overlayVisible, setOverlayVisible] = useState(true);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
// Native Fullscreen API works on desktop (all browsers) and Android Chrome.
|
||||
// iOS Safari doesn't support Fullscreen API on arbitrary elements at all, so
|
||||
// pseudoFullscreen is a CSS-only (fixed inset-0) fallback that keeps our
|
||||
// custom controls instead of falling back to native <video> fullscreen.
|
||||
const [nativeFullscreen, setNativeFullscreen] = useState(false);
|
||||
const [pseudoFullscreen, setPseudoFullscreen] = useState(false);
|
||||
const isFullscreen = nativeFullscreen || pseudoFullscreen;
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [volumePanelOpen, setVolumePanelOpen] = useState(false);
|
||||
const [keyFeedback, setKeyFeedback] = useState(null);
|
||||
@@ -343,11 +349,34 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
|
||||
}, [speed]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
// Safari (incl. desktop) still fires the prefixed webkitfullscreenchange
|
||||
// event for element-level fullscreen, so both are listened for.
|
||||
const onChange = () => {
|
||||
const active = !!(document.fullscreenElement || document.webkitFullscreenElement);
|
||||
setNativeFullscreen(active);
|
||||
// Safari doesn't focus a <div tabIndex> on click, so keyboard
|
||||
// shortcuts silently stop working after entering fullscreen unless
|
||||
// we explicitly refocus the wrapper here.
|
||||
if (active) wrapRef.current?.focus({ preventScroll: true });
|
||||
};
|
||||
document.addEventListener("fullscreenchange", onChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onChange);
|
||||
document.addEventListener("webkitfullscreenchange", onChange);
|
||||
return () => {
|
||||
document.removeEventListener("fullscreenchange", onChange);
|
||||
document.removeEventListener("webkitfullscreenchange", onChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// CSS pseudo-fullscreen fallback (iOS Safari) — lock page scroll and
|
||||
// reclaim keyboard focus while it's active, restore on exit/unmount.
|
||||
useEffect(() => {
|
||||
if (!pseudoFullscreen) return;
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
wrapRef.current?.focus({ preventScroll: true });
|
||||
return () => { document.body.style.overflow = prevOverflow; };
|
||||
}, [pseudoFullscreen]);
|
||||
|
||||
const resetHideTimer = useCallback(() => {
|
||||
setControlsVisible(true);
|
||||
clearTimeout(hideTimer.current);
|
||||
@@ -377,6 +406,19 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
|
||||
else { v.pause(); setPlaying(false); setOverlayVisible(true); }
|
||||
}, []);
|
||||
|
||||
// YouTube-style tap-to-reveal: while playing with the bar auto-hidden, the
|
||||
// first tap only brings the controls back (doesn't pause); a second tap —
|
||||
// once controls are already visible — toggles play, same as desktop where
|
||||
// onMouseMove already keeps controls visible ahead of the click.
|
||||
const handleWrapperClick = useCallback(() => {
|
||||
// Safari doesn't focus a <div tabIndex> on click, which silently kills
|
||||
// keyboard shortcuts afterwards — force it explicitly.
|
||||
wrapRef.current?.focus({ preventScroll: true });
|
||||
if (!controlsVisible) { resetHideTimer(); return; }
|
||||
togglePlay();
|
||||
resetHideTimer();
|
||||
}, [controlsVisible, resetHideTimer, togglePlay]);
|
||||
|
||||
const restart = () => {
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
@@ -409,13 +451,27 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
|
||||
const toggleFullscreen = () => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else el.requestFullscreen?.();
|
||||
if (isFullscreen) {
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else if (document.webkitFullscreenElement) document.webkitExitFullscreen();
|
||||
else setPseudoFullscreen(false);
|
||||
} else if (el.requestFullscreen) {
|
||||
el.requestFullscreen();
|
||||
} else if (el.webkitRequestFullscreen) {
|
||||
// Desktop Safari — supports element fullscreen via the prefixed API.
|
||||
el.webkitRequestFullscreen();
|
||||
} else {
|
||||
// iOS Safari has no element-level Fullscreen API at all.
|
||||
setPseudoFullscreen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.target.tagName === "INPUT") return;
|
||||
switch (e.key) {
|
||||
// Fold single-char keys to lowercase so Shift/Caps-Lock doesn't break
|
||||
// letter shortcuts (e.g. Shift+F reporting as "F", not "f").
|
||||
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||||
switch (key) {
|
||||
case " ": case "k":
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
@@ -460,6 +516,13 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
|
||||
toggleFullscreen();
|
||||
showFeedback(isFullscreen ? <Minimize2 className="size-7 text-white" /> : <Maximize2 className="size-7 text-white" />, isFullscreen ? "Exit fullscreen" : "Fullscreen");
|
||||
break;
|
||||
case "Escape":
|
||||
if (isFullscreen) {
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
showFeedback(<Minimize2 className="size-7 text-white" />, "Exit fullscreen");
|
||||
}
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}, [togglePlay, toggleMute, toggleFullscreen, resetHideTimer, volume, muted, isFullscreen, playing, showFeedback, guard]);
|
||||
@@ -492,11 +555,11 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
|
||||
<div
|
||||
ref={wrapRef}
|
||||
tabIndex={0}
|
||||
className="relative w-full rounded-lg overflow-hidden bg-black select-none outline-none"
|
||||
className={`relative w-full overflow-hidden bg-black select-none outline-none ${pseudoFullscreen ? "fixed inset-0 z-[100]" : "rounded-lg"}`}
|
||||
style={{ aspectRatio: isFullscreen ? undefined : "16/9" }}
|
||||
onMouseMove={resetHideTimer}
|
||||
onMouseLeave={() => { if (playing) setControlsVisible(false); }}
|
||||
onClick={() => { togglePlay(); resetHideTimer(); }}
|
||||
onClick={handleWrapperClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
|
||||
@@ -70,11 +70,13 @@ export function AdminTiersProvider({ children }) {
|
||||
} finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
// Archiving always force-revokes current subscribers' access (no refund) —
|
||||
// handled entirely server-side, not an opt-in from here.
|
||||
const deletePlan = useCallback(async (id) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.delete(`/admin/tiers/${id}`);
|
||||
toast("Plan archived.");
|
||||
toast("Plan archived. Subscriber access has been revoked.");
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast(err?.response?.data?.message ?? "Could not archive plan.");
|
||||
@@ -98,7 +100,7 @@ export function AdminTiersProvider({ children }) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post("/admin/tiers/bulk/archive", { ids });
|
||||
toast("Plans archived.");
|
||||
toast("Plans archived. Subscriber access has been revoked.");
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast(err?.response?.data?.message ?? "Could not archive plans.");
|
||||
@@ -142,13 +144,16 @@ export function AdminTiersProvider({ children }) {
|
||||
} finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
// Permanently deleting an archived plan now also auto-revokes any remaining
|
||||
// active subscribers (no refund) and deletes payment records — both are
|
||||
// automatic consequences of the delete action, not separate opt-ins.
|
||||
const fetchPlanPermanentDeleteImpact = useCallback(async (id) => {
|
||||
try {
|
||||
const { data } = await api.get(`/admin/tiers/${id}/permanent-delete-impact`);
|
||||
const { active_subscriber_count, payment_count } = data?.data ?? {};
|
||||
return [
|
||||
{ label: "active subscriber(s)", count: active_subscriber_count ?? 0 },
|
||||
{ label: "payment record(s) on file — deletion will be blocked while these exist", count: payment_count ?? 0 },
|
||||
{ label: "active subscriber(s) — access will be revoked, no refund", count: active_subscriber_count ?? 0 },
|
||||
{ label: "payment record(s) on file — will be permanently deleted along with the plan", count: payment_count ?? 0 },
|
||||
];
|
||||
} catch {
|
||||
return [];
|
||||
|
||||
@@ -15,14 +15,6 @@ export const ADVERTISEMENT_TYPE_MAP = Object.fromEntries(
|
||||
ADVERTISEMENT_TYPES.map((t) => [t.value, t])
|
||||
);
|
||||
|
||||
// ─── Content modes ──────────────────────────────────────────────────────────
|
||||
// Whether an ad is image-only or carries badge/headline/description/CTAs
|
||||
// alongside the image — an explicit admin choice, decoupled from placement/format.
|
||||
export const CONTENT_MODES = [
|
||||
{ value: "image", label: "Image Only" },
|
||||
{ value: "content", label: "Text with Image" },
|
||||
];
|
||||
|
||||
// ─── Statuses ───────────────────────────────────────────────────────────────
|
||||
// Drives: filter dropdown options, status badge color/label on each card.
|
||||
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
// AccessRuleItemPicker — simple multi-select of specific courses/units/lessons
|
||||
// at a chosen subscription level, for the "item_allowlist" access rule type.
|
||||
//
|
||||
// Deliberately NOT CoursePicker/UnitPicker/LessonPicker — those are tightly
|
||||
// coupled to the Bundles feature's plan-ownership-conflict logic ("this course
|
||||
// is already bundled into another plan"), which doesn't apply here. This is
|
||||
// just "pick some items", reusing the same Popover+Command+Checkbox+ScrollArea
|
||||
// primitives those pickers use.
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { ChevronsUpDown, BookOpen } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
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";
|
||||
|
||||
const MAX_ITEMS = 3;
|
||||
|
||||
const ITEM_TYPE_CONFIG = {
|
||||
course: { endpoint: "/admin/courses/by-subscription", idField: "course_id", nounSingular: "course", nounPlural: "courses" },
|
||||
unit: { endpoint: "/admin/units/by-subscription", idField: "unit_id", nounSingular: "unit", nounPlural: "units" },
|
||||
lesson: { endpoint: "/admin/lessons/by-subscription", idField: "lesson_id", nounSingular: "lesson", nounPlural: "lessons" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Props:
|
||||
* itemType — 'course' | 'unit' | 'lesson'
|
||||
* subscriptionSlug — tier slug to browse (e.g. "exclusive"). Null = picker hidden.
|
||||
* selectedIds — string[] of currently-selected item ids
|
||||
* onChange — (string[]) => void
|
||||
*/
|
||||
export function AccessRuleItemPicker({ itemType, subscriptionSlug, selectedIds, onChange }) {
|
||||
const config = ITEM_TYPE_CONFIG[itemType];
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!subscriptionSlug) { setItems([]); return; }
|
||||
setLoading(true);
|
||||
setSearch("");
|
||||
api.get(`${config.endpoint}?slug=${encodeURIComponent(subscriptionSlug)}`)
|
||||
.then(({ data }) => setItems(data.data ?? []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [itemType, subscriptionSlug]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter((it) => it.title?.toLowerCase().includes(q));
|
||||
}, [items, search]);
|
||||
|
||||
const toggle = (id) => {
|
||||
const set = new Set(selectedIds);
|
||||
if (set.has(id)) {
|
||||
set.delete(id);
|
||||
} else {
|
||||
if (set.size >= MAX_ITEMS) {
|
||||
toast(`You can select at most ${MAX_ITEMS} ${config.nounPlural}.`);
|
||||
return;
|
||||
}
|
||||
set.add(id);
|
||||
}
|
||||
onChange([...set]);
|
||||
};
|
||||
|
||||
const total = items.length;
|
||||
const selectedCount = selectedIds.length;
|
||||
|
||||
if (!subscriptionSlug) return null;
|
||||
|
||||
if (loading) {
|
||||
return <Skeleton className="h-8 w-full" />;
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
|
||||
<BookOpen className="size-3.5 shrink-0" />
|
||||
No {config.nounPlural} found at this subscription level.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const atCap = selectedCount >= MAX_ITEMS;
|
||||
|
||||
return (
|
||||
<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 && "text-muted-foreground")}
|
||||
>
|
||||
{selectedCount === 0
|
||||
? `Select ${config.nounPlural}… (max ${MAX_ITEMS})`
|
||||
: `${selectedCount} of ${MAX_ITEMS} max 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 ${config.nounPlural}…`} value={search} onValueChange={setSearch} />
|
||||
<CommandList>
|
||||
{filtered.length === 0 ? (
|
||||
<CommandEmpty>No {config.nounPlural} match your search.</CommandEmpty>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
{filtered.map((item) => {
|
||||
const id = String(item[config.idField]);
|
||||
const checked = selectedIds.includes(id);
|
||||
const disabled = !checked && atCap;
|
||||
return (
|
||||
<CommandItem
|
||||
key={id}
|
||||
value={id}
|
||||
onSelect={() => toggle(id)}
|
||||
disabled={disabled}
|
||||
className={cn("flex items-center gap-3 px-3 py-2.5 cursor-pointer", disabled && "opacity-50")}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={() => toggle(id)}
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-sm font-medium leading-snug line-clamp-1">{item.title}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{atCap ? `Maximum of ${MAX_ITEMS} reached.` : `Choose up to ${MAX_ITEMS} ${config.nounPlural}.`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -160,7 +160,9 @@ export default function ArchivedTierPlansTable() {
|
||||
onSuccess={handleRestoreSuccess}
|
||||
/>
|
||||
|
||||
{/* Single permanent delete */}
|
||||
{/* Single permanent delete — also auto-revokes any remaining active
|
||||
subscribers (no refund) and deletes payment records, both handled
|
||||
server-side as part of the delete itself. */}
|
||||
<PermanentDeleteDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(v) => !v && setDeleteTarget(null)}
|
||||
|
||||
@@ -1,154 +1,27 @@
|
||||
// BundlesCell — Tier Plans table cell showing course/unit/lesson totals for a
|
||||
// plan, with a "View" trigger that lazy-loads and lists everything included.
|
||||
// BundlesCell — Tier Plans table cell showing the bundled content count for a
|
||||
// plan. A bundle is single-type (Tier Plans v2), so only one of
|
||||
// courseCount/unitCount/lessonCount is ever non-zero — show that one.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { BookOpen, Book, BookOpenCheck, Eye, FileText } from "lucide-react";
|
||||
|
||||
import api from "@/utils/api.util";
|
||||
import { BookOpen, Book, BookOpenCheck } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
function CountBadge({ icon: Icon, count, singular, plural }) {
|
||||
const BUNDLE_KINDS = [
|
||||
{ key: "courseCount", icon: BookOpen, singular: "course", plural: "courses" },
|
||||
{ key: "unitCount", icon: Book, singular: "unit", plural: "units" },
|
||||
{ key: "lessonCount", icon: BookOpenCheck, singular: "lesson", plural: "lessons" },
|
||||
];
|
||||
|
||||
export default function BundlesCell({ plan }) {
|
||||
const active = BUNDLE_KINDS.find((k) => parseInt(plan[k.key] ?? 0, 10) > 0) ?? BUNDLE_KINDS[0];
|
||||
const count = parseInt(plan[active.key] ?? 0, 10);
|
||||
const Icon = active.icon;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||
{count} {count === 1 ? singular : plural}
|
||||
{count} {count === 1 ? active.singular : active.plural}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BundleSection({ icon: Icon, title, loading, items, emptyLabel, renderItem, keyField }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{title} ({items.length})
|
||||
</p>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{[...Array(2)].map((_, i) => <Skeleton key={i} className="h-9 w-full" />)}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
{emptyLabel}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y rounded-lg border overflow-hidden">
|
||||
{items.map((item) => (
|
||||
<div key={item[keyField]} className="flex items-center gap-3 px-3 py-2 bg-card">
|
||||
<div className="h-7 w-7 rounded-md bg-muted flex items-center justify-center shrink-0">
|
||||
<Icon className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BundlesCell({ plan }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [courses, setCourses] = useState([]);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [lessons, setLessons] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
api.get(`/admin/tiers/${plan.plan_id}/courses`).then(({ data }) => setCourses(data.data ?? [])).catch(() => setCourses([])),
|
||||
api.get(`/admin/tiers/${plan.plan_id}/units`).then(({ data }) => setUnits(data.data ?? [])).catch(() => setUnits([])),
|
||||
api.get(`/admin/tiers/${plan.plan_id}/lessons`).then(({ data }) => setLessons(data.data ?? [])).catch(() => setLessons([])),
|
||||
]).finally(() => setLoading(false));
|
||||
}, [open, plan.plan_id]);
|
||||
|
||||
const courseCount = parseInt(plan.courseCount ?? 0, 10);
|
||||
const unitCount = parseInt(plan.unitCount ?? 0, 10);
|
||||
const lessonCount = parseInt(plan.lessonCount ?? 0, 10);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<CountBadge icon={BookOpen} count={courseCount} singular="course" plural="courses" />
|
||||
<CountBadge icon={Book} count={unitCount} singular="unit" plural="units" />
|
||||
<CountBadge icon={BookOpenCheck} count={lessonCount} singular="lesson" plural="lessons" />
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setOpen(true)}>
|
||||
<Eye className="size-3.5" /> View
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bundled Content — {plan.label}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Everything this plan unlocks for subscribers.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-96 pr-3">
|
||||
<div className="space-y-5">
|
||||
<BundleSection
|
||||
icon={BookOpen}
|
||||
title="Courses"
|
||||
loading={loading}
|
||||
items={courses}
|
||||
emptyLabel="No courses assigned to this plan yet."
|
||||
keyField="course_id"
|
||||
renderItem={(course) => (
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium line-clamp-1">{course.title}</p>
|
||||
{(course.course_code || course.level) && (
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{course.course_code && (
|
||||
<span className="text-xs text-muted-foreground">{course.course_code}</span>
|
||||
)}
|
||||
{course.level && (
|
||||
<Badge variant="outline" className="text-xs capitalize h-4 px-1.5">{course.level}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<BundleSection
|
||||
icon={Book}
|
||||
title="Units"
|
||||
loading={loading}
|
||||
items={units}
|
||||
emptyLabel="No units assigned to this plan yet."
|
||||
keyField="unit_id"
|
||||
renderItem={(unit) => (
|
||||
<p className="text-sm font-medium line-clamp-1">{unit.title}</p>
|
||||
)}
|
||||
/>
|
||||
|
||||
<BundleSection
|
||||
icon={FileText}
|
||||
title="Lessons"
|
||||
loading={loading}
|
||||
items={lessons}
|
||||
emptyLabel="No lessons assigned to this plan yet."
|
||||
keyField="lesson_id"
|
||||
renderItem={(lesson) => (
|
||||
<p className="text-sm font-medium line-clamp-1">{lesson.title}</p>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { ChevronsUpDown, Check, BookOpen, AlertTriangle } from "lucide-react";
|
||||
import { ChevronsUpDown, BookOpen } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -7,7 +7,6 @@ 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";
|
||||
|
||||
/**
|
||||
* CoursePicker
|
||||
@@ -16,60 +15,30 @@ import { cn } from "@/lib/utils";
|
||||
* subscription — tier slug ("premium"). Null/undefined = hidden.
|
||||
* selectedIds — Set<string> of selected course_id strings (managed by parent)
|
||||
* onChange — (Set<string>) => void
|
||||
* isPreloaded — true in EditPlan (CoursePicker only mounts AFTER existing
|
||||
* assignments are already in selectedIds, so no race condition).
|
||||
* false in AddPlan (always bundle all on first load).
|
||||
* currentPlanId — plan being edited (undefined in AddPlan). A course already
|
||||
* assigned to a DIFFERENT plan is flagged as a conflict, since
|
||||
* plan_courses.course_id is UNIQUE — a course belongs to at
|
||||
* most one plan, and reassigning it here silently steals it
|
||||
* away from that plan on save.
|
||||
* onConflictsChange — (count: number) => void. Called whenever the number of
|
||||
* currently-SELECTED courses that conflict with another plan
|
||||
* changes, so the parent can block submission until resolved.
|
||||
*
|
||||
* Flow:
|
||||
* • Shows "Bundle all?" question with two buttons.
|
||||
* • "Yes, include all" → selects every course in the tier, hides picker.
|
||||
* • "No, choose specific" → opens a Popover with Command+Search+Checkboxes.
|
||||
* • Selected courses already owned by another plan are called out, and the
|
||||
* parent is expected to disable submission until they're unchecked.
|
||||
* A course may already belong to any number of other plans — that's allowed
|
||||
* (Tier Plans v2, silent duplication across bundles is intentional), so
|
||||
* there's no conflict state to surface or resolve here.
|
||||
*
|
||||
* AddPlan starts with no courses selected; the admin picks specific ones
|
||||
* directly in the dropdown. EditPlan preserves whatever was already assigned.
|
||||
*/
|
||||
export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded = false, currentPlanId, onConflictsChange }) {
|
||||
export function CoursePicker({ subscription, selectedIds, onChange }) {
|
||||
const [courses, setCourses] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [bundleAll, setBundleAll] = useState(true);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!subscription) { setCourses([]); setBundleAll(true); return; }
|
||||
if (!subscription) { setCourses([]); return; }
|
||||
setLoading(true);
|
||||
setSearch("");
|
||||
setBundleAll(true); // reset question to "Yes" whenever subscription changes
|
||||
|
||||
api.get(`/admin/courses/by-subscription?slug=${encodeURIComponent(subscription)}`)
|
||||
.then(({ data }) => {
|
||||
const loaded = data.data ?? [];
|
||||
setCourses(loaded);
|
||||
|
||||
if (!isPreloaded) {
|
||||
// AddPlan: bundle all by default
|
||||
setBundleAll(true);
|
||||
onChange(new Set(loaded.map((c) => String(c.course_id))));
|
||||
} else {
|
||||
// EditPlan: CoursePicker mounts only after assignments loaded into selectedIds.
|
||||
// Detect initial mode from current selectedIds vs total courses.
|
||||
const size = selectedIds.size;
|
||||
if (size > 0 && size < loaded.length) {
|
||||
// Partial selection saved previously → specific mode
|
||||
setBundleAll(false);
|
||||
} else {
|
||||
// All selected, or none (no courses assigned yet) → bundle all
|
||||
setBundleAll(true);
|
||||
onChange(new Set(loaded.map((c) => String(c.course_id))));
|
||||
}
|
||||
}
|
||||
// AddPlan starts with none selected; EditPlan keeps its existing selection as-is.
|
||||
})
|
||||
.catch(() => setCourses([]))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -85,29 +54,6 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
|
||||
);
|
||||
}, [courses, search]);
|
||||
|
||||
// Courses already owned by a DIFFERENT plan — selecting them here will move them.
|
||||
const isConflict = (course) =>
|
||||
course.assigned_plan && String(course.assigned_plan.plan_id) !== String(currentPlanId ?? "");
|
||||
|
||||
// Only courses actually SELECTED matter — unchecking a conflicting course clears it.
|
||||
const conflicts = useMemo(
|
||||
() => courses.filter((c) => isConflict(c) && selectedIds.has(String(c.course_id))),
|
||||
[courses, currentPlanId, selectedIds] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
|
||||
const conflictsByPlan = useMemo(() => {
|
||||
const map = new Map();
|
||||
conflicts.forEach((c) => {
|
||||
const label = c.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);
|
||||
@@ -118,18 +64,6 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
|
||||
const checkAll = () => onChange(new Set(courses.map((c) => String(c.course_id))));
|
||||
const resetAll = () => onChange(new Set());
|
||||
|
||||
// "Yes, include all" clicked
|
||||
const handleBundleAll = () => {
|
||||
setBundleAll(true);
|
||||
setPopoverOpen(false);
|
||||
onChange(new Set(courses.map((c) => String(c.course_id))));
|
||||
};
|
||||
|
||||
// "No, choose specific" clicked — keeps current selection (all) so admin can uncheck specific ones
|
||||
const handleSelectSpecific = () => {
|
||||
setBundleAll(false);
|
||||
};
|
||||
|
||||
const total = courses.length;
|
||||
const selectedCount = selectedIds.size;
|
||||
|
||||
@@ -138,78 +72,16 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
|
||||
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" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-sm">
|
||||
Bundle <span className="font-semibold capitalize">{subscription}</span> courses 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> course{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} course{conflicts.length !== 1 ? "s" : ""} already belong{conflicts.length === 1 ? "s" : ""} to another plan.
|
||||
</p>
|
||||
<p>
|
||||
A course 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 courses in tier ───────────────────────────────────────── */}
|
||||
{!loading && total === 0 && (
|
||||
) : 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> courses found. Add courses 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>
|
||||
@@ -217,10 +89,7 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"w-full justify-between gap-2",
|
||||
selectedCount === 0 && "border-destructive text-destructive hover:border-destructive"
|
||||
)}
|
||||
className="w-full justify-between gap-2"
|
||||
>
|
||||
{selectedCount === 0
|
||||
? "No courses selected"
|
||||
@@ -245,7 +114,6 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
|
||||
{filtered.map((course) => {
|
||||
const id = String(course.course_id);
|
||||
const checked = selectedIds.has(id);
|
||||
const conflict = isConflict(course);
|
||||
return (
|
||||
<CommandItem
|
||||
key={id}
|
||||
@@ -266,12 +134,6 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
|
||||
{course.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 "{course.assigned_plan.label}"
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
@@ -300,8 +162,7 @@ export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded
|
||||
</Popover>
|
||||
|
||||
{selectedCount === 0 && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-destructive">
|
||||
<AlertTriangle className="size-3.5 shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select at least one course to bundle with this plan.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { ChevronsUpDown, Check, FileText, AlertTriangle } from "lucide-react";
|
||||
import { ChevronsUpDown, FileText } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -7,51 +7,36 @@ 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.
|
||||
* Props: same contract as CoursePicker (subscription, selectedIds, onChange)
|
||||
* — see CoursePicker.jsx for the full doc comment, not repeated here.
|
||||
* A lesson may already belong to any number of other plans — that's
|
||||
* allowed (Tier Plans v2, silent duplication across bundles is intentional),
|
||||
* so there's no conflict state to surface or resolve here.
|
||||
*
|
||||
* AddPlan starts with no lessons selected; the admin picks specific ones
|
||||
* directly in the dropdown. EditPlan preserves whatever was already assigned.
|
||||
*/
|
||||
export function LessonPicker({ subscription, selectedIds, onChange, isPreloaded = false, currentPlanId, onConflictsChange }) {
|
||||
export function LessonPicker({ subscription, selectedIds, onChange }) {
|
||||
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; }
|
||||
if (!subscription) { setLessons([]); 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))));
|
||||
}
|
||||
}
|
||||
// AddPlan starts with none selected; EditPlan keeps its existing selection as-is.
|
||||
})
|
||||
.catch(() => setLessons([]))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -67,29 +52,6 @@ export function LessonPicker({ subscription, selectedIds, onChange, isPreloaded
|
||||
);
|
||||
}, [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);
|
||||
@@ -100,18 +62,6 @@ export function LessonPicker({ subscription, selectedIds, onChange, isPreloaded
|
||||
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;
|
||||
|
||||
@@ -120,78 +70,16 @@ export function LessonPicker({ subscription, selectedIds, onChange, isPreloaded
|
||||
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" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</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 && (
|
||||
) : 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>
|
||||
@@ -199,10 +87,7 @@ export function LessonPicker({ subscription, selectedIds, onChange, isPreloaded
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"w-full justify-between gap-2",
|
||||
selectedCount === 0 && "border-destructive text-destructive hover:border-destructive"
|
||||
)}
|
||||
className="w-full justify-between gap-2"
|
||||
>
|
||||
{selectedCount === 0
|
||||
? "No lessons selected"
|
||||
@@ -227,7 +112,6 @@ export function LessonPicker({ subscription, selectedIds, onChange, isPreloaded
|
||||
{filtered.map((lesson) => {
|
||||
const id = String(lesson.lesson_id);
|
||||
const checked = selectedIds.has(id);
|
||||
const conflict = isConflict(lesson);
|
||||
return (
|
||||
<CommandItem
|
||||
key={id}
|
||||
@@ -248,12 +132,6 @@ export function LessonPicker({ subscription, selectedIds, onChange, isPreloaded
|
||||
{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>
|
||||
);
|
||||
@@ -282,8 +160,7 @@ export function LessonPicker({ subscription, selectedIds, onChange, isPreloaded
|
||||
</Popover>
|
||||
|
||||
{selectedCount === 0 && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-destructive">
|
||||
<AlertTriangle className="size-3.5 shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select at least one lesson to bundle with this plan.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -141,7 +141,7 @@ export default function TierPlansTable() {
|
||||
emptyMessage="No tier plans found."
|
||||
/>
|
||||
|
||||
{/* Single archive */}
|
||||
{/* Single archive — always force-revokes current subscribers' access (no refund), handled server-side */}
|
||||
<ArchiveDialog
|
||||
open={!!archiveTarget}
|
||||
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
||||
@@ -153,7 +153,7 @@ export default function TierPlansTable() {
|
||||
onSuccess={handleSuccess}
|
||||
onImpactCheck={async () => {
|
||||
const { data } = await api.get(`/admin/tiers/${archiveTarget?.plan_id}/impact`);
|
||||
return [{ label: "active subscriber(s) on this plan", count: data.data?.active_subscriber_count ?? 0 }];
|
||||
return [{ label: "active subscriber(s) on this plan — access will be revoked, no refund", count: data.data?.active_subscriber_count ?? 0 }];
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { ChevronsUpDown, Check, BookOpen, AlertTriangle } from "lucide-react";
|
||||
import { ChevronsUpDown, BookOpen } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -7,51 +7,36 @@ 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.
|
||||
* Props: same contract as CoursePicker (subscription, selectedIds, onChange)
|
||||
* — see CoursePicker.jsx for the full doc comment, not repeated here.
|
||||
* A unit may already belong to any number of other plans — that's
|
||||
* allowed (Tier Plans v2, silent duplication across bundles is intentional),
|
||||
* so there's no conflict state to surface or resolve here.
|
||||
*
|
||||
* AddPlan starts with no units selected; the admin picks specific ones
|
||||
* directly in the dropdown. EditPlan preserves whatever was already assigned.
|
||||
*/
|
||||
export function UnitPicker({ subscription, selectedIds, onChange, isPreloaded = false, currentPlanId, onConflictsChange }) {
|
||||
export function UnitPicker({ subscription, selectedIds, onChange }) {
|
||||
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; }
|
||||
if (!subscription) { setUnits([]); 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))));
|
||||
}
|
||||
}
|
||||
// AddPlan starts with none selected; EditPlan keeps its existing selection as-is.
|
||||
})
|
||||
.catch(() => setUnits([]))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -67,29 +52,6 @@ export function UnitPicker({ subscription, selectedIds, onChange, isPreloaded =
|
||||
);
|
||||
}, [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);
|
||||
@@ -100,18 +62,6 @@ export function UnitPicker({ subscription, selectedIds, onChange, isPreloaded =
|
||||
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;
|
||||
|
||||
@@ -120,78 +70,16 @@ export function UnitPicker({ subscription, selectedIds, onChange, isPreloaded =
|
||||
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" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</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 && (
|
||||
) : 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>
|
||||
@@ -199,10 +87,7 @@ export function UnitPicker({ subscription, selectedIds, onChange, isPreloaded =
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"w-full justify-between gap-2",
|
||||
selectedCount === 0 && "border-destructive text-destructive hover:border-destructive"
|
||||
)}
|
||||
className="w-full justify-between gap-2"
|
||||
>
|
||||
{selectedCount === 0
|
||||
? "No units selected"
|
||||
@@ -227,7 +112,6 @@ export function UnitPicker({ subscription, selectedIds, onChange, isPreloaded =
|
||||
{filtered.map((unit) => {
|
||||
const id = String(unit.unit_id);
|
||||
const checked = selectedIds.has(id);
|
||||
const conflict = isConflict(unit);
|
||||
return (
|
||||
<CommandItem
|
||||
key={id}
|
||||
@@ -248,12 +132,6 @@ export function UnitPicker({ subscription, selectedIds, onChange, isPreloaded =
|
||||
{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>
|
||||
);
|
||||
@@ -282,8 +160,7 @@ export function UnitPicker({ subscription, selectedIds, onChange, isPreloaded =
|
||||
</Popover>
|
||||
|
||||
{selectedCount === 0 && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-destructive">
|
||||
<AlertTriangle className="size-3.5 shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select at least one unit to bundle with this plan.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function buildRowActions({ navigate, onArchive, onBan, onUnban, onMakeAdm
|
||||
className: "text-destructive focus:text-destructive",
|
||||
icon: <ShieldBan className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onBan(row),
|
||||
hidden: (row) => !!row.is_banned || !row.is_active,
|
||||
hidden: (row) => !!row.is_banned || !row.is_active || row.user_id === currentUserId,
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
@@ -63,7 +63,7 @@ export function buildRowActions({ navigate, onArchive, onBan, onUnban, onMakeAdm
|
||||
className: "text-destructive focus:text-destructive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onArchive(row),
|
||||
hidden: (row) => !row.is_active,
|
||||
hidden: (row) => !row.is_active || row.user_id === currentUserId,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// modules/admin/pages/advertisements/AddAdvertisement.jsx
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
@@ -30,58 +30,33 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { DateTimePicker } from "@/components/ui/date-time-picker";
|
||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||
import { PlacementSkeleton } from "@/components/generic/PlacementSkeleton";
|
||||
import { AdvertisementPreview } from "@/components/admin/advertisements/AdvertisementPreview";
|
||||
|
||||
import { MAX_CTAS, MAX_BADGE_LABELS, CONTENT_MODES } from "@/data/advertisement.data";
|
||||
import { MAX_BADGE_LABELS } from "@/data/advertisement.data";
|
||||
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
|
||||
|
||||
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||
// Every ad now carries the same mandatory shape — badge label(s), headline,
|
||||
// description, image, and a single link — no more Image Only / Text with
|
||||
// Image split (that toggle produced misleading results: Banner's "Text with
|
||||
// Image" mode didn't even show the image).
|
||||
|
||||
const schema = z.object({
|
||||
placement: z.string().min(1, "Placement is required."),
|
||||
content_mode: z.enum(["image", "content"]).default("image"),
|
||||
badge_labels: z.array(z.string().min(1)).max(MAX_BADGE_LABELS, `A maximum of ${MAX_BADGE_LABELS} badges is allowed.`).default([]),
|
||||
headline: z.string().optional(),
|
||||
description: z.string().max(100, "Description must be 100 characters or fewer.").optional(),
|
||||
image_asset_id: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
// Hard cap of 2 CTAs per advertisement (max() backs up the UI-level append guard)
|
||||
ctas: z.array(z.object({
|
||||
label: z.string().min(1, "Label is required."),
|
||||
link: z.string().min(1, "Link is required.").refine(isValidLink, LINK_ERROR),
|
||||
variant: z.enum(["default", "outline"]).default("default"),
|
||||
})).max(MAX_CTAS, `A maximum of ${MAX_CTAS} buttons is allowed.`).default([]),
|
||||
redirect_link: z.string().optional(),
|
||||
landing_page: z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
body: z.string().optional(),
|
||||
links: z.array(z.object({
|
||||
label: z.string().optional(),
|
||||
link: z.string().optional(),
|
||||
})).default([]),
|
||||
}).default({}),
|
||||
badge_labels: z.array(z.string().min(1)).min(1, "At least one badge label is required.").max(MAX_BADGE_LABELS, `A maximum of ${MAX_BADGE_LABELS} badges is allowed.`).default([]),
|
||||
headline: z.string().min(1, "Headline is required."),
|
||||
description: z.string().min(1, "Description is required.").max(100, "Description must be 100 characters or fewer."),
|
||||
image_asset_id: z.union([z.string(), z.number()]).refine((v) => v !== null && v !== undefined && v !== "", "Image is required."),
|
||||
redirect_link: z.string().min(1, "Link is required.").refine(isValidLink, LINK_ERROR),
|
||||
start_date: z.string().optional(),
|
||||
end_date: z.string().optional(),
|
||||
is_active: z.boolean().default(true),
|
||||
}).superRefine((data, ctx) => {
|
||||
// Image Only ads have no other click-through — the Link is their only
|
||||
// destination, so it's required (Text with Image ads click through via
|
||||
// their own CTA links instead, see redirect_link comment above).
|
||||
if (data.content_mode !== "image") return;
|
||||
if (!data.redirect_link?.trim()) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["redirect_link"], message: "Link is required." });
|
||||
} else if (!isValidLink(data.redirect_link)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["redirect_link"], message: LINK_ERROR });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Steps ────────────────────────────────────────────────────────────────────
|
||||
// The Page Builder step only shows up when no redirect_link was given — it's
|
||||
// the alternative click-through destination (an internally-authored landing
|
||||
// page) for ads that don't link straight out to a URL.
|
||||
const ALL_STEPS = [
|
||||
{ id: "placement", label: "Placement", icon: MapPin, description: "Choose where this ad appears — Dashboard, Tier Plans, or Course Details." },
|
||||
{ id: "content", label: "Type", icon: FileText, description: "Image Only, or Text with Image with badges, headline, description, and links." },
|
||||
// { id: "pageBuilder", label: "Page Builder", icon: LayoutTemplate, description: "No redirect link? Build an internal landing page for this ad instead.", skippable: true },
|
||||
{ id: "content", label: "Content", icon: FileText, description: "Badge labels, headline, description, image, and link for this ad." },
|
||||
{ id: "scheduling", label: "Scheduling & Display", icon: CalendarClock, description: "Start/end dates and draft/active status." },
|
||||
{ id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this ad." },
|
||||
];
|
||||
@@ -208,195 +183,86 @@ function StepImagePicker({ selectedAsset, imageUrl, setPickerOpen }) {
|
||||
|
||||
function StepContent({
|
||||
register, errors, setValue, watch, description,
|
||||
selectedAsset, imageUrl, setPickerOpen,
|
||||
ctaFields, appendCta, removeCta,
|
||||
selectedAsset, imageUrl, setPickerOpen, format,
|
||||
}) {
|
||||
const contentMode = watch("content_mode");
|
||||
const badgeLabelFields = watch("badge_labels") ?? [];
|
||||
const appendBadgeLabel = () => setValue("badge_labels", [...badgeLabelFields, ""], { shouldValidate: true, shouldDirty: true });
|
||||
const removeBadgeLabel = (index) => setValue("badge_labels", badgeLabelFields.filter((_, i) => i !== index), { shouldValidate: true, shouldDirty: true });
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="mt-4">
|
||||
<Label className="mb-1.5 block">Content type</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{CONTENT_MODES.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setValue("content_mode", m.value, { shouldValidate: true });
|
||||
// redirect_link only applies to Image Only ads (Text with
|
||||
// Image ads click through via their own CTA links instead)
|
||||
if (m.value === "content") setValue("redirect_link", "", { shouldValidate: true });
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-lg border p-3 text-left transition-colors",
|
||||
contentMode === m.value ? "border-primary bg-primary/5" : "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium">{m.label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{m.value === "image" ? "No text." : "Left-aligned text, image on the right."}
|
||||
</p>
|
||||
</button>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Badge labels</Label>
|
||||
<div className="space-y-2">
|
||||
{badgeLabelFields.map((_, index) => (
|
||||
<div key={index} className="flex gap-2 items-start">
|
||||
<div className="flex-1">
|
||||
<Input placeholder="e.g. Ad" {...register(`badge_labels.${index}`)} />
|
||||
<FieldError message={errors.badge_labels?.[index]?.message} />
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeBadgeLabel(index)} aria-label="Remove">
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{badgeLabelFields.length < MAX_BADGE_LABELS ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={appendBadgeLabel}>
|
||||
<Plus className="size-3.5" />
|
||||
Add badge label
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Maximum of {MAX_BADGE_LABELS} badges reached.</p>
|
||||
)}
|
||||
</div>
|
||||
<FieldError message={errors.badge_labels?.message} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Headline</Label>
|
||||
<Input placeholder="e.g. Discover the World Top Designers" {...register("headline")} />
|
||||
<FieldError message={errors.headline?.message} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Description</Label>
|
||||
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
|
||||
<div className="flex justify-between items-start mt-1">
|
||||
<FieldError message={errors.description?.message} />
|
||||
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 100 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{description?.length ?? 0}/100
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Image</Label>
|
||||
<StepImagePicker selectedAsset={selectedAsset} imageUrl={imageUrl} setPickerOpen={setPickerOpen} />
|
||||
<FieldError message={errors.image_asset_id?.message} />
|
||||
</div>
|
||||
|
||||
{contentMode === "image" && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Title</Label>
|
||||
<Input placeholder="e.g. Summer Enrollment Banner" {...register("headline")} />
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
Internal label shown in the Ads list — not displayed on the ad itself.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Link</Label>
|
||||
<Input placeholder="e.g. /courses or https://example.com" {...register("redirect_link")} />
|
||||
<FieldError message={errors.redirect_link?.message} />
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
Where clicking this ad goes to.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{contentMode === "content" && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Badge labels</Label>
|
||||
<div className="space-y-2">
|
||||
{badgeLabelFields.map((_, index) => (
|
||||
<div key={index} className="flex gap-2 items-start">
|
||||
<div className="flex-1">
|
||||
<Input placeholder="e.g. Ad" {...register(`badge_labels.${index}`)} />
|
||||
<FieldError message={errors.badge_labels?.[index]?.message} />
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeBadgeLabel(index)} aria-label="Remove">
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{badgeLabelFields.length < MAX_BADGE_LABELS ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={appendBadgeLabel}>
|
||||
<Plus className="size-3.5" />
|
||||
Add badge label
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Maximum of {MAX_BADGE_LABELS} badges reached.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Headline</Label>
|
||||
<Input placeholder="e.g. Discover the World Top Designers" {...register("headline")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Description</Label>
|
||||
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
|
||||
<div className="flex justify-between items-start mt-1">
|
||||
<FieldError message={errors.description?.message} />
|
||||
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 100 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{description?.length ?? 0}/100
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Links</Label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Up to {MAX_CTAS} buttons. The first is styled Primary, the second Outline.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{ctaFields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-2 items-start">
|
||||
<div className="flex-1">
|
||||
<Input placeholder="Label (e.g. Explore)" {...register(`ctas.${index}.label`)} />
|
||||
<FieldError message={errors.ctas?.[index]?.label?.message} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input placeholder="Link (e.g. /courses)" {...register(`ctas.${index}.link`)} />
|
||||
<FieldError message={errors.ctas?.[index]?.link?.message} />
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{ctaFields.length < MAX_CTAS ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add link
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contentMode === "image" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Link</Label>
|
||||
<Input placeholder="e.g. /courses or https://example.com" {...register("redirect_link")} />
|
||||
<FieldError message={errors.redirect_link?.message} />
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
Where clicking the image goes to.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<AdvertisementPreview
|
||||
format={format}
|
||||
badgeLabels={badgeLabelFields}
|
||||
headline={watch("headline")}
|
||||
description={watch("description")}
|
||||
imageSrc={imageUrl || (selectedAsset ? resolveAssetSrc(selectedAsset) : null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 3: Page Builder ───────────────────────────────────────────────────
|
||||
|
||||
function StepPageBuilder({ register, linkFields, appendLink, removeLink }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Page title</Label>
|
||||
<Input placeholder="e.g. Why upgrade to Pro" {...register("landing_page.title")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Page description</Label>
|
||||
<Textarea rows={2} placeholder="Short summary shown under the title" {...register("landing_page.description")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Body</Label>
|
||||
<Textarea rows={6} placeholder="Main page content" {...register("landing_page.body")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Links</Label>
|
||||
<div className="space-y-2">
|
||||
{linkFields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-2 items-start">
|
||||
<Input placeholder="Label" className="flex-1" {...register(`landing_page.links.${index}.label`)} />
|
||||
<Input placeholder="URL or path" className="flex-1" {...register(`landing_page.links.${index}.link`)} />
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLink(index)} aria-label="Remove">
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => appendLink({ label: "", link: "" })}>
|
||||
<Plus className="size-3.5" />
|
||||
Add link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 4: Scheduling & Display ───────────────────────────────────────────
|
||||
// ─── Step 3: Scheduling & Display ───────────────────────────────────────────
|
||||
|
||||
function StepScheduling({ watch, setValue }) {
|
||||
const isActive = watch("is_active");
|
||||
@@ -451,8 +317,6 @@ function SummaryRow({ label, value }) {
|
||||
function StepReview({ data, selectedAsset, imageUrl }) {
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
const placementMeta = PLACEMENT_MAP[data.placement];
|
||||
const ctas = (data.ctas ?? []).filter((c) => c.label || c.link);
|
||||
const hasLandingPage = !data.redirect_link && (data.landing_page?.title || data.landing_page?.body);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -467,14 +331,9 @@ function StepReview({ data, selectedAsset, imageUrl }) {
|
||||
|
||||
<div className="border rounded-lg p-4 space-y-1">
|
||||
<p className="text-sm font-medium mb-2">Content</p>
|
||||
<SummaryRow label="Type" value={data.content_mode === "content" ? "Text with Image" : "Image Only"} />
|
||||
{data.content_mode === "content" && (
|
||||
<>
|
||||
<SummaryRow label="Badges" value={(data.badge_labels ?? []).filter(Boolean).join(", ")} />
|
||||
<SummaryRow label="Headline" value={data.headline} />
|
||||
<SummaryRow label="Description" value={data.description} />
|
||||
</>
|
||||
)}
|
||||
<SummaryRow label="Badges" value={(data.badge_labels ?? []).filter(Boolean).join(", ")} />
|
||||
<SummaryRow label="Headline" value={data.headline} />
|
||||
<SummaryRow label="Description" value={data.description} />
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 space-y-1">
|
||||
@@ -490,30 +349,10 @@ function StepReview({ data, selectedAsset, imageUrl }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ctas.length > 0 && (
|
||||
<div className="border rounded-lg p-4 space-y-1">
|
||||
<p className="text-sm font-medium mb-2">Links</p>
|
||||
{ctas.map((c, i) => (
|
||||
<SummaryRow key={i} label={c.label || "—"} value={c.link} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.content_mode === "image" && (
|
||||
<div className="border rounded-lg p-4 space-y-1">
|
||||
<p className="text-sm font-medium mb-2">Click-through</p>
|
||||
{data.redirect_link ? (
|
||||
<SummaryRow label="Link" value={data.redirect_link} />
|
||||
) : hasLandingPage ? (
|
||||
<>
|
||||
<SummaryRow label="Page title" value={data.landing_page?.title} />
|
||||
<SummaryRow label="Links" value={(data.landing_page?.links ?? []).filter((l) => l.label || l.link).length || null} />
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No link or landing page set — this ad won't link anywhere when clicked.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="border rounded-lg p-4 space-y-1">
|
||||
<p className="text-sm font-medium mb-2">Link</p>
|
||||
<SummaryRow label="Goes to" value={data.redirect_link} />
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 space-y-1">
|
||||
<p className="text-sm font-medium mb-2">Scheduling & display</p>
|
||||
@@ -540,7 +379,6 @@ export default function AddAdvertisement() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
trigger,
|
||||
getValues,
|
||||
watch,
|
||||
@@ -550,23 +388,17 @@ export default function AddAdvertisement() {
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
placement: undefined,
|
||||
content_mode: "image",
|
||||
badge_labels: [],
|
||||
headline: "",
|
||||
description: "",
|
||||
image_asset_id: null,
|
||||
ctas: [],
|
||||
redirect_link: "",
|
||||
landing_page: { title: "", description: "", body: "", links: [] },
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
|
||||
const { fields: linkFields, append: appendLink, remove: removeLink } = useFieldArray({ control, name: "landing_page.links" });
|
||||
|
||||
// selectedAsset lives outside the form and its setValue() call doesn't pass
|
||||
// shouldDirty, so isDirty alone would miss it.
|
||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(
|
||||
@@ -575,21 +407,12 @@ export default function AddAdvertisement() {
|
||||
|
||||
const placement = watch("placement");
|
||||
const description = watch("description");
|
||||
const redirectLink = watch("redirect_link");
|
||||
const contentMode = watch("content_mode");
|
||||
const format = PLACEMENT_MAP[placement]?.format;
|
||||
|
||||
const steps = useMemo(
|
||||
() => ALL_STEPS.filter((s) => !s.skippable || !redirectLink?.trim()),
|
||||
[redirectLink]
|
||||
);
|
||||
const steps = ALL_STEPS;
|
||||
const stepIndex = Math.min(step, steps.length - 1);
|
||||
const current = steps[stepIndex];
|
||||
|
||||
// Image Only ads require a valid Link before advancing past the Type step
|
||||
const linkStepInvalid = current.id === "content" && contentMode === "image"
|
||||
&& (!redirectLink?.trim() || !isValidLink(redirectLink));
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Ads", to: "/admin/advertisements" },
|
||||
@@ -600,7 +423,7 @@ export default function AddAdvertisement() {
|
||||
const handleNext = async () => {
|
||||
let fields = [];
|
||||
if (current.id === "placement") fields = ["placement"];
|
||||
else if (current.id === "content") fields = watch("content_mode") === "content" ? ["headline", "description", "badge_labels", "ctas"] : ["redirect_link"];
|
||||
else if (current.id === "content") fields = ["badge_labels", "headline", "description", "image_asset_id", "redirect_link"];
|
||||
|
||||
const valid = fields.length ? await trigger(fields) : true;
|
||||
if (valid) setStep((s) => Math.min(s + 1, steps.length - 1));
|
||||
@@ -612,9 +435,6 @@ export default function AddAdvertisement() {
|
||||
const handleCreate = handleSubmit(async (values) => {
|
||||
const payload = {
|
||||
...values,
|
||||
image_asset_id: values.image_asset_id || null,
|
||||
redirect_link: values.redirect_link || null,
|
||||
landing_page: values.redirect_link ? null : values.landing_page,
|
||||
start_date: values.start_date || null,
|
||||
end_date: values.end_date || null,
|
||||
createdBy: user?.user_id ?? null,
|
||||
@@ -663,17 +483,7 @@ export default function AddAdvertisement() {
|
||||
selectedAsset={selectedAsset}
|
||||
imageUrl={imagePreviewUrl}
|
||||
setPickerOpen={setPickerOpen}
|
||||
ctaFields={ctaFields}
|
||||
appendCta={appendCta}
|
||||
removeCta={removeCta}
|
||||
/>
|
||||
)}
|
||||
{current.id === "pageBuilder" && (
|
||||
<StepPageBuilder
|
||||
register={register}
|
||||
linkFields={linkFields}
|
||||
appendLink={appendLink}
|
||||
removeLink={removeLink}
|
||||
format={format}
|
||||
/>
|
||||
)}
|
||||
{current.id === "scheduling" && (
|
||||
@@ -701,7 +511,7 @@ export default function AddAdvertisement() {
|
||||
Create advertisement
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" onClick={handleNext} disabled={linkStepInvalid}>
|
||||
<Button type="button" onClick={handleNext}>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { House, Plus, Trash2, ImagePlus } from "lucide-react";
|
||||
@@ -12,7 +12,6 @@ import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
import { resolveAssetSrc } from "@/utils/media.util";
|
||||
import { isValidLink, LINK_ERROR } from "@/utils/link.util";
|
||||
import { cn } from "@/lib/utils";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -20,52 +19,30 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { DateTimePicker } from "@/components/ui/date-time-picker";
|
||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||
import { AdvertisementPreview } from "@/components/admin/advertisements/AdvertisementPreview";
|
||||
|
||||
import { MAX_CTAS, MAX_BADGE_LABELS, CONTENT_MODES } from "@/data/advertisement.data";
|
||||
import { MAX_BADGE_LABELS } from "@/data/advertisement.data";
|
||||
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
|
||||
|
||||
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||
// Every ad now carries the same mandatory shape — badge label(s), headline,
|
||||
// description, image, and a single link — no more Image Only / Text with
|
||||
// Image split (that toggle produced misleading results: Banner's "Text with
|
||||
// Image" mode didn't even show the image).
|
||||
|
||||
const schema = z.object({
|
||||
placement: z.string().min(1, "Placement is required."),
|
||||
content_mode: z.enum(["image", "content"]).default("image"),
|
||||
badge_labels: z.array(z.string().min(1)).max(MAX_BADGE_LABELS, `A maximum of ${MAX_BADGE_LABELS} badges is allowed.`).default([]),
|
||||
headline: z.string().optional(),
|
||||
description: z.string().max(100, "Description must be 100 characters or fewer.").optional(),
|
||||
image_asset_id: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
// Hard cap of 2 CTAs per advertisement (max() backs up the UI-level append guard)
|
||||
ctas: z.array(z.object({
|
||||
label: z.string().min(1, "Label is required."),
|
||||
link: z.string().min(1, "Link is required.").refine(isValidLink, LINK_ERROR),
|
||||
variant: z.enum(["default", "outline"]).default("default"),
|
||||
})).max(MAX_CTAS, `A maximum of ${MAX_CTAS} buttons is allowed.`).default([]),
|
||||
redirect_link: z.string().optional(),
|
||||
landing_page: z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
body: z.string().optional(),
|
||||
links: z.array(z.object({
|
||||
label: z.string().optional(),
|
||||
link: z.string().optional(),
|
||||
})).default([]),
|
||||
}).default({}),
|
||||
badge_labels: z.array(z.string().min(1)).min(1, "At least one badge label is required.").max(MAX_BADGE_LABELS, `A maximum of ${MAX_BADGE_LABELS} badges is allowed.`).default([]),
|
||||
headline: z.string().min(1, "Headline is required."),
|
||||
description: z.string().min(1, "Description is required.").max(100, "Description must be 100 characters or fewer."),
|
||||
image_asset_id: z.union([z.string(), z.number()]).refine((v) => v !== null && v !== undefined && v !== "", "Image is required."),
|
||||
redirect_link: z.string().min(1, "Link is required.").refine(isValidLink, LINK_ERROR),
|
||||
start_date: z.string().optional(),
|
||||
end_date: z.string().optional(),
|
||||
is_active: z.boolean().default(true),
|
||||
}).superRefine((data, ctx) => {
|
||||
// Image Only ads have no other click-through — the Link is their only
|
||||
// destination, so it's required (Text with Image ads click through via
|
||||
// their own CTA links instead).
|
||||
if (data.content_mode !== "image") return;
|
||||
if (!data.redirect_link?.trim()) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["redirect_link"], message: "Link is required." });
|
||||
} else if (!isValidLink(data.redirect_link)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["redirect_link"], message: LINK_ERROR });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -108,7 +85,6 @@ export default function EditAdvertisement() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
@@ -117,32 +93,24 @@ export default function EditAdvertisement() {
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
placement: undefined,
|
||||
content_mode: "image",
|
||||
badge_labels: [],
|
||||
headline: "",
|
||||
description: "",
|
||||
image_asset_id: null,
|
||||
ctas: [],
|
||||
redirect_link: "",
|
||||
landing_page: { title: "", description: "", body: "", links: [] },
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
|
||||
const { fields: linkFields, append: appendLink, remove: removeLink } = useFieldArray({ control, name: "landing_page.links" });
|
||||
|
||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
||||
|
||||
const placement = watch("placement");
|
||||
const description = watch("description");
|
||||
const contentMode = watch("content_mode");
|
||||
const badgeLabelFields = watch("badge_labels") ?? [];
|
||||
const appendBadgeLabel = () => setValue("badge_labels", [...badgeLabelFields, ""], { shouldValidate: true, shouldDirty: true });
|
||||
const removeBadgeLabel = (index) => setValue("badge_labels", badgeLabelFields.filter((_, i) => i !== index), { shouldValidate: true, shouldDirty: true });
|
||||
const redirectLink = watch("redirect_link");
|
||||
const format = PLACEMENT_MAP[placement]?.format;
|
||||
|
||||
const breadcrumbItems = [
|
||||
@@ -168,23 +136,11 @@ export default function EditAdvertisement() {
|
||||
|
||||
reset({
|
||||
placement: ad.placement ?? undefined,
|
||||
content_mode: ad.content_mode ?? "image",
|
||||
badge_labels: ad.badge_labels ?? [],
|
||||
headline: ad.headline ?? "",
|
||||
description: ad.description ?? "",
|
||||
image_asset_id: ad.image?.asset_id ?? null,
|
||||
ctas: (ad.ctas ?? []).map((c, i) => ({
|
||||
label: c.label ?? "",
|
||||
link: c.link ?? "",
|
||||
variant: c.variant ?? (i === 0 ? "default" : "outline"),
|
||||
})),
|
||||
redirect_link: ad.redirect_link ?? "",
|
||||
landing_page: {
|
||||
title: ad.landing_page?.title ?? "",
|
||||
description: ad.landing_page?.description ?? "",
|
||||
body: ad.landing_page?.body ?? "",
|
||||
links: (ad.landing_page?.links ?? []).map((l) => ({ label: l.label ?? "", link: l.link ?? "" })),
|
||||
},
|
||||
redirect_link: ad.redirect_link ?? "",
|
||||
start_date: ad.start_date ?? "",
|
||||
end_date: ad.end_date ?? "",
|
||||
is_active: ad.is_active ?? true,
|
||||
@@ -200,9 +156,6 @@ export default function EditAdvertisement() {
|
||||
|
||||
const payload = {
|
||||
...values,
|
||||
image_asset_id: values.image_asset_id || null,
|
||||
redirect_link: values.redirect_link || null,
|
||||
landing_page: values.redirect_link ? null : values.landing_page,
|
||||
start_date: values.start_date || null,
|
||||
end_date: values.end_date || null,
|
||||
updatedBy: user?.user_id ?? null,
|
||||
@@ -235,6 +188,16 @@ export default function EditAdvertisement() {
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
<SectionCard>
|
||||
<AdvertisementPreview
|
||||
format={format}
|
||||
badgeLabels={badgeLabelFields}
|
||||
headline={watch("headline")}
|
||||
description={watch("description")}
|
||||
imageSrc={imagePreviewUrl || (selectedAsset ? resolveAssetSrc(selectedAsset) : null)}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Placement" description="Where this advertisement will appear.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Placement</Label>
|
||||
@@ -261,83 +224,47 @@ export default function EditAdvertisement() {
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Type" description="Image Only, or Text with Image with badges, headline, description, and links.">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{CONTENT_MODES.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setValue("content_mode", m.value, { shouldValidate: true, shouldDirty: true });
|
||||
// redirect_link only applies to Image Only ads (Text with
|
||||
// Image ads click through via their own CTA links instead)
|
||||
if (m.value === "content") setValue("redirect_link", "", { shouldValidate: true, shouldDirty: true });
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-lg border p-3 text-left transition-colors",
|
||||
contentMode === m.value ? "border-primary bg-primary/5" : "hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium">{m.label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{m.value === "image" ? "No text." : "Left-aligned text, image on the right."}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{contentMode === "image" && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Title</Label>
|
||||
<Input placeholder="e.g. Summer Enrollment Banner" {...register("headline")} />
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
Internal label shown in the Ads list — not displayed on the ad itself.
|
||||
</p>
|
||||
<SectionCard title="Content" description="Badge labels, headline, and description shown on the ad.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Badge labels</Label>
|
||||
<div className="space-y-2">
|
||||
{badgeLabelFields.map((_, index) => (
|
||||
<div key={index} className="flex gap-2 items-start">
|
||||
<div className="flex-1">
|
||||
<Input placeholder="e.g. Ad" {...register(`badge_labels.${index}`)} />
|
||||
<FieldError message={errors.badge_labels?.[index]?.message} />
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeBadgeLabel(index)} aria-label="Remove">
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{badgeLabelFields.length < MAX_BADGE_LABELS ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={appendBadgeLabel}>
|
||||
<Plus className="size-3.5" />
|
||||
Add badge label
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Maximum of {MAX_BADGE_LABELS} badges reached.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contentMode === "content" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Badge labels</Label>
|
||||
<div className="space-y-2">
|
||||
{badgeLabelFields.map((_, index) => (
|
||||
<div key={index} className="flex gap-2 items-start">
|
||||
<div className="flex-1">
|
||||
<Input placeholder="e.g. Ad" {...register(`badge_labels.${index}`)} />
|
||||
<FieldError message={errors.badge_labels?.[index]?.message} />
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeBadgeLabel(index)} aria-label="Remove">
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{badgeLabelFields.length < MAX_BADGE_LABELS ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={appendBadgeLabel}>
|
||||
<Plus className="size-3.5" />
|
||||
Add badge label
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Maximum of {MAX_BADGE_LABELS} badges reached.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Headline</Label>
|
||||
<Input placeholder="e.g. Discover the World Top Designers" {...register("headline")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Description</Label>
|
||||
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
|
||||
<div className="flex justify-between items-start mt-1">
|
||||
<FieldError message={errors.description?.message} />
|
||||
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 100 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{description?.length ?? 0}/100
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<FieldError message={errors.badge_labels?.message} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Headline</Label>
|
||||
<Input placeholder="e.g. Discover the World Top Designers" {...register("headline")} />
|
||||
<FieldError message={errors.headline?.message} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Description</Label>
|
||||
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
|
||||
<div className="flex justify-between items-start mt-1">
|
||||
<FieldError message={errors.description?.message} />
|
||||
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 100 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{description?.length ?? 0}/100
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Image" description="Choose an existing asset from Asset Management.">
|
||||
@@ -365,92 +292,16 @@ export default function EditAdvertisement() {
|
||||
<span className="text-sm">Select an image</span>
|
||||
</button>
|
||||
)}
|
||||
<FieldError message={errors.image_asset_id?.message} />
|
||||
</SectionCard>
|
||||
|
||||
{contentMode === "content" && (
|
||||
<SectionCard
|
||||
title="Links"
|
||||
description={`Up to ${MAX_CTAS} buttons shown on the placement. The first is styled Primary, the second Outline.`}
|
||||
>
|
||||
{ctaFields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-2 items-start">
|
||||
<div className="flex-1">
|
||||
<Input placeholder="Label (e.g. Explore)" {...register(`ctas.${index}.label`)} />
|
||||
<FieldError message={errors.ctas?.[index]?.label?.message} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input placeholder="Link (e.g. /courses)" {...register(`ctas.${index}.link`)} />
|
||||
<FieldError message={errors.ctas?.[index]?.link?.message} />
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{ctaFields.length < MAX_CTAS ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add link
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{contentMode === "image" && (
|
||||
<SectionCard title="Click-through" description="Where clicking the image goes to.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Link</Label>
|
||||
<Input placeholder="e.g. /courses or https://example.com" {...register("redirect_link")} />
|
||||
<FieldError message={errors.redirect_link?.message} />
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
Where clicking the image goes to.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!redirectLink?.trim() && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Page title</Label>
|
||||
<Input placeholder="e.g. Why upgrade to Pro" {...register("landing_page.title")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Page description</Label>
|
||||
<Textarea rows={2} placeholder="Short summary shown under the title" {...register("landing_page.description")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Body</Label>
|
||||
<Textarea rows={6} placeholder="Main page content" {...register("landing_page.body")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Links</Label>
|
||||
<div className="space-y-2">
|
||||
{linkFields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-2 items-start">
|
||||
<Input placeholder="Label" className="flex-1" {...register(`landing_page.links.${index}.label`)} />
|
||||
<Input placeholder="URL or path" className="flex-1" {...register(`landing_page.links.${index}.link`)} />
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLink(index)} aria-label="Remove">
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => appendLink({ label: "", link: "" })}>
|
||||
<Plus className="size-3.5" />
|
||||
Add link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
<SectionCard title="Link" description="Where clicking this ad goes to.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Link</Label>
|
||||
<Input placeholder="e.g. /courses or https://example.com" {...register("redirect_link")} />
|
||||
<FieldError message={errors.redirect_link?.message} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Scheduling" description="Optional start and end dates for this placement.">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
@@ -12,6 +12,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
import { AdvertisementPreview } from "@/components/admin/advertisements/AdvertisementPreview";
|
||||
|
||||
import { ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
|
||||
import { PLACEMENT_MAP } from "@/data/placement.data";
|
||||
|
||||
@@ -141,14 +143,14 @@ export default function ViewAdvertisement() {
|
||||
</div>
|
||||
|
||||
{/* ── Preview ────────────────────────────────────────────────── */}
|
||||
<SectionCard title="Preview">
|
||||
<div className="h-48 rounded-lg bg-muted flex items-center justify-center overflow-hidden">
|
||||
{previewSrc ? (
|
||||
<img src={previewSrc} alt={advertisement.headline || advertisement.type} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Megaphone className="size-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<SectionCard>
|
||||
<AdvertisementPreview
|
||||
format={advertisement.type}
|
||||
badgeLabels={advertisement.badge_labels}
|
||||
headline={advertisement.headline}
|
||||
description={advertisement.description}
|
||||
imageSrc={previewSrc}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Content ────────────────────────────────────────────────── */}
|
||||
|
||||
@@ -20,6 +20,9 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription,
|
||||
DrawerFooter, DrawerClose,
|
||||
@@ -39,6 +42,7 @@ const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
description: z.string().optional(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
subscription: z.string().optional(),
|
||||
objectives: z.array(
|
||||
z.object({ value: z.string().min(1, "Objective cannot be empty.") })
|
||||
).optional(),
|
||||
@@ -57,8 +61,10 @@ function FieldError({ message }) {
|
||||
}
|
||||
|
||||
// ─── Step 1 — Details ───────────────────────────────────────────────────────────
|
||||
function StepDetails({ register, errors, control }) {
|
||||
function StepDetails({ register, errors, control, setValue, tierCategories }) {
|
||||
const { fields, append, remove, insert, update } = useFieldArray({ control, name: "objectives" });
|
||||
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||
const defaultTierSlug = tierCategories.find((c) => c.is_default)?.slug || "free";
|
||||
|
||||
const handleObjectivePaste = (e, index) => {
|
||||
const text = e.clipboardData.getData("text");
|
||||
@@ -89,6 +95,28 @@ function StepDetails({ register, errors, control }) {
|
||||
<Label htmlFor="order">Order</Label>
|
||||
<Input id="order" type="number" min={0} {...register("order")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watchedSubscr || defaultTierSlug}
|
||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No tier gate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{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 the unit it's attached to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objectives */}
|
||||
@@ -238,12 +266,19 @@ export default function AddLesson() {
|
||||
const [requirements, setRequirements] = useState([]);
|
||||
const [attachOpen, setAttachOpen] = useState(false);
|
||||
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const {
|
||||
register, control, trigger, getValues, setValue,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0, objectives: [], blocks: [] },
|
||||
defaultValues: { title: "", description: "", order: 0, subscription: "free", objectives: [], blocks: [] },
|
||||
mode: "onTouched",
|
||||
});
|
||||
|
||||
@@ -278,6 +313,7 @@ export default function AddLesson() {
|
||||
title: data.title,
|
||||
description: data.description || null,
|
||||
order: data.order,
|
||||
subscription: data.subscription || (tierCategories.find((c) => c.is_default)?.slug || "free"),
|
||||
objectives: data.objectives?.map((o) => o.value) ?? [],
|
||||
createdBy: user?.user_id,
|
||||
};
|
||||
@@ -388,7 +424,7 @@ export default function AddLesson() {
|
||||
{/* Step content */}
|
||||
<div>
|
||||
{step === 0 && (
|
||||
<StepDetails register={register} errors={errors} control={control} />
|
||||
<StepDetails register={register} errors={errors} control={control} setValue={setValue} tierCategories={tierCategories} />
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { useForm, useFieldArray, useWatch } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, House, Plus, Trash2 } from "lucide-react";
|
||||
@@ -9,11 +9,15 @@ import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import api from "@/utils/api.util";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
|
||||
|
||||
@@ -21,6 +25,7 @@ const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
description: z.string().optional(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
subscription: z.string().optional(),
|
||||
objectives: z.array(
|
||||
z.object({ value: z.string().min(1, "Objective cannot be empty.") })
|
||||
).optional(),
|
||||
@@ -38,11 +43,21 @@ export default function EditLesson() {
|
||||
const { user } = useAuth();
|
||||
const [lessonTitle, setLessonTitle] = useState("");
|
||||
|
||||
const { register, handleSubmit, reset, control, formState: { errors, isDirty } } = useForm({
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const { register, handleSubmit, reset, control, setValue, formState: { errors, isDirty } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0, objectives: [] },
|
||||
defaultValues: { title: "", description: "", order: 0, subscription: "free", objectives: [] },
|
||||
});
|
||||
|
||||
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||
const defaultTierSlug = tierCategories.find((c) => c.is_default)?.slug || "free";
|
||||
|
||||
const { fields, append, remove, insert, update } = useFieldArray({ control, name: "objectives" });
|
||||
|
||||
const handleObjectivePaste = (e, index) => {
|
||||
@@ -68,6 +83,7 @@ export default function EditLesson() {
|
||||
title: lesson.title ?? "",
|
||||
description: lesson.description ?? "",
|
||||
order: lesson.order ?? 0,
|
||||
subscription: lesson.subscription || defaultTierSlug,
|
||||
objectives: lesson.objectives?.map((v) => ({ value: v.text })) ?? [],
|
||||
});
|
||||
})();
|
||||
@@ -81,6 +97,7 @@ export default function EditLesson() {
|
||||
if (!isDirty) { bypassOnce(); return navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/view`); }
|
||||
const result = await updateLesson(courseId, unitId, lessonId, {
|
||||
...data,
|
||||
subscription: data.subscription || defaultTierSlug,
|
||||
objectives: data.objectives?.map((o, i) => ({
|
||||
objective_id: o.objective_id ?? null,
|
||||
text: o.value,
|
||||
@@ -132,6 +149,28 @@ export default function EditLesson() {
|
||||
<Input id="order" type="number" min={0} {...register("order")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watchedSubscr || defaultTierSlug}
|
||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No tier gate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{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 the unit it's attached to.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Objectives */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ChevronLeft, ChevronRight, Check, FileText, ListChecks, Link2, Plus } from "lucide-react";
|
||||
@@ -15,6 +15,9 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
import DraftRequirementsEditor from "@/modules/admin/components/courses/DraftRequirementsEditor";
|
||||
import AttachUnitsDialog from "@/modules/admin/components/library/AttachUnitsDialog";
|
||||
@@ -23,6 +26,7 @@ const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
description: z.string().optional(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
subscription: z.string().optional(),
|
||||
});
|
||||
|
||||
const STEPS = [
|
||||
@@ -49,11 +53,21 @@ export default function AddUnit() {
|
||||
const [requirements, setRequirements] = useState([]);
|
||||
const [attachOpen, setAttachOpen] = useState(false);
|
||||
|
||||
const { register, trigger, getValues, 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, trigger, getValues, control, setValue, formState: { errors, isDirty } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0 },
|
||||
defaultValues: { title: "", description: "", order: 0, subscription: "free" },
|
||||
});
|
||||
|
||||
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||
const defaultTierSlug = tierCategories.find((c) => c.is_default)?.slug || "free";
|
||||
|
||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || requirements.length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -69,7 +83,8 @@ export default function AddUnit() {
|
||||
// click at Requirements (the last step), which is purely a draft form
|
||||
// until now.
|
||||
const handleCreate = async () => {
|
||||
const result = await createUnit(courseId, { ...getValues(), createdBy: user?.user_id });
|
||||
const values = getValues();
|
||||
const result = await createUnit(courseId, { ...values, subscription: values.subscription || defaultTierSlug, createdBy: user?.user_id });
|
||||
const newUnitId = result?.data?.data?.unit_id;
|
||||
if (!newUnitId) return;
|
||||
|
||||
@@ -178,6 +193,28 @@ export default function AddUnit() {
|
||||
<Label htmlFor="order">Order</Label>
|
||||
<Input id="order" type="number" min={0} {...register("order")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watchedSubscr || defaultTierSlug}
|
||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No tier gate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{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 unit directly, independent of the course it's attached to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, House } from "lucide-react";
|
||||
@@ -8,11 +8,15 @@ import { ArrowLeft, House } from "lucide-react";
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import api from "@/utils/api.util";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
|
||||
|
||||
@@ -20,6 +24,7 @@ const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
description: z.string().optional(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
subscription: z.string().optional(),
|
||||
});
|
||||
|
||||
function FieldError({ message }) {
|
||||
@@ -34,11 +39,21 @@ export default function EditUnit() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors, isDirty } } = useForm({
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const { register, handleSubmit, reset, control, setValue, formState: { errors, isDirty } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", order: 0 },
|
||||
defaultValues: { title: "", description: "", order: 0, subscription: "free" },
|
||||
});
|
||||
|
||||
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||
const defaultTierSlug = tierCategories.find((c) => c.is_default)?.slug || "free";
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetchUnit(courseId, unitId);
|
||||
@@ -48,6 +63,7 @@ export default function EditUnit() {
|
||||
title: unit.title ?? "",
|
||||
description: unit.description ?? "",
|
||||
order: unit.order ?? 0,
|
||||
subscription: unit.subscription || defaultTierSlug,
|
||||
});
|
||||
setUnitTitle(unit.title ?? "");
|
||||
})();
|
||||
@@ -61,7 +77,7 @@ export default function EditUnit() {
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
if (!isDirty) { bypassOnce(); return navigate(`/admin/courses/${courseId}/units/${unitId}/view`); }
|
||||
const result = await updateUnit(courseId, unitId, { ...data, updatedBy: user?.user_id });
|
||||
const result = await updateUnit(courseId, unitId, { ...data, subscription: data.subscription || defaultTierSlug, updatedBy: user?.user_id });
|
||||
if (!result) return;
|
||||
bypassOnce();
|
||||
navigate(`/admin/courses/${courseId}/units/${unitId}/view`);
|
||||
@@ -105,6 +121,28 @@ export default function EditUnit() {
|
||||
<Input id="order" type="number" min={0} {...register("order")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watchedSubscr || defaultTierSlug}
|
||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No tier gate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{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 unit directly, independent of the course it's attached to.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
|
||||
@@ -44,7 +44,7 @@ const schema = z.object({
|
||||
blocks: z.array(z.any()).optional(),
|
||||
});
|
||||
|
||||
const DEFAULT_VALUES = { title: "", description: "", subscription: "", blocks: [] };
|
||||
const DEFAULT_VALUES = { title: "", description: "", subscription: "free", blocks: [] };
|
||||
|
||||
const STEPS = [
|
||||
{ id: 0, label: "Lesson", icon: FileText },
|
||||
@@ -65,6 +65,7 @@ function FieldError({ message }) {
|
||||
// ─── Step 1 — Lesson ───────────────────────────────────────────────────────────
|
||||
function StepLesson({ register, errors, control, setValue, tierCategories }) {
|
||||
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||
const defaultTierSlug = tierCategories.find((c) => c.is_default)?.slug || "free";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -82,14 +83,13 @@ function StepLesson({ register, errors, control, setValue, tierCategories }) {
|
||||
<div className="space-y-1.5">
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watchedSubscr || "__open"}
|
||||
onValueChange={(val) => setValue("subscription", val === "__open" ? "" : val, { shouldDirty: true })}
|
||||
value={watchedSubscr || defaultTierSlug}
|
||||
onValueChange={(val) => setValue("subscription", 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}
|
||||
@@ -225,7 +225,7 @@ function StepReview({ data, attachUnitId, requirements, tierCategories }) {
|
||||
</div>
|
||||
<SummaryRow label="Title" value={data.title} />
|
||||
<SummaryRow label="Description" value={data.description} />
|
||||
<SummaryRow label="Subscription" value={tierName || "No tier gate (open)"} />
|
||||
<SummaryRow label="Subscription" value={tierName || "Free"} />
|
||||
<SummaryRow label="Page content" value={`${(data.blocks ?? []).length} block(s)`} />
|
||||
{attachUnitId && <SummaryRow label="Attaches to" value="The unit you came from" />}
|
||||
</div>
|
||||
@@ -289,7 +289,7 @@ export default function AddLibraryLesson() {
|
||||
const result = await createLesson({
|
||||
title: data.title,
|
||||
description: data.description || null,
|
||||
subscription: data.subscription || null,
|
||||
subscription: data.subscription || (tierCategories.find((c) => c.is_default)?.slug || "free"),
|
||||
...(attachUnitId ? { unit_id: attachUnitId } : {}),
|
||||
createdBy: user?.user_id,
|
||||
});
|
||||
|
||||
@@ -49,12 +49,13 @@ export default function EditLibraryLesson() {
|
||||
|
||||
const { register, handleSubmit, reset, control, setValue, formState: { errors, isDirty } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", subscription: "" },
|
||||
defaultValues: { title: "", description: "", subscription: "free" },
|
||||
});
|
||||
|
||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
||||
|
||||
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||
const defaultTierSlug = tierCategories.find((c) => c.is_default)?.slug || "free";
|
||||
|
||||
useEffect(() => {
|
||||
fetchLesson(lessonId);
|
||||
@@ -62,12 +63,12 @@ export default function EditLibraryLesson() {
|
||||
|
||||
useEffect(() => {
|
||||
if (lesson && String(lesson.lesson_id) === String(lessonId)) {
|
||||
reset({ title: lesson.title ?? "", description: lesson.description ?? "", subscription: lesson.subscription ?? "" });
|
||||
reset({ title: lesson.title ?? "", description: lesson.description ?? "", subscription: lesson.subscription || defaultTierSlug });
|
||||
}
|
||||
}, [lesson, lessonId, reset]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await updateLesson(lessonId, { ...data, subscription: data.subscription || null, updatedBy: user?.user_id });
|
||||
const result = await updateLesson(lessonId, { ...data, subscription: data.subscription || defaultTierSlug, updatedBy: user?.user_id });
|
||||
if (!result) return;
|
||||
bypassOnce();
|
||||
navigate(`/admin/lessons/${lessonId}/view`);
|
||||
@@ -108,14 +109,13 @@ export default function EditLibraryLesson() {
|
||||
<div className="space-y-1.5">
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watchedSubscr || "__open"}
|
||||
onValueChange={(val) => setValue("subscription", val === "__open" ? "" : val, { shouldDirty: true })}
|
||||
value={watchedSubscr || defaultTierSlug}
|
||||
onValueChange={(val) => setValue("subscription", 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}
|
||||
|
||||
@@ -61,7 +61,7 @@ const schema = z.object({
|
||||
const DEFAULT_VALUES = {
|
||||
title: "",
|
||||
description: "",
|
||||
subscription: "",
|
||||
subscription: "free",
|
||||
lessons: [],
|
||||
};
|
||||
|
||||
@@ -85,6 +85,7 @@ function FieldError({ message }) {
|
||||
// ─── Step 1 — Create Unit ─────────────────────────────────────────────────────
|
||||
function StepUnit({ register, errors, control, setValue, tierCategories }) {
|
||||
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||
const defaultTierSlug = tierCategories.find((c) => c.is_default)?.slug || "free";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -102,14 +103,13 @@ function StepUnit({ register, errors, control, setValue, tierCategories }) {
|
||||
<div className="space-y-1.5">
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watchedSubscr || "__open"}
|
||||
onValueChange={(val) => setValue("subscription", val === "__open" ? "" : val, { shouldDirty: true })}
|
||||
value={watchedSubscr || defaultTierSlug}
|
||||
onValueChange={(val) => setValue("subscription", 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}
|
||||
@@ -451,7 +451,7 @@ function StepReview({ data, tierCategories, requirements, existingLessons }) {
|
||||
</div>
|
||||
<SummaryRow label="Title" value={data.title} />
|
||||
<SummaryRow label="Description" value={data.description} />
|
||||
<SummaryRow label="Subscription" value={tierName || "No tier gate (open)"} />
|
||||
<SummaryRow label="Subscription" value={tierName || "Free"} />
|
||||
</div>
|
||||
|
||||
{existingLessons.length > 0 && (
|
||||
@@ -557,7 +557,7 @@ export default function AddLibraryUnit() {
|
||||
const payload = {
|
||||
title: data.title,
|
||||
description: data.description || null,
|
||||
subscription: data.subscription || null,
|
||||
subscription: data.subscription || (tierCategories.find((c) => c.is_default)?.slug || "free"),
|
||||
lessons: (data.lessons ?? []).map((l) => ({
|
||||
title: l.title,
|
||||
description: l.description || null,
|
||||
|
||||
@@ -49,12 +49,13 @@ export default function EditLibraryUnit() {
|
||||
|
||||
const { register, handleSubmit, reset, control, setValue, formState: { errors, isDirty } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "", description: "", subscription: "" },
|
||||
defaultValues: { title: "", description: "", subscription: "free" },
|
||||
});
|
||||
|
||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
||||
|
||||
const watchedSubscr = useWatch({ control, name: "subscription" });
|
||||
const defaultTierSlug = tierCategories.find((c) => c.is_default)?.slug || "free";
|
||||
|
||||
useEffect(() => {
|
||||
fetchUnit(unitId);
|
||||
@@ -62,12 +63,12 @@ export default function EditLibraryUnit() {
|
||||
|
||||
useEffect(() => {
|
||||
if (unit && String(unit.unit_id) === String(unitId)) {
|
||||
reset({ title: unit.title ?? "", description: unit.description ?? "", subscription: unit.subscription ?? "" });
|
||||
reset({ title: unit.title ?? "", description: unit.description ?? "", subscription: unit.subscription || defaultTierSlug });
|
||||
}
|
||||
}, [unit, unitId, reset]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
const result = await updateUnit(unitId, { ...data, subscription: data.subscription || null, updatedBy: user?.user_id });
|
||||
const result = await updateUnit(unitId, { ...data, subscription: data.subscription || defaultTierSlug, updatedBy: user?.user_id });
|
||||
if (!result) return;
|
||||
bypassOnce();
|
||||
navigate(`/admin/units/${unitId}/view`);
|
||||
@@ -108,14 +109,13 @@ export default function EditLibraryUnit() {
|
||||
<div className="space-y-1.5">
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watchedSubscr || "__open"}
|
||||
onValueChange={(val) => setValue("subscription", val === "__open" ? "" : val, { shouldDirty: true })}
|
||||
value={watchedSubscr || defaultTierSlug}
|
||||
onValueChange={(val) => setValue("subscription", 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}
|
||||
|
||||
@@ -17,10 +17,9 @@ import { ArrowLeft, ChevronRight, ChevronLeft, Check, FileText, Users, ListCheck
|
||||
|
||||
// ─── Steps ────────────────────────────────────────────────────────────────────
|
||||
const STEPS = [
|
||||
{ id: 0, label: 'Details', icon: FileText },
|
||||
{ id: 1, label: 'Assign Groups', icon: Users },
|
||||
{ id: 2, label: 'Tasks', icon: ListChecks },
|
||||
{ id: 3, label: 'Review', icon: ClipboardList },
|
||||
{ id: 0, label: 'Details & Groups', icon: FileText },
|
||||
{ id: 1, label: 'Tasks', icon: ListChecks },
|
||||
{ id: 2, label: 'Review', icon: ClipboardList },
|
||||
];
|
||||
|
||||
// ─── Summary row ──────────────────────────────────────────────────────────────
|
||||
@@ -178,7 +177,7 @@ export default function CreateTaskList() {
|
||||
<CardContent className="space-y-4 min-h-[280px]">
|
||||
<h2 className="text-base font-medium">{STEPS[step].label}</h2>
|
||||
|
||||
{/* ── Step 1: Details ── */}
|
||||
{/* ── Step 1: Details & Groups ── */}
|
||||
{step === 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
@@ -204,33 +203,30 @@ export default function CreateTaskList() {
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-border pt-4">
|
||||
<Label>Assign to Groups <span className="text-destructive">*</span></Label>
|
||||
<GroupMultiSelect
|
||||
value={selectedGroupIds}
|
||||
onChange={setSelectedGroupIds}
|
||||
disabled={loading}
|
||||
placeholder="Select groups to assign…"
|
||||
/>
|
||||
{selectedGroupIds.length === 0 ? (
|
||||
<p className="text-xs text-destructive">
|
||||
Select at least one group to continue.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Members of selected groups will be able to see and complete this task list.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Assign Groups ── */}
|
||||
{/* ── Step 2: Tasks ── */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-3">
|
||||
<Label>Assign to Groups <span className="text-destructive">*</span></Label>
|
||||
<GroupMultiSelect
|
||||
value={selectedGroupIds}
|
||||
onChange={setSelectedGroupIds}
|
||||
disabled={loading}
|
||||
placeholder="Select groups to assign…"
|
||||
/>
|
||||
{selectedGroupIds.length === 0 ? (
|
||||
<p className="text-xs text-destructive">
|
||||
Select at least one group to continue.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Members of selected groups will be able to see and complete this task list.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Tasks ── */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Optionally add the tasks users will need to complete for this task list.
|
||||
@@ -245,8 +241,8 @@ export default function CreateTaskList() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 4: Review ── */}
|
||||
{step === 3 && (
|
||||
{/* ── Step 3: Review ── */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="border border-border rounded-lg p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -313,7 +309,7 @@ export default function CreateTaskList() {
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
disabled={step === 1 && selectedGroupIds.length === 0}
|
||||
disabled={step === 0 && selectedGroupIds.length === 0}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, ArrowRight, Check, House, Plus, Trash2 } from "lucide-react";
|
||||
import { ArrowLeft, ArrowRight, Check, House, Plus, Trash2, BookOpen, Layers, FileText } from "lucide-react";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
@@ -19,6 +19,7 @@ import { UnitPicker } from "@/modules/admin/components/tiers/UnitPicker";
|
||||
import { LessonPicker } from "@/modules/admin/components/tiers/LessonPicker";
|
||||
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
|
||||
import api from "@/utils/api.util";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
@@ -38,6 +39,12 @@ const DURATION_UNIT_LIMITS = {
|
||||
month: { max: 11, nextLabel: "Year(s)", factor: 12 },
|
||||
};
|
||||
|
||||
const BUNDLE_TYPES = [
|
||||
{ value: "course", label: "Course", icon: BookOpen },
|
||||
{ value: "unit", label: "Units", icon: Layers },
|
||||
{ value: "lesson", label: "Lessons", icon: FileText },
|
||||
];
|
||||
|
||||
const schema = z.object({
|
||||
tier_category_id: z.string().min(1, "Tier category is required."),
|
||||
label: z.string().min(1, "Label is required."),
|
||||
@@ -47,6 +54,7 @@ const schema = z.object({
|
||||
duration_unit: z.string().min(1),
|
||||
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
|
||||
currency: z.string().length(3, "Must be a 3-letter currency code.").default("USD"),
|
||||
status: z.enum(["draft", "published"]).default("draft"),
|
||||
}).superRefine(({ duration_value, duration_unit }, ctx) => {
|
||||
const rule = DURATION_UNIT_LIMITS[duration_unit];
|
||||
if (rule && duration_value > rule.max) {
|
||||
@@ -76,9 +84,9 @@ function SectionCard({ title, children }) {
|
||||
// ─── Steps config ─────────────────────────────────────────────────────────────
|
||||
|
||||
const STEPS = [
|
||||
{ label: "Category & Label", description: "Tier category, label & description" },
|
||||
{ label: "Bundles & Details", description: "What this unlocks, plus its label & description" },
|
||||
{ label: "Duration & Pricing", description: "Billing period, price & currency" },
|
||||
{ label: "Bundles", description: "Choose which courses, units & lessons this unlocks" },
|
||||
{ label: "Review", description: "Confirm everything before creating" },
|
||||
];
|
||||
|
||||
function StepIndicator({ steps, current, maxStepReached, onStepClick }) {
|
||||
@@ -134,6 +142,48 @@ function StepIndicator({ steps, current, maxStepReached, onStepClick }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Review step ──────────────────────────────────────────────────────────────
|
||||
// Read-only summary — no inline editing here. Go back a step to change
|
||||
// something. Re-fetches the by-subscription item list to resolve titles for
|
||||
// whichever ids are selected (the pickers don't expose their loaded list
|
||||
// upward, so this is the simplest way to show names instead of just counts).
|
||||
function BundleReviewList({ bundleType, categorySlug, selectedIds }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const endpoint = {
|
||||
course: "/admin/courses/by-subscription",
|
||||
unit: "/admin/units/by-subscription",
|
||||
lesson: "/admin/lessons/by-subscription",
|
||||
}[bundleType];
|
||||
const idKey = { course: "course_id", unit: "unit_id", lesson: "lesson_id" }[bundleType];
|
||||
|
||||
useEffect(() => {
|
||||
if (!categorySlug || !endpoint) { setItems([]); return; }
|
||||
setLoading(true);
|
||||
api.get(`${endpoint}?slug=${encodeURIComponent(categorySlug)}`)
|
||||
.then(({ data }) => setItems(data.data ?? []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [categorySlug, endpoint]);
|
||||
|
||||
if (loading) return <p className="text-sm text-muted-foreground">Loading selected items…</p>;
|
||||
|
||||
const selected = items.filter((i) => selectedIds.has(String(i[idKey])));
|
||||
|
||||
if (selected.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No items selected.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="text-sm space-y-1 list-disc list-inside">
|
||||
{selected.map((i) => (
|
||||
<li key={i[idKey]}>{i.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AddPlan() {
|
||||
@@ -146,12 +196,10 @@ export default function AddPlan() {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [catLoading, setCatLoading] = useState(true);
|
||||
const [currencies, setCurrencies] = useState([]);
|
||||
const [bundleType, setBundleType] = useState("course");
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
||||
const [courseConflicts, setCourseConflicts] = useState(0);
|
||||
const [selectedUnitIds, setSelectedUnitIds] = useState(new Set());
|
||||
const [unitConflicts, setUnitConflicts] = useState(0);
|
||||
const [selectedLessonIds, setSelectedLessonIds] = useState(new Set());
|
||||
const [lessonConflicts, setLessonConflicts] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
@@ -165,7 +213,7 @@ export default function AddPlan() {
|
||||
|
||||
const { register, handleSubmit, trigger, setValue, watch, control, formState: { errors, isDirty } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { tier_category_id: "", label: "", description: "", features: [], duration_value: 30, duration_unit: "day", price: "", currency: "USD" },
|
||||
defaultValues: { tier_category_id: "", label: "", description: "", features: [], duration_value: 30, duration_unit: "day", price: "", currency: "USD", status: "draft" },
|
||||
});
|
||||
|
||||
const { fields: featureFields, append: appendFeature, remove: removeFeature, insert: insertFeature } =
|
||||
@@ -192,25 +240,35 @@ export default function AddPlan() {
|
||||
);
|
||||
|
||||
const selectedCategoryId = watch("tier_category_id");
|
||||
const selectedCategory = useMemo(
|
||||
() => categories.find((c) => String(c.tier_category_id) === selectedCategoryId) ?? null,
|
||||
[selectedCategoryId, categories]
|
||||
);
|
||||
|
||||
// Derive the subscription slug from the chosen category
|
||||
const categorySlug = useMemo(() => {
|
||||
if (!selectedCategoryId) return null;
|
||||
return categories.find((c) => String(c.tier_category_id) === selectedCategoryId)?.slug ?? null;
|
||||
}, [selectedCategoryId, categories]);
|
||||
const categorySlug = selectedCategory?.slug ?? null;
|
||||
|
||||
// Reset pickers when category changes
|
||||
// Reset all bundle selections when category changes
|
||||
useEffect(() => {
|
||||
setSelectedCourseIds(new Set());
|
||||
setCourseConflicts(0);
|
||||
setSelectedUnitIds(new Set());
|
||||
setUnitConflicts(0);
|
||||
setSelectedLessonIds(new Set());
|
||||
setLessonConflicts(0);
|
||||
}, [categorySlug]);
|
||||
|
||||
// A bundle is single-type — switching type clears whatever the other
|
||||
// types held selected, so at most one of the three sets is ever non-empty.
|
||||
const handleBundleTypeChange = (type) => {
|
||||
if (type === bundleType) return;
|
||||
setBundleType(type);
|
||||
setSelectedCourseIds(new Set());
|
||||
setSelectedUnitIds(new Set());
|
||||
setSelectedLessonIds(new Set());
|
||||
};
|
||||
|
||||
const activeSelectedIds = { course: selectedCourseIds, unit: selectedUnitIds, lesson: selectedLessonIds }[bundleType];
|
||||
|
||||
const STEP_FIELDS = [
|
||||
["tier_category_id", "label", "description", "features"],
|
||||
["tier_category_id", "label", "description", "features", "status"],
|
||||
["duration_value", "duration_unit", "price", "currency"],
|
||||
[],
|
||||
];
|
||||
@@ -224,7 +282,7 @@ export default function AddPlan() {
|
||||
};
|
||||
|
||||
// Only allow jumping via the indicator to steps already reached through Next —
|
||||
// prevents landing on "Assigned Courses" before a category is picked.
|
||||
// prevents landing on a later step before its prerequisites are filled in.
|
||||
const handleStepClick = (i) => {
|
||||
if (i <= maxStepReached) setCurrentStep(i);
|
||||
};
|
||||
@@ -233,20 +291,23 @@ export default function AddPlan() {
|
||||
const result = await createPlan({ ...values, createdBy: user?.user_id });
|
||||
if (!result) return;
|
||||
|
||||
// Sync selected bundles
|
||||
if (selectedCourseIds.size > 0) {
|
||||
await api.post(`/admin/tiers/plans/${result.plan_id}/courses`, {
|
||||
course_ids: [...selectedCourseIds].map(Number),
|
||||
// Single-type bundle — only the active type ever holds selections, so
|
||||
// only one of these sync calls actually has anything to send.
|
||||
// IDs stay strings — these are 19-digit BIGINTs, and Number() silently
|
||||
// truncates past Number.MAX_SAFE_INTEGER (see BigInt precision-loss bug).
|
||||
if (bundleType === "course" && selectedCourseIds.size > 0) {
|
||||
await api.post(`/admin/tiers/${result.plan_id}/courses`, {
|
||||
course_ids: [...selectedCourseIds],
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (selectedUnitIds.size > 0) {
|
||||
await api.post(`/admin/tiers/plans/${result.plan_id}/units`, {
|
||||
unit_ids: [...selectedUnitIds].map(Number),
|
||||
if (bundleType === "unit" && selectedUnitIds.size > 0) {
|
||||
await api.post(`/admin/tiers/${result.plan_id}/units`, {
|
||||
unit_ids: [...selectedUnitIds],
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (selectedLessonIds.size > 0) {
|
||||
await api.post(`/admin/tiers/plans/${result.plan_id}/lessons`, {
|
||||
lesson_ids: [...selectedLessonIds].map(Number),
|
||||
if (bundleType === "lesson" && selectedLessonIds.size > 0) {
|
||||
await api.post(`/admin/tiers/${result.plan_id}/lessons`, {
|
||||
lesson_ids: [...selectedLessonIds],
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -282,9 +343,9 @@ export default function AddPlan() {
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
{/* ── Step 0: Category & Label ── */}
|
||||
{/* ── Step 0: Bundles & Details ── */}
|
||||
{currentStep === 0 && (
|
||||
<SectionCard title="Category & Label" description="Which tier category this plan belongs to, and how it's presented.">
|
||||
<SectionCard title="Bundles & Details" description="Which tier category this targets, what it unlocks, and how it's presented.">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Tier Category <span className="text-destructive">*</span></Label>
|
||||
@@ -318,60 +379,130 @@ export default function AddPlan() {
|
||||
<FieldError message={errors.tier_category_id?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
|
||||
<Input id="label" placeholder="e.g. Premium – 1 Month" {...register("label")} />
|
||||
<FieldError message={errors.label?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Brief description shown to users on the plans page."
|
||||
rows={3}
|
||||
{...register("description")}
|
||||
/>
|
||||
<FieldError message={errors.description?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>What's included</Label>
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Bullet points shown on the plans page and the comparison table.
|
||||
</p>
|
||||
{featureFields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
placeholder={`e.g. Access to all Premium courses`}
|
||||
onPaste={(e) => handleFeaturePaste(e, index)}
|
||||
{...register(`features.${index}.text`)}
|
||||
/>
|
||||
<FieldError message={errors.features?.[index]?.text?.message} />
|
||||
{categorySlug && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Content Type <span className="text-destructive">*</span></Label>
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
A bundle unlocks exactly one content type — pick which.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{BUNDLE_TYPES.map(({ value, label, icon: Icon }) => (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={bundleType === value ? "default" : "outline"}
|
||||
onClick={() => handleBundleTypeChange(value)}
|
||||
className={cn("flex-1")}
|
||||
>
|
||||
<Icon className="size-3.5 mr-1.5" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-5">
|
||||
{bundleType === "course" && (
|
||||
<CoursePicker
|
||||
subscription={categorySlug}
|
||||
selectedIds={selectedCourseIds}
|
||||
onChange={setSelectedCourseIds}
|
||||
/>
|
||||
)}
|
||||
{bundleType === "unit" && (
|
||||
<UnitPicker
|
||||
subscription={categorySlug}
|
||||
selectedIds={selectedUnitIds}
|
||||
onChange={setSelectedUnitIds}
|
||||
/>
|
||||
)}
|
||||
{bundleType === "lesson" && (
|
||||
<LessonPicker
|
||||
subscription={categorySlug}
|
||||
selectedIds={selectedLessonIds}
|
||||
onChange={setSelectedLessonIds}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-5 space-y-1.5">
|
||||
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
|
||||
<Input id="label" placeholder="e.g. Premium – 1 Month" {...register("label")} />
|
||||
<FieldError message={errors.label?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Brief description shown to users on the plans page."
|
||||
rows={3}
|
||||
{...register("description")}
|
||||
/>
|
||||
<FieldError message={errors.description?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Status</Label>
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Draft plans are hidden from the public Plans page entirely — publish when ready to sell.
|
||||
</p>
|
||||
<Select
|
||||
value={watch("status") ?? "draft"}
|
||||
onValueChange={(v) => setValue("status", v, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="published">Published</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.status?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>What's included</Label>
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Bullet points shown on the plans page and the comparison table.
|
||||
</p>
|
||||
{featureFields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-start gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Input
|
||||
placeholder={`e.g. Access to all Premium courses`}
|
||||
onPaste={(e) => handleFeaturePaste(e, index)}
|
||||
{...register(`features.${index}.text`)}
|
||||
/>
|
||||
<FieldError message={errors.features?.[index]?.text?.message} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeFeature(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeFeature(index)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => appendFeature({ text: "" })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Feature
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => appendFeature({ text: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Feature
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
@@ -424,31 +555,68 @@ export default function AddPlan() {
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Bundles ── */}
|
||||
{/* ── Step 2: Review ── */}
|
||||
{currentStep === 2 && (
|
||||
<SectionCard title="Bundles" description="Choose which courses, units, and lessons this plan unlocks.">
|
||||
<CoursePicker
|
||||
subscription={categorySlug}
|
||||
selectedIds={selectedCourseIds}
|
||||
onChange={setSelectedCourseIds}
|
||||
onConflictsChange={setCourseConflicts}
|
||||
/>
|
||||
<div className="border-t pt-5">
|
||||
<UnitPicker
|
||||
subscription={categorySlug}
|
||||
selectedIds={selectedUnitIds}
|
||||
onChange={setSelectedUnitIds}
|
||||
onConflictsChange={setUnitConflicts}
|
||||
/>
|
||||
<SectionCard title="Review" description="Confirm everything before creating this plan.">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Tier Category</Label>
|
||||
<p className="text-sm">
|
||||
{selectedCategory ? `${selectedCategory.name} (${selectedCategory.slug})` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t pt-5">
|
||||
<LessonPicker
|
||||
subscription={categorySlug}
|
||||
selectedIds={selectedLessonIds}
|
||||
onChange={setSelectedLessonIds}
|
||||
onConflictsChange={setLessonConflicts}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Bundle — {BUNDLE_TYPES.find((t) => t.value === bundleType)?.label}
|
||||
{" "}({activeSelectedIds.size} selected)
|
||||
</Label>
|
||||
<BundleReviewList bundleType={bundleType} categorySlug={categorySlug} selectedIds={activeSelectedIds} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Label</Label>
|
||||
<p className="text-sm">{watch("label") || "—"}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Status</Label>
|
||||
<p className="text-sm capitalize">{watch("status") || "draft"}</p>
|
||||
</div>
|
||||
|
||||
{watch("description") && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Description</Label>
|
||||
<p className="text-sm">{watch("description")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{featureFields.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">What's included</Label>
|
||||
<ul className="text-sm space-y-1 list-disc list-inside">
|
||||
{watch("features")?.map((f, i) => (
|
||||
<li key={i}>{f.text}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Duration</Label>
|
||||
<p className="text-sm">
|
||||
{watch("duration_value")} {DURATION_UNITS.find((u) => u.value === watch("duration_unit"))?.label ?? watch("duration_unit")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Price</Label>
|
||||
<p className="text-sm">{watch("price")} {watch("currency")}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Go back a step to change anything above.
|
||||
</p>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
@@ -471,7 +639,7 @@ export default function AddPlan() {
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={loading || catLoading || !selectedCategoryId || courseConflicts > 0 || unitConflicts > 0 || lessonConflicts > 0}
|
||||
disabled={loading || catLoading || !selectedCategoryId}
|
||||
>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Plan
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, House, TriangleAlert, Plus, Trash2 } from "lucide-react";
|
||||
import { ArrowLeft, House, TriangleAlert, Plus, Trash2, BookOpen, Layers, FileText } from "lucide-react";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
@@ -22,8 +22,15 @@ import { UnitPicker } from "@/modules/admin/components/tiers/UnitPicker";
|
||||
import { LessonPicker } from "@/modules/admin/components/tiers/LessonPicker";
|
||||
import { CurrencyPicker } from "@/modules/admin/components/tiers/CurrencyPicker";
|
||||
import api from "@/utils/api.util";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
|
||||
const BUNDLE_TYPES = [
|
||||
{ value: "course", label: "Course", icon: BookOpen },
|
||||
{ value: "unit", label: "Units", icon: Layers },
|
||||
{ value: "lesson", label: "Lessons", icon: FileText },
|
||||
];
|
||||
|
||||
const DURATION_UNITS = [
|
||||
{ value: "minute", label: "Minute(s)" },
|
||||
{ value: "hour", label: "Hour(s)" },
|
||||
@@ -55,6 +62,7 @@ const schema = z.object({
|
||||
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
|
||||
currency: z.string().length(3),
|
||||
is_active: z.boolean().default(true),
|
||||
status: z.enum(["draft", "published"]).default("draft"),
|
||||
}).superRefine(({ duration_value, duration_unit }, ctx) => {
|
||||
const rule = DURATION_UNIT_LIMITS[duration_unit];
|
||||
if (rule && duration_value > rule.max) {
|
||||
@@ -90,13 +98,11 @@ export default function EditPlan() {
|
||||
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
||||
const [coursesLoaded, setCoursesLoaded] = useState(false);
|
||||
const [courseConflicts, setCourseConflicts] = useState(0);
|
||||
const [selectedUnitIds, setSelectedUnitIds] = useState(new Set());
|
||||
const [unitsLoaded, setUnitsLoaded] = useState(false);
|
||||
const [unitConflicts, setUnitConflicts] = useState(0);
|
||||
const [selectedLessonIds, setSelectedLessonIds] = useState(new Set());
|
||||
const [lessonsLoaded, setLessonsLoaded] = useState(false);
|
||||
const [lessonConflicts, setLessonConflicts] = useState(0);
|
||||
const [bundleType, setBundleType] = useState(null); // null until auto-detected from preload
|
||||
const [currencies, setCurrencies] = useState([]);
|
||||
const [impactDialog, setImpactDialog] = useState(false);
|
||||
const [impactCount, setImpactCount] = useState(0);
|
||||
@@ -144,6 +150,7 @@ export default function EditPlan() {
|
||||
price: plan.price,
|
||||
currency: plan.currency,
|
||||
is_active: plan.is_active,
|
||||
status: plan.status ?? "draft",
|
||||
});
|
||||
}
|
||||
}, [plan]);
|
||||
@@ -183,6 +190,28 @@ export default function EditPlan() {
|
||||
.catch(() => setLessonsLoaded(true));
|
||||
}, [planId]);
|
||||
|
||||
// A bundle is single-type — auto-detect which type this plan already holds
|
||||
// once all three preloads land, and only ever do this once (afterward,
|
||||
// switching type is a deliberate admin action, not a re-detection).
|
||||
useEffect(() => {
|
||||
if (bundleType !== null) return;
|
||||
if (!coursesLoaded || !unitsLoaded || !lessonsLoaded) return;
|
||||
if (selectedCourseIds.size > 0) setBundleType("course");
|
||||
else if (selectedUnitIds.size > 0) setBundleType("unit");
|
||||
else if (selectedLessonIds.size > 0) setBundleType("lesson");
|
||||
else setBundleType("course"); // no items assigned yet — default to Course
|
||||
}, [bundleType, coursesLoaded, unitsLoaded, lessonsLoaded, selectedCourseIds, selectedUnitIds, selectedLessonIds]);
|
||||
|
||||
// Switching type clears whatever the other types held selected, so at most
|
||||
// one of the three sets is ever non-empty going forward.
|
||||
const handleBundleTypeChange = (type) => {
|
||||
if (type === bundleType) return;
|
||||
setBundleType(type);
|
||||
setSelectedCourseIds(new Set());
|
||||
setSelectedUnitIds(new Set());
|
||||
setSelectedLessonIds(new Set());
|
||||
};
|
||||
|
||||
const durationChanged = (values) => {
|
||||
if (!plan) return false;
|
||||
const originalUnit = plan.duration_unit ?? "day";
|
||||
@@ -196,14 +225,17 @@ export default function EditPlan() {
|
||||
const doSave = async (values) => {
|
||||
const result = await updatePlan(planId, { ...values, updatedBy: user?.user_id });
|
||||
if (!result) return;
|
||||
// Single-type bundle — sync all three endpoints explicitly (sending an
|
||||
// empty list for the inactive types) so switching bundle type also
|
||||
// clears out whatever the plan previously held under the old type.
|
||||
await api.post(`/admin/tiers/${planId}/courses`, {
|
||||
course_ids: [...selectedCourseIds],
|
||||
course_ids: bundleType === "course" ? [...selectedCourseIds] : [],
|
||||
}).catch(() => {});
|
||||
await api.post(`/admin/tiers/${planId}/units`, {
|
||||
unit_ids: [...selectedUnitIds],
|
||||
unit_ids: bundleType === "unit" ? [...selectedUnitIds] : [],
|
||||
}).catch(() => {});
|
||||
await api.post(`/admin/tiers/${planId}/lessons`, {
|
||||
lesson_ids: [...selectedLessonIds],
|
||||
lesson_ids: bundleType === "lesson" ? [...selectedLessonIds] : [],
|
||||
}).catch(() => {});
|
||||
bypassOnce();
|
||||
navigate("/admin/tiers/plans");
|
||||
@@ -365,6 +397,26 @@ export default function EditPlan() {
|
||||
<FieldError message={errors.currency?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Status</Label>
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Draft plans are hidden from the public Plans page entirely. Published plans appear there (Active below still controls whether they can be purchased).
|
||||
</p>
|
||||
<Select
|
||||
value={watch("status") ?? "draft"}
|
||||
onValueChange={(v) => setValue("status", v, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="published">Published</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.status?.message} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Active</p>
|
||||
@@ -378,17 +430,8 @@ export default function EditPlan() {
|
||||
</SectionCard>
|
||||
|
||||
{plan?.tier && (
|
||||
<SectionCard title="Bundles" description="Choose which courses, units, and lessons this plan unlocks.">
|
||||
{coursesLoaded ? (
|
||||
<CoursePicker
|
||||
subscription={plan.tier}
|
||||
selectedIds={selectedCourseIds}
|
||||
onChange={setSelectedCourseIds}
|
||||
isPreloaded={true}
|
||||
currentPlanId={planId}
|
||||
onConflictsChange={setCourseConflicts}
|
||||
/>
|
||||
) : (
|
||||
<SectionCard title="Bundles" description="Choose which single content type this plan unlocks.">
|
||||
{bundleType === null || !coursesLoaded || !unitsLoaded || !lessonsLoaded ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-36" />
|
||||
@@ -396,55 +439,61 @@ export default function EditPlan() {
|
||||
</div>
|
||||
<Skeleton className="h-8 w-52" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Content Type <span className="text-destructive">*</span></Label>
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
A bundle unlocks exactly one content type — pick which.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{BUNDLE_TYPES.map(({ value, label, icon: Icon }) => (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={bundleType === value ? "default" : "outline"}
|
||||
onClick={() => handleBundleTypeChange(value)}
|
||||
className={cn("flex-1")}
|
||||
>
|
||||
<Icon className="size-3.5 mr-1.5" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-5">
|
||||
{bundleType === "course" && (
|
||||
<CoursePicker
|
||||
subscription={plan.tier}
|
||||
selectedIds={selectedCourseIds}
|
||||
onChange={setSelectedCourseIds}
|
||||
/>
|
||||
)}
|
||||
{bundleType === "unit" && (
|
||||
<UnitPicker
|
||||
subscription={plan.tier}
|
||||
selectedIds={selectedUnitIds}
|
||||
onChange={setSelectedUnitIds}
|
||||
/>
|
||||
)}
|
||||
{bundleType === "lesson" && (
|
||||
<LessonPicker
|
||||
subscription={plan.tier}
|
||||
selectedIds={selectedLessonIds}
|
||||
onChange={setSelectedLessonIds}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-5">
|
||||
{unitsLoaded ? (
|
||||
<UnitPicker
|
||||
subscription={plan.tier}
|
||||
selectedIds={selectedUnitIds}
|
||||
onChange={setSelectedUnitIds}
|
||||
isPreloaded={true}
|
||||
currentPlanId={planId}
|
||||
onConflictsChange={setUnitConflicts}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-36" />
|
||||
<Skeleton className="h-8 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-52" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-5">
|
||||
{lessonsLoaded ? (
|
||||
<LessonPicker
|
||||
subscription={plan.tier}
|
||||
selectedIds={selectedLessonIds}
|
||||
onChange={setSelectedLessonIds}
|
||||
isPreloaded={true}
|
||||
currentPlanId={planId}
|
||||
onConflictsChange={setLessonConflicts}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-36" />
|
||||
<Skeleton className="h-8 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-52" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button type="button" variant="outline" onClick={() => navigate("/admin/tiers/plans")} disabled={loading || impactLoading}>Cancel</Button>
|
||||
<Button type="submit" disabled={loading || impactLoading || courseConflicts > 0 || unitConflicts > 0 || lessonConflicts > 0}>
|
||||
<Button type="submit" disabled={loading || impactLoading}>
|
||||
{(loading || impactLoading) && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
|
||||
@@ -150,7 +150,6 @@ function EditTierCategoryInner({ isAdd }) {
|
||||
const [badgeIcon, setBadgeIcon] = useState(null);
|
||||
const [badgeLabel, setBadgeLabel] = useState("");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [isSpecial, setIsSpecial] = useState(false);
|
||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
||||
const [clearBadge, setClearBadge] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
@@ -169,7 +168,6 @@ function EditTierCategoryInner({ isAdd }) {
|
||||
setBadgeIcon(category.badge_icon ?? null);
|
||||
setBadgeLabel(category.badge_label ?? "");
|
||||
setIsActive(category.is_active ?? true);
|
||||
setIsSpecial(category.is_special ?? false);
|
||||
setSelectedAsset(null);
|
||||
setClearBadge(false);
|
||||
}
|
||||
@@ -195,7 +193,6 @@ function EditTierCategoryInner({ isAdd }) {
|
||||
badge_icon: badgeIcon || null,
|
||||
badge_label: badgeLabel.trim() || null,
|
||||
is_active: isActive,
|
||||
is_special: isSpecial,
|
||||
};
|
||||
|
||||
if (selectedAsset) payload.badge_asset_id = selectedAsset.asset_id;
|
||||
@@ -311,14 +308,6 @@ function EditTierCategoryInner({ isAdd }) {
|
||||
<Label htmlFor="is_active">Active</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="is_special" checked={isSpecial} onCheckedChange={setIsSpecial} />
|
||||
<Label htmlFor="is_special">Special</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Marks this category as intended for restrictive/limited-access plans (e.g. capped starter-content access rules).
|
||||
</p>
|
||||
</SectionCard>
|
||||
|
||||
{/* Badge */}
|
||||
|
||||
@@ -2,8 +2,7 @@ import { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft, CreditCard, Pencil, Tag, BadgeCheck, BookOpen, Clock,
|
||||
ShieldCheck, Plus, Trash2, Loader2, Receipt, KeyRound, Users, Lock, FileText,
|
||||
ListChecks,
|
||||
ShieldCheck, Plus, Trash2, Loader2, Receipt, FileText,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -20,7 +19,6 @@ import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import api from "@/utils/api.util";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
import { AccessRuleItemPicker } from "@/modules/admin/components/tiers/AccessRuleItemPicker";
|
||||
import PaymentsTable from "@/modules/admin/components/tiers/PaymentsTable";
|
||||
|
||||
// ─── Shared helpers ────────────────────────────────────────────────────────────
|
||||
@@ -84,6 +82,16 @@ function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoadin
|
||||
}
|
||||
if (!plan) return <p className="text-sm text-muted-foreground">Plan not found.</p>;
|
||||
|
||||
// A bundle is single-type — only one of these is ever populated — so show
|
||||
// just that one section instead of three, two of which would always be empty.
|
||||
const bundleLoading = coursesLoading || unitsLoading || lessonsLoading;
|
||||
const activeBundleType = bundleLoading
|
||||
? null
|
||||
: assignedCourses.length > 0 ? "course"
|
||||
: assignedUnits.length > 0 ? "unit"
|
||||
: assignedLessons.length > 0 ? "lesson"
|
||||
: "course";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SectionCard icon={Tag} title="Plan Details">
|
||||
@@ -98,7 +106,12 @@ function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoadin
|
||||
<InfoRow label="Duration">{formatDuration(plan.duration_days, plan.duration_unit)}</InfoRow>
|
||||
<InfoRow label="Price">{plan.currency} {Number(plan.price).toFixed(2)}</InfoRow>
|
||||
<InfoRow label="Currency">{plan.currency}</InfoRow>
|
||||
<InfoRow label="Status">
|
||||
<InfoRow label="Publish Status">
|
||||
<Badge variant={plan.status === "published" ? "default" : "secondary"} className="mt-0.5 capitalize">
|
||||
{plan.status ?? "draft"}
|
||||
</Badge>
|
||||
</InfoRow>
|
||||
<InfoRow label="Active">
|
||||
<Badge variant={plan.is_active ? "default" : "secondary"} className="mt-0.5">
|
||||
{plan.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
@@ -112,12 +125,17 @@ function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoadin
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={BookOpen} title="Assigned Courses">
|
||||
{coursesLoading ? (
|
||||
{bundleLoading && (
|
||||
<SectionCard icon={BookOpen} title="Assigned Content">
|
||||
<div className="space-y-2">
|
||||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
) : assignedCourses.length === 0 ? (
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{activeBundleType === "course" && (
|
||||
<SectionCard icon={BookOpen} title="Assigned Courses">
|
||||
{assignedCourses.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 courses assigned to this plan yet.
|
||||
@@ -154,19 +172,15 @@ function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoadin
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!coursesLoading && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{assignedCourses.length} course{assignedCourses.length !== 1 ? "s" : ""} assigned
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{assignedCourses.length} course{assignedCourses.length !== 1 ? "s" : ""} assigned
|
||||
</p>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{activeBundleType === "unit" && (
|
||||
<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 ? (
|
||||
{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.
|
||||
@@ -185,19 +199,15 @@ function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoadin
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!unitsLoading && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{assignedUnits.length} unit{assignedUnits.length !== 1 ? "s" : ""} assigned
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{assignedUnits.length} unit{assignedUnits.length !== 1 ? "s" : ""} assigned
|
||||
</p>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{activeBundleType === "lesson" && (
|
||||
<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 ? (
|
||||
{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.
|
||||
@@ -216,12 +226,11 @@ function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoadin
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!lessonsLoading && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{assignedLessons.length} lesson{assignedLessons.length !== 1 ? "s" : ""} assigned
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{assignedLessons.length} lesson{assignedLessons.length !== 1 ? "s" : ""} assigned
|
||||
</p>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard icon={BadgeCheck} title="Audit">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -545,323 +554,6 @@ function PaymentPolicyTab({ planId, plan }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tab: Access Rules ─────────────────────────────────────────────────────────
|
||||
// Configures plan_policies.access_rules — evaluateCourseAccess (utils/accessPolicy.util.js)
|
||||
// reads these instead of falling back to plain tier-rank comparison once any
|
||||
// rule exists here. Empty (default) = unchanged rank-comparison behavior.
|
||||
|
||||
const RULE_TYPES = [
|
||||
{ value: "course_subscription_access", label: "Allowed subscription levels", icon: Tag,
|
||||
description: "Only grant access to courses at these subscription levels." },
|
||||
{ value: "required_active_tier", label: "Required active tier", icon: KeyRound,
|
||||
description: "User's active tier must be at least this rank." },
|
||||
{ value: "group_restriction", label: "Group restriction", icon: Users,
|
||||
description: "User must belong to at least one of these groups." },
|
||||
{ value: "item_allowlist", label: "Specific item preview", icon: ListChecks,
|
||||
description: "Grant access to these exact courses/units/lessons regardless of level — e.g. a curated Exclusive preview for Premium subscribers." },
|
||||
];
|
||||
|
||||
const ITEM_TYPE_LABELS = { course: "Courses", unit: "Units", lesson: "Lessons" };
|
||||
|
||||
function ruleSummary(rule, tierCategories, groups) {
|
||||
if (rule.type === "course_subscription_access") {
|
||||
const names = (rule.levels ?? []).map((slug) => tierCategories.find((c) => c.slug === slug)?.name ?? slug);
|
||||
return `Allowed levels: ${names.join(", ") || "—"}`;
|
||||
}
|
||||
if (rule.type === "required_active_tier") {
|
||||
return `Requires active tier: ${tierCategories.find((c) => c.slug === rule.tier)?.name ?? rule.tier}`;
|
||||
}
|
||||
if (rule.type === "group_restriction") {
|
||||
const names = (rule.group_ids ?? []).map((id) => groups.find((g) => String(g.group_id) === String(id))?.name ?? id);
|
||||
return `Restricted to groups: ${names.join(", ") || "—"}`;
|
||||
}
|
||||
if (rule.type === "item_allowlist") {
|
||||
const count = (rule.item_ids ?? []).length;
|
||||
const noun = ITEM_TYPE_LABELS[rule.item_type]?.toLowerCase() ?? "item(s)";
|
||||
return `Preview access: ${count} specific ${noun}`;
|
||||
}
|
||||
return rule.type;
|
||||
}
|
||||
|
||||
function AccessRulesTab({ planId, tierCategoryId }) {
|
||||
const [rules, setRules] = useState([]);
|
||||
const [rulesLoading, setRulesLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
|
||||
const isSpecialCategory = tierCategories.find(
|
||||
(c) => String(c.tier_category_id) === String(tierCategoryId)
|
||||
)?.is_special ?? false;
|
||||
|
||||
// Free content is always accessible regardless of any rule (evaluateCourseAccess
|
||||
// short-circuits rank-0 content to allowed), so Free is never a meaningful
|
||||
// option in any access rule — only paid levels can actually be gated.
|
||||
const payableTierCategories = tierCategories.filter((c) => !c.is_default);
|
||||
|
||||
const availableRuleTypes = RULE_TYPES.filter(
|
||||
(t) => t.value !== "item_allowlist" || isSpecialCategory
|
||||
);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newType, setNewType] = useState("course_subscription_access");
|
||||
const [newLevels, setNewLevels] = useState([]);
|
||||
const [newTier, setNewTier] = useState("");
|
||||
const [newGroupIds, setNewGroupIds] = useState([]);
|
||||
const [newItemType, setNewItemType] = useState("course");
|
||||
const [newItemSlug, setNewItemSlug] = useState("");
|
||||
const [newItemIds, setNewItemIds] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
setRulesLoading(true);
|
||||
api.get(`/admin/tier-policies/plans/${planId}/policy`)
|
||||
.then(({ data }) => setRules(data.data?.access_rules ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setRulesLoading(false));
|
||||
|
||||
api.get("/admin/tiers/categories").then(({ data }) => setTierCategories(data.data ?? [])).catch(() => {});
|
||||
api.get("/admin/groups", { params: { limit: 100 } })
|
||||
.then(({ data }) => setGroups(data.data?.data ?? []))
|
||||
.catch(() => {});
|
||||
}, [planId]);
|
||||
|
||||
const handleSave = async (next) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put(`/admin/tier-policies/plans/${planId}/policy`, { access_rules: next });
|
||||
setRules(next);
|
||||
toast("Access rules saved.");
|
||||
} catch (err) {
|
||||
toast(err?.response?.data?.message ?? "Could not save access rules.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetAddForm = () => {
|
||||
setShowAdd(false);
|
||||
setNewType("course_subscription_access");
|
||||
setNewLevels([]);
|
||||
setNewTier("");
|
||||
setNewGroupIds([]);
|
||||
setNewItemType("course");
|
||||
setNewItemSlug("");
|
||||
setNewItemIds([]);
|
||||
};
|
||||
|
||||
const handleAddRule = () => {
|
||||
let rule;
|
||||
if (newType === "course_subscription_access") {
|
||||
if (!newLevels.length) { toast("Select at least one subscription level."); return; }
|
||||
rule = { type: newType, levels: newLevels };
|
||||
} else if (newType === "required_active_tier") {
|
||||
if (!newTier) { toast("Select a required tier."); return; }
|
||||
rule = { type: newType, tier: newTier };
|
||||
} else if (newType === "group_restriction") {
|
||||
if (!newGroupIds.length) { toast("Select at least one group."); return; }
|
||||
rule = { type: newType, group_ids: newGroupIds.map(Number) };
|
||||
} else {
|
||||
if (!newItemIds.length) { toast("Select at least one item."); return; }
|
||||
rule = { type: newType, item_type: newItemType, item_ids: newItemIds };
|
||||
}
|
||||
handleSave([...rules, rule]);
|
||||
resetAddForm();
|
||||
};
|
||||
|
||||
const handleRemoveRule = (index) => {
|
||||
handleSave(rules.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
if (rulesLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[...Array(2)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SectionCard
|
||||
icon={Lock}
|
||||
title="Access Rules"
|
||||
description="Overrides the default rank-comparison access check for this plan. Leave empty to use plain tier-rank comparison."
|
||||
>
|
||||
{rules.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{rules.map((rule, i) => {
|
||||
const meta = RULE_TYPES.find((t) => t.value === rule.type);
|
||||
const Icon = meta?.icon ?? Lock;
|
||||
return (
|
||||
<div key={i} className="flex items-center justify-between gap-3 rounded-lg border bg-muted/30 px-4 py-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Icon className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm">{ruleSummary(rule, tierCategories, groups)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="text-destructive hover:text-destructive shrink-0"
|
||||
disabled={saving}
|
||||
onClick={() => handleRemoveRule(i)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
<Lock className="size-4 shrink-0" />
|
||||
No access rules configured — falls back to plain tier-rank comparison.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd ? (
|
||||
<div className="rounded-lg border bg-muted/20 p-4 space-y-4">
|
||||
<p className="text-sm font-medium">New Access Rule</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Rule Type</Label>
|
||||
<Select value={newType} onValueChange={setNewType}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableRuleTypes.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!isSpecialCategory && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mark this plan's tier category as "Special" (Admin > Tier Categories) to unlock the "Specific item preview" rule type.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{RULE_TYPES.find((t) => t.value === newType)?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{newType === "course_subscription_access" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Allowed Levels</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{payableTierCategories.map((c) => (
|
||||
<Badge
|
||||
key={c.slug}
|
||||
variant={newLevels.includes(c.slug) ? "default" : "outline"}
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => setNewLevels((prev) =>
|
||||
prev.includes(c.slug) ? prev.filter((s) => s !== c.slug) : [...prev, c.slug]
|
||||
)}
|
||||
>
|
||||
{c.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newType === "required_active_tier" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Required Tier</Label>
|
||||
<Select value={newTier} onValueChange={setNewTier}>
|
||||
<SelectTrigger><SelectValue placeholder="Select a tier" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{payableTierCategories.map((c) => (
|
||||
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newType === "group_restriction" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Groups</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{groups.map((g) => (
|
||||
<Badge
|
||||
key={g.group_id}
|
||||
variant={newGroupIds.includes(String(g.group_id)) ? "default" : "outline"}
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => setNewGroupIds((prev) =>
|
||||
prev.includes(String(g.group_id))
|
||||
? prev.filter((id) => id !== String(g.group_id))
|
||||
: [...prev, String(g.group_id)]
|
||||
)}
|
||||
>
|
||||
{g.name}
|
||||
</Badge>
|
||||
))}
|
||||
{groups.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">No groups found.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newType === "item_allowlist" && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Item Type</Label>
|
||||
<Select
|
||||
value={newItemType}
|
||||
onValueChange={(v) => { setNewItemType(v); setNewItemIds([]); }}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="course">Course</SelectItem>
|
||||
<SelectItem value="unit">Unit</SelectItem>
|
||||
<SelectItem value="lesson">Lesson</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Subscription Level to browse</Label>
|
||||
<Select
|
||||
value={newItemSlug}
|
||||
onValueChange={(v) => { setNewItemSlug(v); setNewItemIds([]); }}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="Select a level" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{payableTierCategories.map((c) => (
|
||||
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{newItemSlug && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{ITEM_TYPE_LABELS[newItemType]}</Label>
|
||||
<AccessRuleItemPicker
|
||||
itemType={newItemType}
|
||||
subscriptionSlug={newItemSlug}
|
||||
selectedIds={newItemIds}
|
||||
onChange={setNewItemIds}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<Button variant="outline" size="sm" onClick={resetAddForm}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleAddRule} disabled={saving}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAdd(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Add Access Rule
|
||||
</Button>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tab: Payments ─────────────────────────────────────────────────────────────
|
||||
|
||||
function PaymentsTab({ planId }) {
|
||||
@@ -878,7 +570,6 @@ function PaymentsTab({ planId }) {
|
||||
|
||||
const TABS = [
|
||||
{ key: "details", label: "Plan Details", icon: CreditCard },
|
||||
{ key: "access", label: "Access Rules", icon: Lock },
|
||||
{ key: "policy", label: "Payment Policy", icon: ShieldCheck },
|
||||
{ key: "payments", label: "Payments", icon: Receipt },
|
||||
];
|
||||
@@ -1001,9 +692,6 @@ export default function ViewPlan() {
|
||||
lessonsLoading={lessonsLoading}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "access" && (
|
||||
<AccessRulesTab planId={planId} tierCategoryId={plan?.tier_category_id} />
|
||||
)}
|
||||
{activeTab === "policy" && (
|
||||
<PaymentPolicyTab planId={planId} plan={plan} />
|
||||
)}
|
||||
|
||||
@@ -70,7 +70,9 @@ export default function IntroPage() {
|
||||
if (!givenName.trim()) e.givenName = 'First name is required.'
|
||||
if (!lastName.trim()) e.lastName = 'Last name is required.'
|
||||
if (!occupation.trim()) e.occupation = 'Occupation is required.'
|
||||
if (dateOfBirth) {
|
||||
if (!dateOfBirth) {
|
||||
e.dateOfBirth = 'Date of birth is required.'
|
||||
} else {
|
||||
const age = (Date.now() - new Date(dateOfBirth)) / (1000 * 60 * 60 * 24 * 365.25)
|
||||
if (isNaN(age) || age < 13 || age > 120) e.dateOfBirth = 'Please enter a valid date of birth.'
|
||||
}
|
||||
@@ -105,7 +107,7 @@ export default function IntroPage() {
|
||||
extension_name: extensionName,
|
||||
full_name: fullName,
|
||||
},
|
||||
...(dateOfBirth ? { date_of_birth: dateOfBirth } : {}),
|
||||
date_of_birth: dateOfBirth,
|
||||
occupation,
|
||||
phone_number: phone.trim()
|
||||
? (() => {
|
||||
@@ -224,9 +226,7 @@ export default function IntroPage() {
|
||||
|
||||
{/* Date of birth */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">
|
||||
Date of birth <span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Label className="text-xs">Date of birth <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateOfBirth}
|
||||
|
||||
@@ -43,7 +43,9 @@ export const LessonCard = ({ lesson, tierMap = {}, onViewDetails }) => {
|
||||
{label}
|
||||
</Badge>
|
||||
{locked && (
|
||||
<Badge variant="outline" className="text-muted-foreground">Locked</Badge>
|
||||
<Badge variant="secondary">
|
||||
<LockIcon className="size-3" /> Locked
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,37 +1,35 @@
|
||||
// LessonUpsellModal — shown when a learner clicks a locked standalone Lesson.
|
||||
// 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.
|
||||
// Mirrors UnitUpsellModal.jsx: unlocks via any course reachable through its
|
||||
// attached Units (aggregated across all of them, since a Lesson can sit in
|
||||
// more than one), with an optional direct "Buy" listing if the lesson itself
|
||||
// has an active product, plus a generic "View Plans" fallback.
|
||||
// Shared by LessonsList and Dashboard.
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LockIcon, BookOpen, ShoppingCart, GraduationCap } from "lucide-react";
|
||||
import { LockIcon, ShoppingCart, GraduationCap, Check } 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";
|
||||
import { resolveTierBadge, cheapestTierSlug } 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 purchasable = lesson?.product?.is_active && !lesson?.has_purchased;
|
||||
const awaitingStarterSet = purchasable && lesson?.purchase_eligible === false;
|
||||
const canBuy = purchasable && !awaitingStarterSet;
|
||||
|
||||
const slug = cheapestTierSlug([lesson?.subscription, ...courses.map((c) => c.subscription)], tierMap);
|
||||
const { rank, label, cls, panel } = resolveTierBadge(slug, tierMap);
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={lesson?.title ?? "Lesson Details"}
|
||||
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."
|
||||
}
|
||||
description="Upgrade your plan to access this lesson."
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
||||
@@ -46,19 +44,7 @@ export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 py-2">
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className="space-y-6 py-2">
|
||||
{awaitingStarterSet && (
|
||||
<div className="flex items-center gap-2.5 p-4 rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40">
|
||||
<GraduationCap className="size-4 text-amber-700 dark:text-amber-400 shrink-0" />
|
||||
@@ -68,38 +54,21 @@ export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap
|
||||
</div>
|
||||
)}
|
||||
|
||||
{courses.length === 0 && !ownTier ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upgrade your plan to access this content.
|
||||
</p>
|
||||
) : (
|
||||
courses.map((course) => {
|
||||
const { label, cls } = resolveTierBadge(course.subscription, tierMap);
|
||||
return (
|
||||
<div
|
||||
key={course.course_id}
|
||||
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">
|
||||
<BookOpen className="size-4 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{course.title}</p>
|
||||
<Badge className={`${cls} mt-1`}>
|
||||
<LockIcon className="size-3" /> {label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={() => { onOpenChange(false); navigate(`/course/${course.course_id}`); }}
|
||||
>
|
||||
View Course
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
{rank > 0 && (
|
||||
<div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Badge className={cls}>
|
||||
<LockIcon className="size-3" /> {label}
|
||||
</Badge>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4">
|
||||
<li className="flex items-center gap-2"><Check /> Access to {label} content</li>
|
||||
<li className="flex items-center gap-2"><Check /> Certificates & achievements</li>
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upgrade to a <span className="font-medium">{label}</span> plan to unlock this lesson.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
// exact feature text is present in its own list.
|
||||
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import { Check, Minus, Tag } from "lucide-react";
|
||||
import { Check, Minus, Tag, Lock } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
import { getBundleContent, overlapsExistingAccess, isPlanCurrent } from "@/utils/planBundle.util";
|
||||
|
||||
export default function PlanComparisonTable({ plans, myTier, tierMap, fmtCurrency, onSelect }) {
|
||||
const featureRows = [...new Set(
|
||||
@@ -23,9 +24,8 @@ export default function PlanComparisonTable({ plans, myTier, tierMap, fmtCurrenc
|
||||
{plans.map((plan) => {
|
||||
const { label, cls } = resolveTierBadge(plan.tier, tierMap);
|
||||
const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
|
||||
// A user can hold more than one active tier concurrently — check
|
||||
// membership in the full active set, not just the best one.
|
||||
const isCurrent = (myTier?.active_tiers ?? []).some((t) => t.tier === plan.tier);
|
||||
const isCurrent = isPlanCurrent(plan, myTier);
|
||||
const isBlockedByOverlap = !isCurrent && overlapsExistingAccess(plan, myTier?.my_grants);
|
||||
return (
|
||||
<th key={plan.plan_id} className="p-4 text-center align-bottom min-w-[160px]">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
@@ -33,6 +33,11 @@ export default function PlanComparisonTable({ plans, myTier, tierMap, fmtCurrenc
|
||||
<span className="font-semibold text-foreground">{plan.label}</span>
|
||||
<span className="text-lg font-bold">{fmtCurrency(plan.price, plan.currency)}</span>
|
||||
{isCurrent && <Badge variant="secondary" className="text-xs">Current Plan</Badge>}
|
||||
{isBlockedByOverlap && (
|
||||
<Badge variant="secondary" className="text-xs gap-1">
|
||||
<Lock className="size-3" /> Already Included
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
@@ -41,12 +46,15 @@ export default function PlanComparisonTable({ plans, myTier, tierMap, fmtCurrenc
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b bg-muted/30">
|
||||
<td className="p-3 font-medium text-muted-foreground">Courses included</td>
|
||||
{plans.map((plan) => (
|
||||
<td key={plan.plan_id} className="p-3 text-center">
|
||||
{plan.course_count > 0 ? plan.course_count : "—"}
|
||||
</td>
|
||||
))}
|
||||
<td className="p-3 font-medium text-muted-foreground">Items included</td>
|
||||
{plans.map((plan) => {
|
||||
const bundle = getBundleContent(plan);
|
||||
return (
|
||||
<td key={plan.plan_id} className="p-3 text-center">
|
||||
{bundle ? `${bundle.items.length} ${bundle.noun.toLowerCase()}${bundle.items.length !== 1 ? "s" : ""}` : "—"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
{featureRows.map((text, i) => (
|
||||
<tr key={text} className={`border-b ${i % 2 === 1 ? "bg-muted/30" : ""}`}>
|
||||
@@ -76,18 +84,17 @@ export default function PlanComparisonTable({ plans, myTier, tierMap, fmtCurrenc
|
||||
<tr>
|
||||
<td className="p-4" />
|
||||
{plans.map((plan) => {
|
||||
// A user can hold more than one active tier concurrently — check
|
||||
// membership in the full active set, not just the best one.
|
||||
const isCurrent = (myTier?.active_tiers ?? []).some((t) => t.tier === plan.tier);
|
||||
const isCurrent = isPlanCurrent(plan, myTier);
|
||||
const isBlockedByOverlap = !isCurrent && overlapsExistingAccess(plan, myTier?.my_grants);
|
||||
return (
|
||||
<td key={plan.plan_id} className="p-4 text-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isCurrent ? "secondary" : "default"}
|
||||
disabled={isCurrent || !plan.is_active}
|
||||
variant={isCurrent || isBlockedByOverlap ? "secondary" : "default"}
|
||||
disabled={isCurrent || isBlockedByOverlap || !plan.is_active}
|
||||
onClick={() => onSelect(plan)}
|
||||
>
|
||||
{isCurrent ? "Current" : !plan.is_active ? "Not Available" : "Select"}
|
||||
{isCurrent ? "Current" : isBlockedByOverlap ? "Included" : !plan.is_active ? "Not Available" : "Select"}
|
||||
</Button>
|
||||
</td>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,11 @@ export const UnitCard = ({ unit, tierMap = {}, onViewDetails }) => {
|
||||
{locked ? <LockIcon className="size-3" /> : <Tag className="size-3" />}
|
||||
{label}
|
||||
</Badge>
|
||||
{locked && (
|
||||
<Badge variant="secondary">
|
||||
<LockIcon className="size-3" /> Locked
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -1,38 +1,35 @@
|
||||
// UnitUpsellModal — shown when a learner clicks a locked standalone Unit.
|
||||
// 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).
|
||||
// Unlocks via any course it's attached to (links to view/buy the course), with
|
||||
// an optional direct "Buy" listing if the unit itself has an active product
|
||||
// (same PayPal flow as Courses).
|
||||
// Shared by UnitsList, UnitDetails, and Dashboard.
|
||||
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LockIcon, BookOpen, ShoppingCart, GraduationCap } from "lucide-react";
|
||||
import { LockIcon, ShoppingCart, GraduationCap, Check } 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";
|
||||
import { resolveTierBadge, cheapestTierSlug } 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 purchasable = unit?.product?.is_active && !unit?.has_purchased;
|
||||
// purchase_eligible is undefined for callers that haven't fetched it yet — treat as eligible (no regression).
|
||||
const awaitingStarterSet = purchasable && unit?.purchase_eligible === false;
|
||||
const canBuy = purchasable && !awaitingStarterSet;
|
||||
|
||||
const slug = cheapestTierSlug([unit?.subscription, ...courses.map((c) => c.subscription)], tierMap);
|
||||
const { rank, label, cls, panel } = resolveTierBadge(slug, tierMap);
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={unit?.title ?? "Unit Details"}
|
||||
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."
|
||||
}
|
||||
description="Upgrade your plan to access this unit."
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
||||
@@ -47,19 +44,7 @@ export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 py-2">
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className="space-y-6 py-2">
|
||||
{awaitingStarterSet && (
|
||||
<div className="flex items-center gap-2.5 p-4 rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40">
|
||||
<GraduationCap className="size-4 text-amber-700 dark:text-amber-400 shrink-0" />
|
||||
@@ -69,38 +54,21 @@ export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{courses.length === 0 && !ownTier ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upgrade your plan to access this content.
|
||||
</p>
|
||||
) : (
|
||||
courses.map((course) => {
|
||||
const { label, cls } = resolveTierBadge(course.subscription, tierMap);
|
||||
return (
|
||||
<div
|
||||
key={course.course_id}
|
||||
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">
|
||||
<BookOpen className="size-4 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{course.title}</p>
|
||||
<Badge className={`${cls} mt-1`}>
|
||||
<LockIcon className="size-3" /> {label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={() => { onOpenChange(false); navigate(`/course/${course.course_id}`); }}
|
||||
>
|
||||
View Course
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
{rank > 0 && (
|
||||
<div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Badge className={cls}>
|
||||
<LockIcon className="size-3" /> {label}
|
||||
</Badge>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm [&_svg]:size-4 mb-4">
|
||||
<li className="flex items-center gap-2"><Check /> Access to {label} content</li>
|
||||
<li className="flex items-center gap-2"><Check /> Certificates & achievements</li>
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upgrade to a <span className="font-medium">{label}</span> plan to unlock this unit.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
|
||||
@@ -286,7 +286,7 @@ const Checkout = () => {
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium flex items-center gap-2">
|
||||
<BookOpen className="size-4" /> Included Courses
|
||||
<BookOpen className="size-4" /> Bundle
|
||||
</p>
|
||||
{plan.courses?.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -684,7 +684,7 @@ const CourseDetails = () => {
|
||||
</div>
|
||||
<h1 className="font-bold xs:text-2xl lg:text-4xl">{course?.title ?? "Course Title"}</h1>
|
||||
<p className="max-w-2xl xs:text-sm lg:text-lg">{course?.description ?? ""}</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-4 text-sm lg:text-base">
|
||||
{course?.duration_seconds > 0 && (
|
||||
<div className="[&_svg]:size-4 flex gap-1.5 items-center">
|
||||
<Timer />
|
||||
@@ -733,11 +733,11 @@ const CourseDetails = () => {
|
||||
|
||||
|
||||
{/* Body */}
|
||||
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-3 xs:-mt-5 lg:-mt-0">
|
||||
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:p-6 xs:-mt-5 lg:-mt-0">
|
||||
<div className="flex flex-col xs:gap-12 lg:gap-12 flex-1 min-w-0">
|
||||
<div className="space-y-4">
|
||||
<div className="font-bold text-2xl">About</div>
|
||||
<div className="max-w-3xl space-y-4 text-muted-foreground lg:text-lg">
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<p>{course?.description ?? ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -748,7 +748,7 @@ const CourseDetails = () => {
|
||||
{course?.objectives?.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="font-bold text-2xl">Learning Outcomes</div>
|
||||
<ul className="max-w-3xl list-disc list-inside space-y-1 lg:text-lg text-muted-foreground">
|
||||
<ul className="max-w-3xl list-disc list-inside space-y-1 ">
|
||||
{course.objectives.map((obj) => (
|
||||
<li key={obj.objective_id}>{obj.text}</li>
|
||||
))}
|
||||
|
||||
@@ -134,14 +134,12 @@ const GroupsTable = ({ groups, onView }) => (
|
||||
<TableHead className="w-10 text-center px-4">#</TableHead>
|
||||
<TableHead className="px-4">Group Name</TableHead>
|
||||
<TableHead className="px-4">Code</TableHead>
|
||||
<TableHead className="px-4">Description</TableHead>
|
||||
<TableHead className="px-4">Task Lists</TableHead>
|
||||
<TableHead className="px-4" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.map((g, i) => {
|
||||
const isDefault = g.group_code === 'NOGRP';
|
||||
const taskListCount = Number(g.task_list_count ?? g.taskLists?.length ?? 0);
|
||||
return (
|
||||
<TableRow key={g.group_id}>
|
||||
@@ -152,12 +150,6 @@ const GroupsTable = ({ groups, onView }) => (
|
||||
<TableCell className="px-4">
|
||||
<Badge variant="outline" className="font-mono text-xs">{g.group_code}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-muted-foreground ">
|
||||
{isDefault
|
||||
? <span className="text-xs italic">Awaiting assignment by admin</span>
|
||||
: (g.description ?? <span className="text-xs text-muted-foreground/50">—</span>)
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{taskListCount}
|
||||
</TableCell>
|
||||
@@ -182,8 +174,7 @@ const GroupsTableSkeleton = () => (
|
||||
<TableHead className="w-10 text-center px-4">#</TableHead>
|
||||
<TableHead className="px-4">Group Name</TableHead>
|
||||
<TableHead className="px-4">Code</TableHead>
|
||||
<TableHead className="px-4 w-full">Description</TableHead>
|
||||
<TableHead className="px-4 text-center whitespace-nowrap">Task Lists</TableHead>
|
||||
<TableHead className="px-4 w-full text-center whitespace-nowrap">Task Lists</TableHead>
|
||||
<TableHead className="px-4" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -193,7 +184,6 @@ const GroupsTableSkeleton = () => (
|
||||
<TableCell className="text-center px-4"><Skeleton className="h-4 w-4 mx-auto" /></TableCell>
|
||||
<TableCell className="px-4"><Skeleton className="h-4 w-32" /></TableCell>
|
||||
<TableCell className="px-4"><Skeleton className="h-5 w-16 rounded-full" /></TableCell>
|
||||
<TableCell className="px-4"><Skeleton className="h-4 w-48" /></TableCell>
|
||||
<TableCell className="px-4"><Skeleton className="h-4 w-8 mx-auto" /></TableCell>
|
||||
<TableCell className="px-4 text-right"><Skeleton className="h-8 w-14 ml-auto" /></TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useParams, useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
House, SendHorizonal, CheckCheck, Check, Hourglass, Clock, Video, ListChecks,
|
||||
House, CheckCheck, Check, Hourglass, Clock, Video, ListChecks,
|
||||
} from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
@@ -90,15 +89,47 @@ const LessonDetails = () => {
|
||||
}, [uuid]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
// Content shorter than the viewport (nothing to scroll) used to flag 100%
|
||||
// on the spot — but right after mount the video/image blocks haven't
|
||||
// finished loading yet, so that snapshot is often taken against a
|
||||
// still-collapsed layout and fires a false "completed" before the real
|
||||
// (taller) content has even rendered. Debounce a settle window and
|
||||
// re-check before trusting it.
|
||||
let settleTimer = null;
|
||||
|
||||
const evaluate = () => {
|
||||
const scrollTop = window.scrollY;
|
||||
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
if (scrollHeight <= 0) { setScrollProgress(100); return; }
|
||||
setScrollProgress(Math.round((scrollTop / scrollHeight) * 100));
|
||||
|
||||
if (scrollHeight <= 0) {
|
||||
if (settleTimer) return;
|
||||
settleTimer = setTimeout(() => {
|
||||
settleTimer = null;
|
||||
const stillShort = document.documentElement.scrollHeight - window.innerHeight <= 0;
|
||||
if (stillShort) setScrollProgress(100);
|
||||
}, 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; }
|
||||
|
||||
// Percentage rounding alone can hit 100 a few pixels short of the real
|
||||
// bottom (e.g. 99.5% rounds up), which was enough to auto-fire
|
||||
// completion before the reader actually reached the end. Cap the
|
||||
// ratio-derived value at 90 and only award the true 100 when the
|
||||
// scroll position has actually reached the bottom.
|
||||
const atBottom = scrollTop >= scrollHeight - 4;
|
||||
setScrollProgress(atBottom ? 100 : Math.min(Math.round((scrollTop / scrollHeight) * 100), 90));
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
evaluate(); // seed a measurement — short content may never fire a scroll event
|
||||
window.addEventListener("scroll", evaluate, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", evaluate);
|
||||
if (settleTimer) clearTimeout(settleTimer);
|
||||
};
|
||||
}, [uuid]);
|
||||
|
||||
// ── Mark lesson completed when user scrolls to the bottom ─────────────
|
||||
// Only fires in Task mode — outside of it, this standalone lesson page is a
|
||||
@@ -129,6 +160,14 @@ const LessonDetails = () => {
|
||||
await markComplete(lesson.uuid, unit?.uuid ?? null);
|
||||
}, [lesson, unit, markComplete]);
|
||||
|
||||
// ── Course-attached lessons hand off straight to the Unit reader — no
|
||||
// separate "Start Lesson" landing step in between.
|
||||
useEffect(() => {
|
||||
if (!lessonLoading && lesson && hasCourse && !contentNotReady) {
|
||||
navigate(`/units/${unit.uuid}/read`, { replace: true, state: { lessonId: lesson.lesson_id } });
|
||||
}
|
||||
}, [lessonLoading, lesson, hasCourse, contentNotReady, unit, navigate]);
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
||||
{ label: "Lessons", to: `/lessons` },
|
||||
@@ -149,7 +188,7 @@ const LessonDetails = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (lessonLoading || !lesson) {
|
||||
if (lessonLoading || !lesson || (hasCourse && !contentNotReady)) {
|
||||
return (
|
||||
<div className="my-17 p-8 lg:container lg:mx-auto space-y-4">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
@@ -159,11 +198,6 @@ const LessonDetails = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const handleStart = () => {
|
||||
if (!hasUnit) return;
|
||||
navigate(`/units/${unit.uuid}/read`, { state: { lessonId: lesson.lesson_id } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col">
|
||||
<PageMeta title={`${lesson.title} - STARR`} description={lesson.description} />
|
||||
@@ -258,15 +292,6 @@ const LessonDetails = () => {
|
||||
<Hourglass className="size-4 shrink-0" />
|
||||
This lesson is currently being prepared. Please check back later.
|
||||
</div>
|
||||
) : hasCourse ? (
|
||||
<div className="w-fit">
|
||||
<Button className="w-fit bg-blue-500" onClick={handleStart}>
|
||||
{hasCompleted
|
||||
? <><CheckCheck /> Start Again</>
|
||||
: <><SendHorizonal /> Start Lesson</>
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<LessonBlock
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
BookOpen, Clock, Check,
|
||||
Tag, RotateCcw, LaptopMinimal, Table as TableIcon,
|
||||
BookOpen, Clock, Check, Lock,
|
||||
Tag, RotateCcw, Plus,
|
||||
} from "lucide-react";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
@@ -26,7 +26,7 @@ import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
|
||||
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
import PlanComparisonTable from "../components/PlanComparisonTable";
|
||||
import { getBundleContent, overlapsExistingAccess, isPlanCurrent } from "@/utils/planBundle.util";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -56,6 +56,7 @@ function formatCourseDuration(seconds = 0) {
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
|
||||
// ─── Skeleton ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const PlanSkeleton = () => (
|
||||
@@ -86,20 +87,30 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
|
||||
const { fmtCurrency } = useDateFormat();
|
||||
const [coursesOpen, setCoursesOpen] = useState(false);
|
||||
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
|
||||
const { label: tierLabel, cls: badgeCls, rank } = resolveTierBadge(plan.tier, tierMap);
|
||||
const { label: tierLabel, cls: badgeCls } = resolveTierBadge(plan.tier, tierMap);
|
||||
const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
|
||||
const ring = rank > 0 ? "ring-2 ring-primary/30" : "";
|
||||
// A user can hold more than one active tier concurrently (e.g. premium +
|
||||
// exclusive) — check membership in the full active set, not just the best one.
|
||||
const isCurrent = (myTier?.active_tiers ?? []).some((t) => t.tier === plan.tier);
|
||||
// "Current" means the user actually purchased THIS exact plan (matched by
|
||||
// plan_id) and it's still active — NOT just any plan sharing the same tier
|
||||
// slug. Two different plans can be the same tier (e.g. two Premium
|
||||
// bundles) and stay independently active (Tier Plans v2).
|
||||
const isCurrent = isPlanCurrent(plan, myTier);
|
||||
// Blocked-by-overlap ("already covered") — the plan isn't itself owned,
|
||||
// but at least one of its bundle items is already granted by something
|
||||
// else the user holds. Card stays visible but disabled (Scenario 3).
|
||||
const isBlockedByOverlap = !isCurrent && overlapsExistingAccess(plan, myTier?.my_grants);
|
||||
const duration = formatDuration(plan.duration_days, plan.duration_unit);
|
||||
const features = plan.features ?? [];
|
||||
const previewCourses = plan.courses?.slice(0, PREVIEW_COURSE_LIMIT) ?? [];
|
||||
const extraCount = (plan.courses?.length ?? 0) - PREVIEW_COURSE_LIMIT;
|
||||
const bundle = getBundleContent(plan);
|
||||
const previewItems = bundle?.items?.slice(0, PREVIEW_COURSE_LIMIT) ?? [];
|
||||
const extraCount = (bundle?.items?.length ?? 0) - PREVIEW_COURSE_LIMIT;
|
||||
const hasFooterAction = (isCurrent && refundSecsLeft > 0) || !plan.is_active || isBlockedByOverlap || (isCurrent && plan.tier !== "free");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={`relative flex flex-col ${ring}`}>
|
||||
<Card
|
||||
className={`relative flex flex-col cursor-pointer transition-shadow hover:shadow-md ${isBlockedByOverlap ? "opacity-60" : ""}`}
|
||||
onClick={() => onView(plan)}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{plan.label}</CardTitle>
|
||||
@@ -107,12 +118,21 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
|
||||
{isCurrent && (
|
||||
<Badge className="bg-green-500 text-white">Current Plan</Badge>
|
||||
)}
|
||||
{isBlockedByOverlap && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Lock className="size-3" />
|
||||
Already Included in Your Plan
|
||||
</Badge>
|
||||
)}
|
||||
<Badge className={badgeCls}>
|
||||
<Icon />
|
||||
{tierLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{plan.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{plan.description}</p>
|
||||
)}
|
||||
<CardDescription>
|
||||
<span className="text-3xl font-bold text-foreground">
|
||||
{fmtCurrency(plan.price, plan.currency)}
|
||||
@@ -126,36 +146,41 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
|
||||
{features.length > 0 && (
|
||||
<ul className="space-y-1.5">
|
||||
{features.slice(0, 4).map((f, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm">
|
||||
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
|
||||
<span>{f.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{plan.courses?.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
|
||||
<BookOpen className="size-3.5" />
|
||||
Course{plan.course_count !== 1 ? "s" : ""} Included
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
What's included
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{previewCourses.map((course) => (
|
||||
<li key={course.course_id} className="flex items-start gap-2 text-sm">
|
||||
{features.slice(0, 4).map((f, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm">
|
||||
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
|
||||
<span>{f.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bundle ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
|
||||
<bundle.icon className="size-3.5" />
|
||||
Bundle
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{previewItems.map((item) => (
|
||||
<li key={item[bundle.idKey]} className="flex items-start gap-2 text-sm">
|
||||
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="line-clamp-1">{course.title}</span>
|
||||
<span className="line-clamp-1">{item.title}</span>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{course.level && (
|
||||
<span className="text-xs text-muted-foreground capitalize">{course.level}</span>
|
||||
{item.level && (
|
||||
<span className="text-xs text-muted-foreground capitalize">{item.level}</span>
|
||||
)}
|
||||
{formatCourseDuration(course.duration_seconds) && (
|
||||
{formatCourseDuration(item.duration_seconds) && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{formatCourseDuration(course.duration_seconds)}
|
||||
{formatCourseDuration(item.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -171,7 +196,7 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
|
||||
variant="secondary"
|
||||
|
||||
>
|
||||
+{extraCount} more course{extraCount !== 1 ? "s" : ""}
|
||||
+{extraCount} more {bundle.noun.toLowerCase()}{extraCount !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -182,53 +207,47 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
<Separator />
|
||||
|
||||
<CardFooter className="flex gap-2 pt-4">
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="outline"
|
||||
onClick={() => onView(plan)}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
{isCurrent && refundSecsLeft > 0 ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="destructive"
|
||||
onClick={() => onRefund(plan)}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
Refund ({formatCountdown(refundSecsLeft)})
|
||||
</Button>
|
||||
) : !plan.is_active ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="secondary"
|
||||
onClick={() => setNotAvailableOpen(true)}
|
||||
>
|
||||
Not Available
|
||||
</Button>
|
||||
) : !isCurrent ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => onSelect(plan)}
|
||||
>
|
||||
{plan.tier === "free" ? "Current" : `Get ${tierLabel}`}
|
||||
</Button>
|
||||
) : plan.tier !== "free" ? (
|
||||
// Already holds this tier (from another plan, past the refund
|
||||
// window) — repurchasing extends the existing grant's expiry
|
||||
// rather than being blocked, so still offer a way to buy.
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="outline"
|
||||
onClick={() => onSelect(plan)}
|
||||
>
|
||||
Extend {tierLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardFooter>
|
||||
{hasFooterAction && (
|
||||
<>
|
||||
<Separator />
|
||||
<CardFooter className="flex gap-2 pt-4">
|
||||
{isCurrent && refundSecsLeft > 0 ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="destructive"
|
||||
onClick={(e) => { e.stopPropagation(); onRefund(plan); }}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
Refund ({formatCountdown(refundSecsLeft)})
|
||||
</Button>
|
||||
) : !plan.is_active ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="secondary"
|
||||
onClick={(e) => { e.stopPropagation(); setNotAvailableOpen(true); }}
|
||||
>
|
||||
Not Available
|
||||
</Button>
|
||||
) : isBlockedByOverlap ? (
|
||||
<Button className="flex-1" variant="secondary" disabled>
|
||||
<Lock className="size-4" />
|
||||
Already Included
|
||||
</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={(e) => { e.stopPropagation(); onSelect(plan); }}
|
||||
>
|
||||
Extend {tierLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardFooter>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── Not Available Dialog ──────────────────────────────────────── */}
|
||||
@@ -294,36 +313,36 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Courses horizontal scroll */}
|
||||
{plan.courses?.length > 0 && (
|
||||
{/* Bundle items horizontal scroll */}
|
||||
{bundle && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
|
||||
<BookOpen className="size-3.5" />
|
||||
{plan.courses.length} Course{plan.courses.length !== 1 ? "s" : ""} Included
|
||||
<bundle.icon className="size-3.5" />
|
||||
Bundle
|
||||
</p>
|
||||
<ScrollArea className="w-md whitespace-nowrap">
|
||||
<div className="flex gap-3 pb-3 pt-1 w-max">
|
||||
{plan.courses.map((course) => (
|
||||
{bundle.items.map((item) => (
|
||||
<div
|
||||
key={course.course_id}
|
||||
key={item[bundle.idKey]}
|
||||
className="w-40 shrink-0 rounded-xl border bg-muted/50 p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-start gap-1.5">
|
||||
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
|
||||
<p className="text-xs font-medium leading-snug line-clamp-3">
|
||||
{course.title}
|
||||
{item.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{course.level && (
|
||||
{item.level && (
|
||||
<span className="text-[11px] text-muted-foreground capitalize">
|
||||
{course.level}
|
||||
{item.level}
|
||||
</span>
|
||||
)}
|
||||
{formatCourseDuration(course.duration_seconds) && (
|
||||
{formatCourseDuration(item.duration_seconds) && (
|
||||
<span className="text-[11px] text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{formatCourseDuration(course.duration_seconds)}
|
||||
{formatCourseDuration(item.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -335,7 +354,7 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCurrent && plan.tier !== "free" && (
|
||||
{!isCurrent && !isBlockedByOverlap && plan.tier !== "free" && (
|
||||
<DialogFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
@@ -359,11 +378,11 @@ export default function PlanList() {
|
||||
const { fmtDate, fmtCurrency } = useDateFormat();
|
||||
const { adLists, listLoading: adLoading, getActiveAdvertisementList, handleAdCtaClick } = useClientAdvertisements();
|
||||
|
||||
const [view, setView] = useState("grid");
|
||||
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
|
||||
const [refundLoading, setRefundLoading] = useState(false);
|
||||
// Keyed by tier slug — a user can hold more than one active tier concurrently,
|
||||
// and each one has its own independent refund window based on its own starts_at.
|
||||
// Keyed by plan_id, not tier slug — two different plans can share a tier
|
||||
// (Tier Plans v2) and each has its own independent refund window based on
|
||||
// its own starts_at.
|
||||
const [refundSecsLeftByTier, setRefundSecsLeftByTier] = useState({});
|
||||
const refundTimerRef = useRef(null);
|
||||
|
||||
@@ -385,9 +404,9 @@ export default function PlanList() {
|
||||
const compute = () => {
|
||||
const map = {};
|
||||
for (const t of activeTiers) {
|
||||
if (!t.starts_at) continue;
|
||||
if (!t.starts_at || t.plan_id == null) continue;
|
||||
const elapsed = Math.floor((Date.now() - new Date(t.starts_at).getTime()) / 1000);
|
||||
map[t.tier] = Math.max(0, REFUND_WINDOW_SECS - elapsed);
|
||||
map[t.plan_id] = Math.max(0, REFUND_WINDOW_SECS - elapsed);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
@@ -406,10 +425,11 @@ export default function PlanList() {
|
||||
const handleRefundClick = (plan) => setRefundPlan(plan);
|
||||
|
||||
// The plan being refunded may not be the user's "best" tier (myTier), since
|
||||
// more than one can be active at once — resolve its own record for its own
|
||||
// more than one can be active at once — resolve its own record by plan_id
|
||||
// (not tier slug, since two plans can share a tier) for its own
|
||||
// expires_at/refund window instead of assuming it matches myTier.
|
||||
const refundPlanTier = (myTier?.active_tiers ?? []).find((t) => t.tier === refundPlan?.tier) ?? null;
|
||||
const refundPlanSecsLeft = refundSecsLeftByTier[refundPlan?.tier] ?? 0;
|
||||
const refundPlanTier = (myTier?.active_tiers ?? []).find((t) => t.plan_id != null && String(t.plan_id) === String(refundPlan?.plan_id)) ?? null;
|
||||
const refundPlanSecsLeft = refundSecsLeftByTier[refundPlan?.plan_id] ?? 0;
|
||||
const isOnlyActiveTier = (myTier?.active_tiers ?? []).length <= 1;
|
||||
|
||||
const handleConfirmRefund = async () => {
|
||||
@@ -432,9 +452,9 @@ export default function PlanList() {
|
||||
return (
|
||||
<div className="mt-17">
|
||||
<PageMeta title="Plans - STARR" description="Browse available subscription plans." />
|
||||
<div className="bg-muted min-h-screen">
|
||||
<div className="relative overflow-hidden bg-gradient-to-b from-primary/5 via-background to-background min-h-screen">
|
||||
<div className="lg:container lg:mx-auto space-y-8 p-6">
|
||||
<div className="xs:pt-2 lg:pt-8 space-y-4">
|
||||
<div className="relative xs:pt-2 lg:pt-8 space-y-4">
|
||||
{/* Advertisement Banner */}
|
||||
{adLoading["tier_plans.banner"] ? (
|
||||
<BannerSkeleton />
|
||||
@@ -443,26 +463,20 @@ export default function PlanList() {
|
||||
)}
|
||||
|
||||
{/* Section Header */}
|
||||
<div className="flex flex-col items-center gap-4 mt-6">
|
||||
<div className="relative flex flex-col items-center gap-4 mt-6">
|
||||
<Plus aria-hidden className="hidden md:block absolute top-0 left-4 size-5 text-muted-foreground/15 pointer-events-none" />
|
||||
<Plus aria-hidden className="hidden md:block absolute top-2 right-8 size-4 text-muted-foreground/15 pointer-events-none" />
|
||||
<Plus aria-hidden className="hidden md:block absolute bottom-0 left-16 size-4 text-muted-foreground/15 pointer-events-none" />
|
||||
<Plus aria-hidden className="hidden md:block absolute bottom-2 right-20 size-5 text-muted-foreground/15 pointer-events-none" />
|
||||
<div className="text-center my-8">
|
||||
<h2 className="text-3xl font-bold">Available Plans</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Choose a subscription that matches your goals.
|
||||
</p>
|
||||
</div>
|
||||
{!plansLoading && plans.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant={view === "grid" ? "default" : "outline"} onClick={() => setView("grid")}>
|
||||
<LaptopMinimal /> Cards
|
||||
</Button>
|
||||
<Button size="sm" variant={view === "table" ? "default" : "outline"} onClick={() => setView("table")}>
|
||||
<TableIcon /> Compare
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plan Cards / Comparison Table */}
|
||||
{/* Plan Cards */}
|
||||
{plansLoading || tierLoading ? (
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => <PlanSkeleton key={i} />)}
|
||||
@@ -472,14 +486,6 @@ export default function PlanList() {
|
||||
<BookOpen className="size-10 mb-3" />
|
||||
<p className="text-sm">No plans available at the moment.</p>
|
||||
</div>
|
||||
) : view === "table" ? (
|
||||
<PlanComparisonTable
|
||||
plans={plans}
|
||||
myTier={myTier}
|
||||
tierMap={tierMap}
|
||||
fmtCurrency={fmtCurrency}
|
||||
onSelect={handleSelectPlan}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{plans.map((plan) => (
|
||||
@@ -491,7 +497,7 @@ export default function PlanList() {
|
||||
onSelect={handleSelectPlan}
|
||||
onView={handleViewPlan}
|
||||
onRefund={handleRefundClick}
|
||||
refundSecsLeft={refundSecsLeftByTier[plan.tier] ?? 0}
|
||||
refundSecsLeft={refundSecsLeftByTier[plan.plan_id] ?? 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useParams, useNavigate, useLocation, useBlocker } from "react-router-dom";
|
||||
import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle, Zap, ListChecks, ChevronsLeft, ChevronsRight, Hourglass } from "lucide-react";
|
||||
import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, CheckCheck, Circle, Zap, ListChecks, ChevronsLeft, ChevronsRight, Hourglass, HelpCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Accordion,
|
||||
@@ -24,6 +24,7 @@ import { useProfile } from "@/contexts/ProfileProvider";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
import api from "@/utils/api.util";
|
||||
import { TYPE_DEFS } from "@/modules/admin/components/courses/completionRequirementTypes";
|
||||
|
||||
// ─── Quiz Prerequisite Gate ────────────────────────────────────────────────
|
||||
|
||||
@@ -333,44 +334,53 @@ const UnitList = () => {
|
||||
const [selectedUnitId, setSelectedUnitId] = useState(null);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
const resetCompletion = () => setSelectedCompletion(false);
|
||||
|
||||
// ── Course-readiness notices — shown once per course visit, informational only ──
|
||||
// Gated behind the "Course notices" toggle in Account Settings. Sequenced so at
|
||||
// most one is open at a time: missing-quiz notice (unit-level) takes priority,
|
||||
// then no-assessment (course-level) once that one is dismissed.
|
||||
const [missingQuizDialogOpen, setMissingQuizDialogOpen] = useState(false);
|
||||
const [noAssessmentDialogOpen, setNoAssessmentDialogOpen] = useState(false);
|
||||
const missingQuizNoticeShown = useRef(false);
|
||||
const noAssessmentNoticeShown = useRef(false);
|
||||
// ── Course-readiness notice — shown once per course visit, informational only ──
|
||||
// Gated behind the "Course notices" toggle in Account Settings. Missing-quiz and
|
||||
// no-assessment are merged into a single dialog instead of two separate popups.
|
||||
const [courseNoticeDialogOpen, setCourseNoticeDialogOpen] = useState(false);
|
||||
const [courseNoticeMissingQuiz, setCourseNoticeMissingQuiz] = useState(false);
|
||||
const [courseNoticeNoAssessment, setCourseNoticeNoAssessment] = useState(false);
|
||||
const courseNoticeShown = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
getProfile();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
missingQuizNoticeShown.current = false;
|
||||
noAssessmentNoticeShown.current = false;
|
||||
courseNoticeShown.current = false;
|
||||
}, [courseId]);
|
||||
|
||||
useEffect(() => {
|
||||
const noticesEnabled =
|
||||
course && !courseBlocked && course.duration_seconds &&
|
||||
profile && (profile.personal_info?.show_course_notices ?? true);
|
||||
if (!noticesEnabled) return;
|
||||
if (!noticesEnabled || courseNoticeShown.current) return;
|
||||
|
||||
if (!missingQuizNoticeShown.current && (course.units ?? []).some((u) => !u.quiz)) {
|
||||
missingQuizNoticeShown.current = true;
|
||||
setMissingQuizDialogOpen(true);
|
||||
return; // let the assessment notice wait until this one is dismissed
|
||||
}
|
||||
if (missingQuizDialogOpen) return;
|
||||
if (!noAssessmentNoticeShown.current && !course.assessment) {
|
||||
noAssessmentNoticeShown.current = true;
|
||||
setNoAssessmentDialogOpen(true);
|
||||
}
|
||||
}, [course, courseBlocked, profile, missingQuizDialogOpen]);
|
||||
const missingQuiz = (course.units ?? []).some((u) => !u.quiz);
|
||||
const noAssessment = !course.assessment;
|
||||
if (!missingQuiz && !noAssessment) return;
|
||||
|
||||
courseNoticeShown.current = true;
|
||||
setCourseNoticeMissingQuiz(missingQuiz);
|
||||
setCourseNoticeNoAssessment(noAssessment);
|
||||
setCourseNoticeDialogOpen(true);
|
||||
}, [course, courseBlocked, profile]);
|
||||
|
||||
const courseNoticeTitle = courseNoticeMissingQuiz && courseNoticeNoAssessment
|
||||
? "Quizzes & Assessment Coming Soon"
|
||||
: courseNoticeMissingQuiz
|
||||
? "Quizzes Coming Soon"
|
||||
: "Assessment Coming Soon";
|
||||
|
||||
const courseNoticeDescription = courseNoticeMissingQuiz && courseNoticeNoAssessment
|
||||
? "Some units in this course don't have a quiz yet, and this course does not have an assessment built yet — we are currently working on both. You may proceed with your studies. Enjoy!"
|
||||
: courseNoticeMissingQuiz
|
||||
? "Some units in this course don't have a quiz yet — we are currently working on it. You may proceed with your studies. Enjoy!"
|
||||
: "This course does not have an assessment built yet — we are currently working on it. You may proceed with your studies. Enjoy!";
|
||||
|
||||
// ── Session guard — block navigation while a quiz/assessment is in progress ─
|
||||
const quizActiveRef = useRef(false); // sync check inside handlers
|
||||
@@ -454,15 +464,48 @@ const UnitList = () => {
|
||||
}, [selectedLessonId, selectedQuizId, selectedAssessment, selectedCompletion]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
// Content shorter than the viewport (nothing to scroll) used to flag 100%
|
||||
// on the spot — but right after switching lessons the video/image blocks
|
||||
// haven't finished loading yet, so that snapshot is often taken against a
|
||||
// still-collapsed layout and fires a false "completed" before the real
|
||||
// (taller) content has even rendered. Debounce a settle window and
|
||||
// re-check before trusting it.
|
||||
let settleTimer = null;
|
||||
|
||||
const evaluate = () => {
|
||||
const scrollTop = window.scrollY;
|
||||
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
if (scrollHeight <= 0) { setScrollProgress(100); return; }
|
||||
setScrollProgress(Math.round((scrollTop / scrollHeight) * 100));
|
||||
|
||||
if (scrollHeight <= 0) {
|
||||
if (settleTimer) return;
|
||||
settleTimer = setTimeout(() => {
|
||||
settleTimer = null;
|
||||
const stillShort = document.documentElement.scrollHeight - window.innerHeight <= 0;
|
||||
if (stillShort) setScrollProgress(100);
|
||||
}, 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; }
|
||||
|
||||
// Percentage rounding alone can hit 100 a few pixels short of the real
|
||||
// bottom (e.g. 99.5% rounds up), which was enough to auto-fire
|
||||
// completion before the reader actually reached the end — while a "How
|
||||
// to complete" explainer sits right there telling the learner it isn't
|
||||
// done yet. Cap the ratio-derived value at 90 and only award the true
|
||||
// 100 when the scroll position has actually reached the bottom.
|
||||
const atBottom = scrollTop >= scrollHeight - 4;
|
||||
setScrollProgress(atBottom ? 100 : Math.min(Math.round((scrollTop / scrollHeight) * 100), 90));
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
evaluate(); // seed a measurement — short content may never fire a scroll event
|
||||
window.addEventListener("scroll", evaluate, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", evaluate);
|
||||
if (settleTimer) clearTimeout(settleTimer);
|
||||
};
|
||||
}, [selectedLessonId, selectedQuizId, selectedAssessment, selectedCompletion]);
|
||||
|
||||
// ── Lesson completion-trigger dispatch ─────────────────────────────────
|
||||
// read_all_content (or unconfigured/default) → scroll-to-bottom, below.
|
||||
@@ -622,6 +665,14 @@ const UnitList = () => {
|
||||
const currentUnit = course?.units?.find((u) => u.unit_id === selectedUnitId);
|
||||
const currentLesson = lesson;
|
||||
const currentLessonStub = currentUnit?.lessons?.find((l) => l.lesson_id === selectedLessonId) ?? null;
|
||||
|
||||
// ── "How to complete" help text — this lesson's / this unit's / this course's
|
||||
// configured completion requirement (or the default implicit rule).
|
||||
const unitCompletionType = currentUnit?.completion?.type ?? 'read_all_content';
|
||||
const courseCompletionType = course?.completion?.type ?? 'read_all_content';
|
||||
const lessonRequirementText = TYPE_DEFS[selectedLessonCompletionType]?.describe('lesson');
|
||||
const unitRequirementText = TYPE_DEFS[unitCompletionType]?.describe('unit');
|
||||
const courseRequirementText = TYPE_DEFS[courseCompletionType]?.describe('course');
|
||||
const pageTitle = (() => {
|
||||
if (!course) return undefined;
|
||||
if (selectedCompletion) return `${course.title} – Completed - STARR`;
|
||||
@@ -860,21 +911,65 @@ const UnitList = () => {
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
|
||||
{/* ── Missing-quiz notice — informational, shown once per course visit ── */}
|
||||
{/* ── Course-readiness notice — informational, shown once per course visit ── */}
|
||||
<InfoDialog
|
||||
open={missingQuizDialogOpen}
|
||||
onOpenChange={setMissingQuizDialogOpen}
|
||||
title="Quizzes Coming Soon"
|
||||
description="Some units in this course don't have a quiz yet — we are currently working on it. You may proceed with your studies. Enjoy!"
|
||||
open={courseNoticeDialogOpen}
|
||||
onOpenChange={setCourseNoticeDialogOpen}
|
||||
title={courseNoticeTitle}
|
||||
description={courseNoticeDescription}
|
||||
/>
|
||||
|
||||
{/* ── No-assessment notice — informational, shown once per course visit ── */}
|
||||
<InfoDialog
|
||||
open={noAssessmentDialogOpen}
|
||||
onOpenChange={setNoAssessmentDialogOpen}
|
||||
title="Assessment Coming Soon"
|
||||
description="This course does not have an assessment built yet — we are currently working on it. You may proceed with your studies. Enjoy!"
|
||||
/>
|
||||
{/* ── "How to complete" help dialog ── */}
|
||||
<ResponsiveModal
|
||||
open={helpOpen}
|
||||
onOpenChange={setHelpOpen}
|
||||
title="How to complete this content"
|
||||
description=""
|
||||
footer={<Button onClick={() => setHelpOpen(false)}>Got it</Button>}
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-start gap-3 rounded-lg border p-3">
|
||||
{(() => {
|
||||
const Icon = TYPE_DEFS[selectedLessonCompletionType]?.icon;
|
||||
return Icon ? <Icon className="size-4 mt-0.5 text-muted-foreground shrink-0" /> : null;
|
||||
})()}
|
||||
<div>
|
||||
<p className="font-medium">This lesson</p>
|
||||
<p className="text-muted-foreground">{lessonRequirementText}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 rounded-lg border p-3">
|
||||
{(() => {
|
||||
const Icon = TYPE_DEFS[unitCompletionType]?.icon;
|
||||
return Icon ? <Icon className="size-4 mt-0.5 text-muted-foreground shrink-0" /> : null;
|
||||
})()}
|
||||
<div>
|
||||
<p className="font-medium">This unit</p>
|
||||
<p className="text-muted-foreground">{unitRequirementText}</p>
|
||||
{currentUnit?.quiz && unitCompletionType !== 'pass_quiz' && (
|
||||
<p className="text-muted-foreground mt-1">
|
||||
This unit also has a quiz — passing it is tracked separately from the unit's completion status.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 rounded-lg border p-3">
|
||||
{(() => {
|
||||
const Icon = TYPE_DEFS[courseCompletionType]?.icon;
|
||||
return Icon ? <Icon className="size-4 mt-0.5 text-muted-foreground shrink-0" /> : null;
|
||||
})()}
|
||||
<div>
|
||||
<p className="font-medium">This course</p>
|
||||
<p className="text-muted-foreground">{courseRequirementText}</p>
|
||||
{course?.assessment && courseCompletionType !== 'pass_quiz' && (
|
||||
<p className="text-muted-foreground mt-1">
|
||||
This course also has a final assessment — passing it is tracked separately from the course's completion status.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
|
||||
{/* ── Task-mode banner ─────────────────────────────────────────── */}
|
||||
{taskCtx?.has_task && (
|
||||
@@ -891,24 +986,6 @@ const UnitList = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Up next floating button (lesson only — quizzes/assessments have their own bottom controls) ── */}
|
||||
{scrollProgress >= 100 && nextContent && !selectedQuizId && !selectedAssessment && (
|
||||
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div
|
||||
onClick={handleNextContentClick}
|
||||
className="flex items-center gap-3 bg-card border rounded-xl dark:hover:border-blue-500 dark:hover:shadow-blue-500 px-4 py-3 shadow-xl hover:bg-muted transition-colors text-sm font-medium cursor-pointer select-none"
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-sm text-muted-foreground font-normal">Up next</span>
|
||||
{nextContent.type === "lesson" ? nextContent.lesson.title
|
||||
: nextContent.type === "quiz" ? (nextContent.quiz.title || "Quiz")
|
||||
: (nextContent.assessment?.title || "Course Assessment")}
|
||||
</div>
|
||||
<ArrowRight className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Up next after passing a quiz ── */}
|
||||
{selectedQuizId && quiz?.has_passed && !quizSessionActive && nextContent && (
|
||||
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
@@ -940,6 +1017,25 @@ const UnitList = () => {
|
||||
<AppBreadcrumb items={breadcrumbItemsMobile} />
|
||||
</div>
|
||||
|
||||
{!selectedQuizId && !selectedAssessment && !selectedCompletion && lesson && (
|
||||
isProgressCompleted(lesson.uuid) ? (
|
||||
<div className="hidden lg:flex items-center gap-1.5 text-sm font-medium text-emerald-600 dark:text-emerald-400 shrink-0">
|
||||
<CheckCheck className="size-4" />
|
||||
Done
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden lg:flex items-center gap-1.5 text-muted-foreground shrink-0"
|
||||
onClick={() => setHelpOpen(true)}
|
||||
>
|
||||
<HelpCircle className="size-4" />
|
||||
How to complete
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetTrigger asChild>
|
||||
@@ -1033,7 +1129,7 @@ const UnitList = () => {
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<div className={`${taskCtx?.has_task ? "xs:mt-42 lg:mt-36" : "mt-32"} ${showSidebarContent ? "lg:ml-80" : "lg:ml-14"} p-4 md:p-6 min-h-screen`}>
|
||||
<div className="relative w-full h-full">
|
||||
<div className={`relative w-full h-full ${showSidebarContent ? "" : "mx-auto max-w-xl"}`}>
|
||||
{selectedCompletion ? (
|
||||
<CourseCompleteBlock course={course} />
|
||||
) : selectedAssessment ? (
|
||||
@@ -1101,6 +1197,20 @@ const UnitList = () => {
|
||||
onMarkComplete={handleMarkComplete}
|
||||
/>
|
||||
)}
|
||||
{!lessonLoading && lesson && scrollProgress >= 100 && nextContent && (
|
||||
<div className="max-w-2xl mx-auto mt-5 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div
|
||||
onClick={handleNextContentClick}
|
||||
className="flex items-center gap-3 bg-card border rounded-xl px-4 py-3 shadow-sm hover:bg-muted transition-colors text-sm font-medium cursor-pointer select-none"
|
||||
>
|
||||
<div className="flex flex-col items-start flex-1">
|
||||
<span className="text-xs text-muted-foreground font-normal">Up next</span>
|
||||
<span>{nextLabel}</span>
|
||||
</div>
|
||||
<ArrowRight className="size-4 text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useParams, useNavigate, useLocation, useBlocker } from "react-router-dom";
|
||||
import { House, TableOfContents, ArrowRight, ClipboardList, Lock, CheckCircle2, Circle, Zap, ChevronsLeft, ChevronsRight, Hourglass, ListChecks } from "lucide-react";
|
||||
import { House, TableOfContents, ArrowRight, ClipboardList, Lock, CheckCircle2, CheckCheck, Circle, Zap, ChevronsLeft, ChevronsRight, Hourglass, ListChecks, HelpCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
@@ -14,6 +14,7 @@ import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
import api from "@/utils/api.util";
|
||||
import { TYPE_DEFS } from "@/modules/admin/components/courses/completionRequirementTypes";
|
||||
|
||||
// ─── Sidebar (single unit — flat lessons + quiz, no unit-accordion nesting) ──
|
||||
|
||||
@@ -107,6 +108,7 @@ const UnitReader = () => {
|
||||
const [selectedQuizId, setSelectedQuizId] = useState(null);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
// ── Session guard — block navigation while a quiz is in progress ─────────
|
||||
const quizActiveRef = useRef(false);
|
||||
@@ -151,15 +153,48 @@ const UnitReader = () => {
|
||||
}, [selectedLessonId, selectedQuizId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
// Content shorter than the viewport (nothing to scroll) used to flag 100%
|
||||
// on the spot — but right after switching lessons the video/image blocks
|
||||
// haven't finished loading yet, so that snapshot is often taken against a
|
||||
// still-collapsed layout and fires a false "completed" before the real
|
||||
// (taller) content has even rendered. Debounce a settle window and
|
||||
// re-check before trusting it.
|
||||
let settleTimer = null;
|
||||
|
||||
const evaluate = () => {
|
||||
const scrollTop = window.scrollY;
|
||||
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
if (scrollHeight <= 0) { setScrollProgress(100); return; }
|
||||
setScrollProgress(Math.round((scrollTop / scrollHeight) * 100));
|
||||
|
||||
if (scrollHeight <= 0) {
|
||||
if (settleTimer) return;
|
||||
settleTimer = setTimeout(() => {
|
||||
settleTimer = null;
|
||||
const stillShort = document.documentElement.scrollHeight - window.innerHeight <= 0;
|
||||
if (stillShort) setScrollProgress(100);
|
||||
}, 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; }
|
||||
|
||||
// Percentage rounding alone can hit 100 a few pixels short of the real
|
||||
// bottom (e.g. 99.5% rounds up), which was enough to auto-fire
|
||||
// completion before the reader actually reached the end — while a "How
|
||||
// to complete" explainer sits right there telling the learner it isn't
|
||||
// done yet. Cap the ratio-derived value at 90 and only award the true
|
||||
// 100 when the scroll position has actually reached the bottom.
|
||||
const atBottom = scrollTop >= scrollHeight - 4;
|
||||
setScrollProgress(atBottom ? 100 : Math.min(Math.round((scrollTop / scrollHeight) * 100), 90));
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
evaluate(); // seed a measurement — short content may never fire a scroll event
|
||||
window.addEventListener("scroll", evaluate, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", evaluate);
|
||||
if (settleTimer) clearTimeout(settleTimer);
|
||||
};
|
||||
}, [selectedLessonId, selectedQuizId]);
|
||||
|
||||
// ── Lesson completion-trigger dispatch (mirrors UnitList.jsx) ──────────
|
||||
// read_all_content (or unconfigured/default) → scroll-to-bottom, below.
|
||||
@@ -167,6 +202,12 @@ const UnitReader = () => {
|
||||
// trigger must NOT also fire completion for those, so it's gated here.
|
||||
const lessonCompletionType = lesson?.completion?.type ?? 'read_all_content';
|
||||
|
||||
// ── "How to complete" help text — explains this lesson's and this unit's
|
||||
// configured completion requirement (or the default implicit rule).
|
||||
const unitCompletionType = unitDetail?.completion?.type ?? 'read_all_content';
|
||||
const lessonRequirementText = TYPE_DEFS[lessonCompletionType]?.describe('lesson');
|
||||
const unitRequirementText = TYPE_DEFS[unitCompletionType]?.describe('unit');
|
||||
|
||||
// ── Mark lesson completed when user scrolls to the bottom ─────────────
|
||||
useEffect(() => {
|
||||
if (lessonCompletionType !== 'read_all_content') return;
|
||||
@@ -401,6 +442,43 @@ const UnitReader = () => {
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
|
||||
{/* ── "How to complete" help dialog ── */}
|
||||
<ResponsiveModal
|
||||
open={helpOpen}
|
||||
onOpenChange={setHelpOpen}
|
||||
title="How to complete this content"
|
||||
description=""
|
||||
footer={<Button onClick={() => setHelpOpen(false)}>Got it</Button>}
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-start gap-3 rounded-lg border p-3">
|
||||
{(() => {
|
||||
const Icon = TYPE_DEFS[lessonCompletionType]?.icon;
|
||||
return Icon ? <Icon className="size-4 mt-0.5 text-muted-foreground shrink-0" /> : null;
|
||||
})()}
|
||||
<div>
|
||||
<p className="font-medium">This lesson</p>
|
||||
<p className="text-muted-foreground">{lessonRequirementText}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 rounded-lg border p-3">
|
||||
{(() => {
|
||||
const Icon = TYPE_DEFS[unitCompletionType]?.icon;
|
||||
return Icon ? <Icon className="size-4 mt-0.5 text-muted-foreground shrink-0" /> : null;
|
||||
})()}
|
||||
<div>
|
||||
<p className="font-medium">This unit</p>
|
||||
<p className="text-muted-foreground">{unitRequirementText}</p>
|
||||
{unitDetail?.quiz && unitCompletionType !== 'pass_quiz' && (
|
||||
<p className="text-muted-foreground mt-1">
|
||||
This unit also has a quiz — passing it is tracked separately from the unit's completion status.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
|
||||
{/* ── Up next floating button ── */}
|
||||
{scrollProgress >= 100 && nextContent && !selectedQuizId && (
|
||||
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
@@ -443,6 +521,25 @@ const UnitReader = () => {
|
||||
<AppBreadcrumb items={breadcrumbItemsMobile} />
|
||||
</div>
|
||||
|
||||
{!selectedQuizId && lesson && (
|
||||
lesson.status === 'completed' ? (
|
||||
<div className="hidden lg:flex items-center gap-1.5 text-sm font-medium text-emerald-600 dark:text-emerald-400 shrink-0">
|
||||
<CheckCheck className="size-4" />
|
||||
Done
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hidden lg:flex items-center gap-1.5 text-muted-foreground shrink-0"
|
||||
onClick={() => setHelpOpen(true)}
|
||||
>
|
||||
<HelpCircle className="size-4" />
|
||||
How to complete
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="lg:hidden flex-shrink-0" aria-label="Open unit content">
|
||||
@@ -521,7 +618,7 @@ const UnitReader = () => {
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<div className={`${taskCtx?.has_task ? "xs:mt-42 lg:mt-36" : "mt-32"} ${showSidebarContent ? "lg:ml-80" : "lg:ml-14"} p-4 md:p-6 min-h-screen`}>
|
||||
<div className="relative w-full h-full">
|
||||
<div className={`relative w-full h-full ${showSidebarContent ? "" : "mx-auto max-w-xl"}`}>
|
||||
{selectedQuizId ? (
|
||||
<QuizBlock
|
||||
quiz={quiz}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
ArrowLeft, BookOpen, Clock, Check,
|
||||
ArrowLeft, BookOpen, Clock, Check, Lock,
|
||||
Tag, CalendarDays,
|
||||
} from "lucide-react";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
@@ -14,6 +14,7 @@ import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
import { getTierColor } from "@/utils/tierColors";
|
||||
import { getBundleContent, overlapsExistingAccess, isPlanCurrent } from "@/utils/planBundle.util";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -80,11 +81,19 @@ const ViewPlan = () => {
|
||||
const accentBorder = colors.panel.border;
|
||||
const features = plan.features ?? [];
|
||||
const duration = formatDuration(plan.duration_days, plan.duration_unit);
|
||||
// A user can hold more than one active tier concurrently — check membership
|
||||
// in the full active set, not just the best one.
|
||||
const isCurrent = (myTier?.active_tiers ?? []).some((t) => t.tier === plan.tier);
|
||||
// "Current" means the user actually purchased THIS exact plan (matched by
|
||||
// plan_id), not just any plan sharing the same tier slug — two different
|
||||
// plans can share a tier and stay independently active (Tier Plans v2).
|
||||
const isCurrent = isPlanCurrent(plan, myTier);
|
||||
// Blocked-by-overlap ("already covered") — plan isn't owned, but at least
|
||||
// one of its bundle items is already granted by something else the user
|
||||
// holds (Scenario 3): shown, not hidden, but purchase is disabled.
|
||||
const isBlockedByOverlap = !isCurrent && overlapsExistingAccess(plan, myTier?.my_grants);
|
||||
|
||||
const totalSeconds = (plan.courses ?? []).reduce((sum, c) => sum + (c.duration_seconds ?? 0), 0);
|
||||
const bundle = getBundleContent(plan);
|
||||
const bundleCount = bundle?.items?.length ?? 0;
|
||||
const BundleIcon = bundle?.icon ?? BookOpen;
|
||||
const totalSeconds = (bundle?.items ?? []).reduce((sum, c) => sum + (c.duration_seconds ?? 0), 0);
|
||||
const totalDuration = formatCourseDuration(totalSeconds);
|
||||
|
||||
return (
|
||||
@@ -137,15 +146,11 @@ const ViewPlan = () => {
|
||||
<Badge className="bg-white/20 border border-white/30 text-white px-4 py-2 text-sm font-medium">
|
||||
Not Available at the Moment
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-white text-gray-900 hover:bg-white/90 font-bold shadow-xl"
|
||||
onClick={() => navigate(`/plans/checkout?plan_id=${plan.plan_id}`)}
|
||||
>
|
||||
Get Started →
|
||||
</Button>
|
||||
)}
|
||||
) : isBlockedByOverlap ? (
|
||||
<Badge className="bg-white/20 border border-white/30 text-white px-4 py-2 text-sm font-medium">
|
||||
<Lock className="size-3.5 mr-1" /> Already Included in Your Plan
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,7 +179,7 @@ const ViewPlan = () => {
|
||||
{/* ── Stats row ─────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Courses", value: plan.course_count ?? 0, icon: BookOpen },
|
||||
{ label: bundle ? `${bundle.noun}s` : "Courses", value: bundleCount, icon: BundleIcon },
|
||||
{ label: "Content", value: totalDuration ?? "—", icon: Clock },
|
||||
{ label: "Access", value: duration ?? "Lifetime", icon: CalendarDays },
|
||||
].map(({ label, value, icon: StatIcon }) => (
|
||||
@@ -186,39 +191,39 @@ const ViewPlan = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Included Courses ──────────────────────────────────── */}
|
||||
{/* ── Included Items ─────────────────────────────────────── */}
|
||||
<div className="rounded-2xl bg-card border overflow-hidden">
|
||||
<div className="px-5 py-4 border-b flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="size-4" style={accentStyle} />
|
||||
<span className="text-sm font-semibold">Included Courses</span>
|
||||
<BundleIcon className="size-4" style={accentStyle} />
|
||||
<span className="text-sm font-semibold">Bundle</span>
|
||||
</div>
|
||||
<Badge variant="secondary">{plan.course_count ?? 0}</Badge>
|
||||
<Badge variant="secondary">{bundleCount}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
{plan.courses?.length > 0 ? (
|
||||
plan.courses.map((course) => (
|
||||
<div key={course.course_id} className="flex items-start gap-4 px-5 py-4">
|
||||
{bundle ? (
|
||||
bundle.items.map((item) => (
|
||||
<div key={item[bundle.idKey]} className="flex items-start gap-4 px-5 py-4">
|
||||
<div className={`h-10 w-10 rounded-xl ${accentBg} border ${accentBorder} flex items-center justify-center shrink-0`}>
|
||||
<BookOpen className="size-5" style={accentStyle} />
|
||||
<bundle.icon className="size-5" style={accentStyle} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold line-clamp-1">{course.title}</p>
|
||||
<p className="text-sm font-semibold line-clamp-1">{item.title}</p>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
{course.level && (
|
||||
{item.level && (
|
||||
<Badge variant="outline" className="text-xs capitalize h-5">
|
||||
{course.level}
|
||||
{item.level}
|
||||
</Badge>
|
||||
)}
|
||||
{formatCourseDuration(course.duration_seconds) && (
|
||||
{formatCourseDuration(item.duration_seconds) && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{formatCourseDuration(course.duration_seconds)}
|
||||
{formatCourseDuration(item.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
{course.course_code && (
|
||||
<span className="text-xs text-muted-foreground">{course.course_code}</span>
|
||||
{item.course_code && (
|
||||
<span className="text-xs text-muted-foreground">{item.course_code}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,7 +237,7 @@ const ViewPlan = () => {
|
||||
<div className={`h-14 w-14 rounded-2xl ${accentBg} border ${accentBorder} flex items-center justify-center mb-3`}>
|
||||
<BookOpen className="size-7" style={accentStyle} />
|
||||
</div>
|
||||
<p className="font-semibold text-sm">Courses coming soon</p>
|
||||
<p className="font-semibold text-sm">Content coming soon</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 max-w-xs">
|
||||
We're curating the best content for this plan. Subscribe now and get instant access the moment they go live.
|
||||
</p>
|
||||
@@ -250,22 +255,17 @@ const ViewPlan = () => {
|
||||
</div>
|
||||
<div className="relative">
|
||||
<p className="text-white font-bold text-lg mb-1">
|
||||
{plan.is_active ? "Ready to get started?" : "Coming Soon"}
|
||||
{isBlockedByOverlap ? "Already Included" : plan.is_active ? "Ready to get started?" : "Coming Soon"}
|
||||
</p>
|
||||
<p className="text-white/70 text-sm mb-5 max-w-xs mx-auto">
|
||||
{plan.is_active
|
||||
{isBlockedByOverlap
|
||||
? "Your existing access already covers item(s) in this bundle, so it can't be purchased separately."
|
||||
: plan.is_active
|
||||
? (plan.description || `Everything you need with the ${tierLabel} plan.`)
|
||||
: "This plan is not available for purchase at the moment. Check back later."}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-white/80 hover:text-white hover:bg-white/10"
|
||||
onClick={() => navigate("/plans")}
|
||||
>
|
||||
View All Plans
|
||||
</Button>
|
||||
{plan.is_active && (
|
||||
{plan.is_active && !isBlockedByOverlap && (
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-white text-gray-900 hover:bg-white/90 font-bold shadow-lg"
|
||||
|
||||
@@ -94,10 +94,15 @@ export default function Footer() {
|
||||
|
||||
</div>
|
||||
{/* Copyright */}
|
||||
<div className="p-8 border-t">
|
||||
<div className="p-8 border-t space-y-2">
|
||||
<p className="text-center text-muted-foreground text-sm">
|
||||
© {new Date().getFullYear()} Philproperties International Corporation. All rights reserved.
|
||||
</p>
|
||||
<p className="text-center text-sm">
|
||||
<Link to="/privacy-policy" className="text-muted-foreground underline hover:text-primary dark:hover:text-white">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: PrivacyPolicy.jsx
|
||||
* Type of Program: Frontend Page
|
||||
* Description: Public privacy policy page. Required by Google as the Privacy Policy URL
|
||||
* for OAuth sensitive-scope verification (Gmail API gmail.send usage).
|
||||
* Module: Public
|
||||
* Author: Kenneth Obsequio
|
||||
* Date Created: Aug. 4, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
|
||||
const LAST_UPDATED = 'August 4, 2026'
|
||||
|
||||
function Section({ title, children }) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-semibold text-primary dark:text-white tracking-tight">{title}</h2>
|
||||
<div className="space-y-3 text-muted-foreground leading-relaxed">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-6 py-16 space-y-10">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold text-primary dark:text-white tracking-tighter">Privacy Policy</h1>
|
||||
<p className="text-sm text-muted-foreground">Last updated: {LAST_UPDATED}</p>
|
||||
</div>
|
||||
|
||||
<Section title="Introduction">
|
||||
<p>
|
||||
STARR ("we", "our", "us") is a learning management system operated by Philproperties
|
||||
International Corporation. This policy explains what information we collect through
|
||||
starr.philproperties.orij.space, how we use it, and how it is protected.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Information We Collect">
|
||||
<p>
|
||||
When you register or use an account on STARR, we collect the information you provide
|
||||
directly, such as your name, email address, and contact details, along with information
|
||||
generated by your use of the platform, such as course enrollment, progress, and quiz
|
||||
results.
|
||||
</p>
|
||||
<p>
|
||||
If you make a purchase, payment is processed by PayPal. We do not collect or store your
|
||||
payment card details ourselves.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Google Sign-In">
|
||||
<p>
|
||||
If you choose to sign in with Google, we receive only your name, email address, and
|
||||
profile picture from your Google account, used solely to create and authenticate your
|
||||
STARR account. We do not request or receive access to any other Google data through this
|
||||
sign-in.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Google API Services — Gmail API Use">
|
||||
<p>
|
||||
STARR uses the Gmail API, specifically the <code className="text-sm bg-muted px-1 py-0.5 rounded">gmail.send</code> scope,
|
||||
solely to send transactional system emails — such as one-time passcodes (OTP) for login
|
||||
and account notifications — from our own service mailbox. This scope only permits sending
|
||||
mail; it does not grant access to read, collect, or share the content of any Gmail
|
||||
account, including our own.
|
||||
</p>
|
||||
<p>
|
||||
STARR's use and transfer of information received from Google APIs adheres to the{' '}
|
||||
<a
|
||||
href="https://developers.google.com/terms/api-services-user-data-policy"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline hover:text-primary dark:hover:text-white"
|
||||
>
|
||||
Google API Services User Data Policy
|
||||
</a>, including the Limited Use requirements.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Data Security">
|
||||
<p>
|
||||
We use industry-standard measures, including encryption in transit, to protect the
|
||||
information you provide. Access to user data within STARR is restricted to personnel who
|
||||
need it to operate the platform.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Data Retention">
|
||||
<p>
|
||||
We retain account and course-activity information for as long as your account remains
|
||||
active, or as needed to comply with legal obligations, resolve disputes, and enforce our
|
||||
agreements.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Changes to This Policy">
|
||||
<p>
|
||||
We may update this policy from time to time. Material changes will be reflected by
|
||||
updating the "Last updated" date above.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Contact Us">
|
||||
<p>
|
||||
If you have questions about this policy or how your information is handled, contact us at{' '}
|
||||
<a href="mailto:services.philpro@gmail.com" className="underline hover:text-primary dark:hover:text-white">
|
||||
services.philpro@gmail.com
|
||||
</a>.
|
||||
</p>
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,11 +4,15 @@ import { ClientRoutes } from '../modules/client/routes/ClientRoutes'
|
||||
import { StaffRoutes } from '../modules/staff/routes/StaffRoutes'
|
||||
import { AuthRoutes } from '../modules/auth/routes/AuthRoutes'
|
||||
import NotFound from '@/modules/public/pages/NotFound'
|
||||
import LandingLayout from '@/modules/public/layouts/LandingLayout'
|
||||
import PrivacyPolicy from '@/modules/public/pages/PrivacyPolicy'
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
AuthRoutes, // "/" → Login, guarded by PublicRoute
|
||||
AdminRoutes, // "/admin" → guarded by ProtectedRoute allowedRoles=['admin']
|
||||
ClientRoutes, // "/client" → guarded by ProtectedRoute allowedRoles=['client']
|
||||
StaffRoutes, // "/staff" → guarded by ProtectedRoute allowedRoles=['staff']
|
||||
// Unguarded — must stay reachable regardless of auth state (Google OAuth verification requirement)
|
||||
{ path: '/privacy-policy', element: <LandingLayout><PrivacyPolicy /></LandingLayout> },
|
||||
{ path: '*', element: <NotFound /> },
|
||||
])
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BookOpen, Layers, FileText } from "lucide-react";
|
||||
|
||||
// A plan's bundle is single-type (Tier Plans v2) — resolve whichever of
|
||||
// courses/units/lessons is actually populated instead of assuming courses.
|
||||
const BUNDLE_META = {
|
||||
course: { icon: BookOpen, idKey: "course_id", noun: "Course" },
|
||||
unit: { icon: Layers, idKey: "unit_id", noun: "Unit" },
|
||||
lesson: { icon: FileText, idKey: "lesson_id", noun: "Lesson" },
|
||||
};
|
||||
|
||||
export function getBundleContent(plan) {
|
||||
if (plan?.courses?.length) return { type: "course", items: plan.courses, ...BUNDLE_META.course };
|
||||
if (plan?.units?.length) return { type: "unit", items: plan.units, ...BUNDLE_META.unit };
|
||||
if (plan?.lessons?.length) return { type: "lesson", items: plan.lessons, ...BUNDLE_META.lesson };
|
||||
return null;
|
||||
}
|
||||
|
||||
// Item-specific "already covered" check (Tier Plans v2) — a plan is blocked
|
||||
// from purchase if ANY of its bundle items overlap the user's existing
|
||||
// granted items, even partially, regardless of tier slug.
|
||||
export function overlapsExistingAccess(plan, myGrants) {
|
||||
if (!myGrants) return false;
|
||||
const overlaps = (planIds = [], grantedIds = []) => {
|
||||
const granted = new Set((grantedIds ?? []).map(String));
|
||||
return (planIds ?? []).some((id) => granted.has(String(id)));
|
||||
};
|
||||
return (
|
||||
overlaps(plan?.course_ids, myGrants.course_ids) ||
|
||||
overlaps(plan?.unit_ids, myGrants.unit_ids) ||
|
||||
overlaps(plan?.lesson_ids, myGrants.lesson_ids)
|
||||
);
|
||||
}
|
||||
|
||||
// "Current" means the user actually purchased THIS exact plan (matched by
|
||||
// plan_id) and it's still active — NOT just any plan sharing the same tier
|
||||
// slug. Two different plans can be the same tier (e.g. two Premium bundles)
|
||||
// and stay independently active (Tier Plans v2).
|
||||
export function isPlanCurrent(plan, myTier) {
|
||||
return (myTier?.active_tiers ?? []).some((t) => t.plan_id != null && String(t.plan_id) === String(plan?.plan_id));
|
||||
}
|
||||
@@ -80,6 +80,10 @@ export const BADGE_STYLES = {
|
||||
// ── Generic boolean labels (BOOLEAN_FIELD_LABELS) ─────────
|
||||
"Yes": "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
"No": "bg-zinc-100 text-zinc-600 border-zinc-200",
|
||||
|
||||
// ── Publish status (Courses, Tier Plans) ──────
|
||||
"draft": "bg-amber-100 text-amber-700 border-amber-200",
|
||||
"published": "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
};
|
||||
|
||||
function EnumBadge({ value }) {
|
||||
|
||||
Reference in New Issue
Block a user