client revised

This commit is contained in:
rgrgogu
2026-08-05 04:32:29 +08:00
parent cd16e996e5
commit 882dfbe67a
52 changed files with 1946 additions and 2302 deletions
@@ -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()}
>
+9 -4
View File
@@ -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 [];
-8
View File
@@ -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 }];
}}
/>
+17 -140
View File
@@ -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" />
+268 -100
View File
@@ -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
+110 -61
View File
@@ -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 */}
+41 -353
View File
@@ -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 &gt; 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} />
)}
+5 -5
View File
@@ -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}
+3 -1
View File
@@ -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>
+1 -1
View File
@@ -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">
+4 -4
View File
@@ -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>
))}
+1 -11
View File
@@ -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>
+48 -23
View File
@@ -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
+129 -123
View File
@@ -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>
+170 -60
View File
@@ -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>
+105 -8
View File
@@ -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}
+41 -41
View File
@@ -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"
+6 -1
View File
@@ -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">
&copy; {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>
)
+114
View File
@@ -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
View File
@@ -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 /> },
])
+40
View File
@@ -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));
}
+4
View File
@@ -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 }) {