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