changes to advertisements

This commit is contained in:
rgrgogu
2026-07-29 03:25:31 +08:00
parent 48dd501d17
commit 04c4a3e1f6
8 changed files with 806 additions and 186 deletions
+4 -1
View File
@@ -6,7 +6,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&family=Fira+Code:wght@400..600&display=swap" rel="stylesheet">
<link
href="https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&family=Fira+Code:wght@400..600&family=Abril+Fatface&display=swap"
rel="stylesheet"
/>
<title>%VITE_APP_NAME%</title>
</head>
<body>
@@ -1,64 +1,203 @@
// components/blocks/Banner.jsx
import { useEffect, useState } from "react";
import { Megaphone } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext } from "@/components/ui/carousel";
import { Progress } from "@/components/ui/progress";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { resolveAssetSrc } from "@/utils/media.util";
// Height per size — controls the strip's visual weight, not its width (always full-width).
// sm/md are fixed; lg/xl use clamp() so they scale with viewport width between
// a min and max instead of jumping at breakpoints.
const SIZE_HEIGHT = {
sm: "h-[80px]",
md: "h-[140px]",
lg: "h-[clamp(220px,26vw,320px)]",
xl: "h-[clamp(320px,34vw,460px)]",
};
// An ad only ever has an internally-authored landing page when it has no
// redirect_link (see applyAdvertisementFields on the backend, which clears
// whichever one the admin didn't choose) — so its presence alone is enough
// to tell whether there's Page Builder content worth surfacing.
function hasLandingPage(ad) {
const page = ad?.landing_page;
if (!page) return false;
return Boolean(page.title || page.description || page.body || page.links?.length);
}
// ── Banner ───────────────────────────────────────────────────────────────────
/**
* Generic banner advertisement block.
* Full-width horizontal strip, image-led with optional headline overlay.
* Click anywhere on the banner triggers the first available CTA (or just tracks
* the click if no CTA exists) — banners don't carry their own button row.
* Banner advertisement carousel — same carousel shell as Hero, so a placement
* with several live ads (e.g. tier_plans.banner) rotates through all of them
* instead of only ever showing the single highest-priority one.
* Each slide's layout is driven by its own content_mode:
* "content" — decorative panel with badge/headline/description/CTAs
* "image" — full-bleed image, clickable through to the ad's redirect/CTA
*
* Props:
* ad — advertisement object { headline, ctas, image, image_url, advertisement_id }
* size — "sm" | "md" | "lg" | "xl" (default "md")
* onCtaClick — (ad, cta) => void, called on click. cta may be undefined if the ad has none.
* ads — array of advertisement objects { content_mode, badge_label, headline, description, ctas, image, image_url, advertisement_id }
* onCtaClick — (ad, cta) => void. cta is undefined for the whole-banner click on image-only ads.
*/
export function Banner({ ad, size, onCtaClick }) {
if (!ad) return null;
export function Banner({ ads, onCtaClick }) {
const [api, setApi] = useState();
const [current, setCurrent] = useState(0);
const [count, setCount] = useState(0);
const [viewAd, setViewAd] = useState(null); // ad whose Page Builder content is open in the Dialog
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
const resolvedSize = ad.size || size || "md";
const heightClass = SIZE_HEIGHT[resolvedSize] ?? SIZE_HEIGHT.md;
useEffect(() => {
if (!api) return;
const handleClick = () => onCtaClick?.(ad, ctas[0]);
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
api.on("select", () => {
setCurrent(api.selectedScrollSnap() + 1);
});
}, [api]);
if (!ads?.length) return null;
const progressValue = count ? (current / count) * 100 : 0;
return (
<button
type="button"
onClick={handleClick}
className={`relative w-full rounded-lg bg-muted overflow-hidden flex items-center justify-center text-left ${heightClass}`}
>
{imageSrc ? (
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover pointer-events-none select-none" />
) : (
<Megaphone className="size-6 text-muted-foreground pointer-events-none select-none" />
)}
<div className="w-full">
<Carousel setApi={setApi} className="w-full">
<CarouselContent>
{ads.map((ad) => {
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
{ad.headline && (
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 flex items-end p-4 pointer-events-none select-none">
<p className="text-white font-medium text-sm sm:text-base">{ad.headline}</p>
</div>
)}
</button>
return (
<CarouselItem key={ad.advertisement_id}>
{ad.content_mode !== "content" ? (
// ── Full Image ──
// A landing page (Page Builder content) always wins over the CTA
// click-through — it's the only destination this ad actually has.
<button
type="button"
onClick={() => (hasLandingPage(ad) ? setViewAd(ad) : onCtaClick?.(ad, ctas[0]))}
className="relative w-full rounded-xl overflow-hidden bg-muted flex items-center justify-center text-left h-[clamp(180px,22vw,320px)] border"
>
{imageSrc ? (
<img
src={imageSrc}
alt={ad.headline || "Advertisement"}
className="w-full h-full object-cover pointer-events-none select-none"
/>
) : (
<Megaphone className="size-6 text-muted-foreground pointer-events-none select-none" />
)}
</button>
) : (
// ── Content + Image ──
<div className="tier_plans_banner w-full rounded-xl xs:p-5 sm:p-4 lg:p-10 text-white">
<div className="relative w-full items-start flex justify-between">
<div className="md:w-2xl space-y-4 xs:p-1.5 lg:p-0">
{ad.badge_label && (
<Badge className="bg-white text-black">
{ad.badge_label}
</Badge>
)}
{ad.headline && <div className="lg:text-5xl xs:text-4xl font-tier-ads lg:leading-16">{ad.headline}</div>}
{ad.description && <p className="leading-relaxed text-lg">{ad.description}</p>}
{ctas.length > 0 && (
<div className="flex gap-3 items-center">
{ctas.map((cta, i) => (
<Button
key={i}
variant={cta.variant === "outline" ? "outline" : "secondary"}
onClick={() => onCtaClick?.(ad, cta)}
>
{cta.label}
</Button>
))}
</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>
)}
</CarouselItem>
);
})}
</CarouselContent>
{/* Navigation and Progress — only meaningful with more than one slide */}
{ads.length > 1 && (
<div className="flex items-center justify-between mt-4">
<div className="flex items-center gap-2">
<CarouselPrevious className="static translate-y-0 size-8 rounded-lg" />
<CarouselNext className="static translate-y-0 size-8 rounded-lg" />
</div>
<div className="flex-1 max-w-24 ml-4">
<Progress value={progressValue} className="h-2" />
</div>
</div>
)}
</Carousel>
{/* ── Page Builder content Dialog ─────────────────────────────────── */}
<Dialog open={!!viewAd} onOpenChange={(open) => !open && setViewAd(null)}>
<DialogContent className="sm:max-w-lg max-h-[80vh] overflow-y-auto">
{viewAd && (() => {
const page = viewAd.landing_page ?? {};
const imageSrc = resolveAssetSrc(viewAd.image) || viewAd.image_url || null;
const links = Array.isArray(page.links) ? page.links : [];
return (
<>
<DialogHeader>
<DialogTitle>{page.title || viewAd.headline || "Advertisement"}</DialogTitle>
{page.description && <DialogDescription>{page.description}</DialogDescription>}
</DialogHeader>
{imageSrc && (
<div className="rounded-lg overflow-hidden border h-48">
<img
src={imageSrc}
alt={page.title || viewAd.headline || "Advertisement"}
className="w-full h-full object-cover"
/>
</div>
)}
{page.body && (
<div className="prose prose-sm dark:prose-invert max-w-none whitespace-pre-wrap">
{page.body}
</div>
)}
{links.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2">
{links.map((l, i) => (
<Button
key={i}
variant={i === 0 ? "default" : "outline"}
onClick={() => { onCtaClick?.(viewAd, l); setViewAd(null); }}
>
{l.label || l.link}
</Button>
))}
</div>
)}
</>
);
})()}
</DialogContent>
</Dialog>
</div>
);
}
// ── BannerSkeleton ───────────────────────────────────────────────────────────
export function BannerSkeleton({ size = "md" }) {
return <Skeleton className={`w-full rounded-lg ${SIZE_HEIGHT[size] ?? SIZE_HEIGHT.md}`} />;
export function BannerSkeleton() {
return <Skeleton className="w-full rounded-xl h-[220px]" />;
}
@@ -8,8 +8,19 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent } from "@/components/ui/card";
import { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext } from "@/components/ui/carousel";
import { Progress } from "@/components/ui/progress";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { resolveAssetSrc } from "@/utils/media.util";
// An ad only ever has an internally-authored landing page when it has no
// redirect_link (see applyAdvertisementFields on the backend, which clears
// whichever one the admin didn't choose) — so its presence alone is enough
// to tell whether there's Page Builder content worth surfacing.
function hasLandingPage(ad) {
const page = ad?.landing_page;
if (!page) return false;
return Boolean(page.title || page.description || page.body || page.links?.length);
}
// ── Hero ─────────────────────────────────────────────────────────────────────
/**
* Hero advertisement carousel.
@@ -25,6 +36,7 @@ export function Hero({ ads, onCtaClick }) {
const [api, setApi] = useState();
const [current, setCurrent] = useState(0);
const [count, setCount] = useState(0);
const [viewAd, setViewAd] = useState(null); // ad whose Page Builder content is open in the Dialog
useEffect(() => {
if (!api) return;
@@ -66,6 +78,18 @@ export function Hero({ ads, onCtaClick }) {
{/* Bottom gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent" />
{hasLandingPage(ad) && (
<div className="absolute top-4 right-4">
<Button
variant="secondary"
aria-label="View advertisement details"
onClick={() => setViewAd(ad)}
>
View <Megaphone />
</Button>
</div>
)}
{/* Bottom-left content */}
<div className="absolute bottom-0 left-0 p-6 flex flex-col gap-3 xs:max-w-[280px] sm:max-w-sm lg:max-w-xl">
{ad.badge_label && (
@@ -118,6 +142,56 @@ export function Hero({ ads, onCtaClick }) {
</div>
)}
</Carousel>
{/* ── Page Builder content Dialog ─────────────────────────────────── */}
<Dialog open={!!viewAd} onOpenChange={(open) => !open && setViewAd(null)}>
<DialogContent className="sm:max-w-lg max-h-[80vh] overflow-y-auto">
{viewAd && (() => {
const page = viewAd.landing_page ?? {};
const imageSrc = resolveAssetSrc(viewAd.image) || viewAd.image_url || null;
const links = Array.isArray(page.links) ? page.links : [];
return (
<>
<DialogHeader>
<DialogTitle>{page.title || viewAd.headline || "Advertisement"}</DialogTitle>
{page.description && <DialogDescription>{page.description}</DialogDescription>}
</DialogHeader>
{imageSrc && (
<div className="rounded-lg overflow-hidden border h-48">
<img
src={imageSrc}
alt={page.title || viewAd.headline || "Advertisement"}
className="w-full h-full object-cover"
/>
</div>
)}
{page.body && (
<div className="prose prose-sm dark:prose-invert max-w-none whitespace-pre-wrap">
{page.body}
</div>
)}
{links.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2">
{links.map((l, i) => (
<Button
key={i}
variant={i === 0 ? "default" : "outline"}
onClick={() => { onCtaClick?.(viewAd, l); setViewAd(null); }}
>
{l.label || l.link}
</Button>
))}
</div>
)}
</>
);
})()}
</DialogContent>
</Dialog>
</div>
);
}
+456
View File
@@ -22,6 +22,8 @@
@theme inline {
/* From myself */
--font-geist: "Instrument Sans", sans-serif;
--font-tier-ads: "Abril Fatface", serif;
--breakpoint-xs: 320px;
/* white fixed */
@@ -255,3 +257,457 @@
--ring: oklch(0.63 0.25 302);
--sidebar-ring: oklch(0.63 0.25 302);
}
/* For our Tier Plans call */
.tier_plans_banner {
background-color: hsla(173, 77%, 83%, 1);
background-image: radial-gradient(circle at 5% 1%, hsla(250, 76%, 61%, 1) 7%, transparent 84%), radial-gradient(circle at 7% 81%, hsla(184, 91%, 91%, 1) 16%, transparent 53%), radial-gradient(circle at 11% 29%, hsla(213, 97%, 75%, 1) 8%, transparent 74%), radial-gradient(circle at 39% 52%, hsla(149, 93%, 64%, 1) 6%, transparent 68%), radial-gradient(circle at 90% 50%, hsla(118, 94%, 89%, 1) 2%, transparent 85%);
background-blend-mode: normal, normal, normal, normal, normal;
}
.dark .tier_plans_banner {
--x-0: 93%;
--y-0: 93%;
--c-0: hsla(212, 0%, 0%, 1);
--y-1: 9%;
--x-1: 26%;
--c-1: hsla(212, 0%, 0%, 1);
--c-2: hsla(257, 91%, 27%, 0.35);
--x-2: 15%;
--y-2: 79%;
--c-3: hsla(212, 100%, 50%, 0.5);
--y-3: 104%;
--x-3: 40%;
--x-4: 0%;
--y-4: 60%;
--c-4: hsla(224, 72%, 36%, 1);
--c-5: hsla(248, 52%, 24%, 1);
--y-5: 37%;
--x-5: 92%;
--c-6: hsla(212, 100%, 50%, 0.19);
--y-6: 16%;
--x-6: 101%;
--y-7: 13%;
--x-7: 90%;
--c-7: hsla(227, 98%, 53%, 1);
--c-8: hsla(166, 71%, 60%, 0.32);
--x-8: 104%;
--y-8: 56%;
--x-9: 97%;
--y-9: 19%;
--c-9: hsla(219, 83%, 23%, 0.18);
background-color: hsla(305, 0%, 0%, 1);
background-image: radial-gradient(circle at var(--x-0) var(--y-0), var(--c-0) var(--s-start-0), transparent var(--s-end-0)), radial-gradient(circle at var(--x-1) var(--y-1), var(--c-1) var(--s-start-1), transparent var(--s-end-1)), radial-gradient(circle at var(--x-2) var(--y-2), var(--c-2) var(--s-start-2), transparent var(--s-end-2)), radial-gradient(circle at var(--x-3) var(--y-3), var(--c-3) var(--s-start-3), transparent var(--s-end-3)), radial-gradient(circle at var(--x-4) var(--y-4), var(--c-4) var(--s-start-4), transparent var(--s-end-4)), radial-gradient(circle at var(--x-5) var(--y-5), var(--c-5) var(--s-start-5), transparent var(--s-end-5)), radial-gradient(circle at var(--x-6) var(--y-6), var(--c-6) var(--s-start-6), transparent var(--s-end-6)), radial-gradient(circle at var(--x-7) var(--y-7), var(--c-7) var(--s-start-7), transparent var(--s-end-7)), radial-gradient(circle at var(--x-8) var(--y-8), var(--c-8) var(--s-start-8), transparent var(--s-end-8)), radial-gradient(circle at var(--x-9) var(--y-9), var(--c-9) var(--s-start-9), transparent var(--s-end-9));
animation: tier_anim 10s linear infinite alternate;
background-blend-mode: normal, normal, normal, normal, normal, normal, normal, normal, normal, normal;
will-change: transform, opacity;
contain: paint;
}
@keyframes tier_anim {
0% {
--x-0: 93%;
--y-0: 93%;
--s-start-0: 14.489998991212337%;
--s-end-0: 72%;
--c-0: hsla(212, 0%, 0%, 1);
--y-1: 9%;
--x-1: 26%;
--s-start-1: 0%;
--s-end-1: 45%;
--c-1: hsla(212, 0%, 0%, 1);
--s-start-2: 2.9253667596993065%;
--s-end-2: 22.388851682060018%;
--c-2: hsla(257, 91%, 27%, 0.35);
--x-2: 15%;
--y-2: 79%;
--s-start-3: 3.985353824694249%;
--s-end-3: 47.580278608924694%;
--c-3: hsla(212, 100%, 50%, 0.5);
--y-3: 104%;
--x-3: 40%;
--x-4: 0%;
--y-4: 60%;
--s-start-4: 2.391200382592061%;
--s-end-4: 29.307684556768592%;
--c-4: hsla(224, 72%, 36%, 1);
--s-start-5: 2.9253667596993065%;
--s-end-5: 22.388851682060018%;
--c-5: hsla(248, 52%, 24%, 1);
--y-5: 37%;
--x-5: 92%;
--s-start-6: 13.173642363290591%;
--s-end-6: 31.747336520355095%;
--c-6: hsla(212, 100%, 50%, 0.19);
--y-6: 16%;
--x-6: 101%;
--y-7: 13%;
--x-7: 90%;
--s-start-7: 1%;
--s-end-7: 31%;
--c-7: hsla(227, 98%, 53%, 1);
--s-start-8: 3.985353824694249%;
--s-end-8: 13.103042116379756%;
--c-8: hsla(166, 71%, 60%, 0.32);
--x-8: 104%;
--y-8: 56%;
--x-9: 97%;
--y-9: 19%;
--s-start-9: 18.597054544690312%;
--s-end-9: 31%;
--c-9: hsla(219, 83%, 23%, 0.18);
}
100% {
--x-0: 7%;
--y-0: 9%;
--s-start-0: 2.391200382592061%;
--s-end-0: 43.902064173373226%;
--c-0: hsla(306, 0%, 0%, 1);
--y-1: 93%;
--x-1: 96%;
--s-start-1: 9%;
--s-end-1: 54.805582404585024%;
--c-1: hsla(306, 0%, 0%, 1);
--s-start-2: 3%;
--s-end-2: 26.722813338714598%;
--c-2: hsla(166, 72%, 60%, 1);
--x-2: -2%;
--y-2: 103%;
--s-start-3: 2.391200382592061%;
--s-end-3: 32.0689540200964%;
--c-3: hsla(180, 100%, 50%, 0.26);
--y-3: 82%;
--x-3: 33%;
--x-4: 37%;
--y-4: 81%;
--s-start-4: 4.40642490323111%;
--s-end-4: 37.23528104246256%;
--c-4: hsla(212, 88%, 26%, 0.58);
--s-start-5: 3%;
--s-end-5: 32.537089799783296%;
--c-5: hsla(271, 98%, 53%, 0.31);
--y-5: 99%;
--x-5: 54%;
--s-start-6: 6%;
--s-end-6: 42.501105312974815%;
--c-6: hsla(262, 100%, 50%, 0.15);
--y-6: 43%;
--x-6: 104%;
--y-7: -16%;
--x-7: 104%;
--s-start-7: 5%;
--s-end-7: 13.10107024898374%;
--c-7: hsla(298, 36%, 23%, 1);
--s-start-8: 2.391200382592061%;
--s-end-8: 27.141813016850573%;
--c-8: hsla(180, 100%, 50%, 0.11);
--x-8: 97%;
--y-8: 30%;
--x-9: 78%;
--y-9: 4%;
--s-start-9: 5%;
--s-end-9: 21.32164536610654%;
--c-9: hsla(219, 83%, 23%, 0.59);
}
}
@property --x-0 {
syntax: '<percentage>';
inherits: false;
initial-value: 93%
}
@property --y-0 {
syntax: '<percentage>';
inherits: false;
initial-value: 93%
}
@property --s-start-0 {
syntax: '<percentage>';
inherits: false;
initial-value: 14.489998991212337%
}
@property --s-end-0 {
syntax: '<percentage>';
inherits: false;
initial-value: 72%
}
@property --c-0 {
syntax: '<color>';
inherits: false;
initial-value: hsla(212, 0%, 0%, 1)
}
@property --y-1 {
syntax: '<percentage>';
inherits: false;
initial-value: 9%
}
@property --x-1 {
syntax: '<percentage>';
inherits: false;
initial-value: 26%
}
@property --s-start-1 {
syntax: '<percentage>';
inherits: false;
initial-value: 0%
}
@property --s-end-1 {
syntax: '<percentage>';
inherits: false;
initial-value: 45%
}
@property --c-1 {
syntax: '<color>';
inherits: false;
initial-value: hsla(212, 0%, 0%, 1)
}
@property --s-start-2 {
syntax: '<percentage>';
inherits: false;
initial-value: 2.9253667596993065%
}
@property --s-end-2 {
syntax: '<percentage>';
inherits: false;
initial-value: 22.388851682060018%
}
@property --c-2 {
syntax: '<color>';
inherits: false;
initial-value: hsla(257, 91%, 27%, 0.35)
}
@property --x-2 {
syntax: '<percentage>';
inherits: false;
initial-value: 15%
}
@property --y-2 {
syntax: '<percentage>';
inherits: false;
initial-value: 79%
}
@property --s-start-3 {
syntax: '<percentage>';
inherits: false;
initial-value: 3.985353824694249%
}
@property --s-end-3 {
syntax: '<percentage>';
inherits: false;
initial-value: 47.580278608924694%
}
@property --c-3 {
syntax: '<color>';
inherits: false;
initial-value: hsla(212, 100%, 50%, 0.5)
}
@property --y-3 {
syntax: '<percentage>';
inherits: false;
initial-value: 104%
}
@property --x-3 {
syntax: '<percentage>';
inherits: false;
initial-value: 40%
}
@property --x-4 {
syntax: '<percentage>';
inherits: false;
initial-value: 0%
}
@property --y-4 {
syntax: '<percentage>';
inherits: false;
initial-value: 60%
}
@property --s-start-4 {
syntax: '<percentage>';
inherits: false;
initial-value: 2.391200382592061%
}
@property --s-end-4 {
syntax: '<percentage>';
inherits: false;
initial-value: 29.307684556768592%
}
@property --c-4 {
syntax: '<color>';
inherits: false;
initial-value: hsla(224, 72%, 36%, 1)
}
@property --s-start-5 {
syntax: '<percentage>';
inherits: false;
initial-value: 2.9253667596993065%
}
@property --s-end-5 {
syntax: '<percentage>';
inherits: false;
initial-value: 22.388851682060018%
}
@property --c-5 {
syntax: '<color>';
inherits: false;
initial-value: hsla(248, 52%, 24%, 1)
}
@property --y-5 {
syntax: '<percentage>';
inherits: false;
initial-value: 37%
}
@property --x-5 {
syntax: '<percentage>';
inherits: false;
initial-value: 92%
}
@property --s-start-6 {
syntax: '<percentage>';
inherits: false;
initial-value: 13.173642363290591%
}
@property --s-end-6 {
syntax: '<percentage>';
inherits: false;
initial-value: 31.747336520355095%
}
@property --c-6 {
syntax: '<color>';
inherits: false;
initial-value: hsla(212, 100%, 50%, 0.19)
}
@property --y-6 {
syntax: '<percentage>';
inherits: false;
initial-value: 16%
}
@property --x-6 {
syntax: '<percentage>';
inherits: false;
initial-value: 101%
}
@property --y-7 {
syntax: '<percentage>';
inherits: false;
initial-value: 13%
}
@property --x-7 {
syntax: '<percentage>';
inherits: false;
initial-value: 90%
}
@property --s-start-7 {
syntax: '<percentage>';
inherits: false;
initial-value: 1%
}
@property --s-end-7 {
syntax: '<percentage>';
inherits: false;
initial-value: 31%
}
@property --c-7 {
syntax: '<color>';
inherits: false;
initial-value: hsla(227, 98%, 53%, 1)
}
@property --s-start-8 {
syntax: '<percentage>';
inherits: false;
initial-value: 3.985353824694249%
}
@property --s-end-8 {
syntax: '<percentage>';
inherits: false;
initial-value: 13.103042116379756%
}
@property --c-8 {
syntax: '<color>';
inherits: false;
initial-value: hsla(166, 71%, 60%, 0.32)
}
@property --x-8 {
syntax: '<percentage>';
inherits: false;
initial-value: 104%
}
@property --y-8 {
syntax: '<percentage>';
inherits: false;
initial-value: 56%
}
@property --x-9 {
syntax: '<percentage>';
inherits: false;
initial-value: 97%
}
@property --y-9 {
syntax: '<percentage>';
inherits: false;
initial-value: 19%
}
@property --s-start-9 {
syntax: '<percentage>';
inherits: false;
initial-value: 18.597054544690312%
}
@property --s-end-9 {
syntax: '<percentage>';
inherits: false;
initial-value: 31%
}
@property --c-9 {
syntax: '<color>';
inherits: false;
initial-value: hsla(219, 83%, 23%, 0.18)
}
@@ -62,7 +62,6 @@ const schema = z.object({
end_date: z.string().optional(),
order: z.coerce.number().min(0).default(0),
is_active: z.boolean().default(true),
size: z.enum(["sm", "md", "lg", "xl"]).nullable().optional(),
}).superRefine((data, ctx) => {
const format = PLACEMENT_MAP[data.placement]?.format;
if (format === "hero" && (data.description?.length ?? 0) > 200) {
@@ -96,13 +95,6 @@ function FieldError({ message }) {
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
const BANNER_SIZES = [
{ value: "sm", label: "Small" },
{ value: "md", label: "Medium" },
{ value: "lg", label: "Large" },
{ value: "xl", label: "Extra Large" },
];
const CTA_VARIANTS = [
{ value: "default", label: "Primary" },
{ value: "outline", label: "Outline" },
@@ -152,7 +144,7 @@ function Stepper({ steps, stepIndex }) {
// ─── Step 1: Placement ──────────────────────────────────────────────────────
function StepPlacement({ placement, setValue, errors, format, isBanner, watch }) {
function StepPlacement({ placement, setValue, errors, format }) {
return (
<div className="space-y-5">
<div>
@@ -184,25 +176,6 @@ function StepPlacement({ placement, setValue, errors, format, isBanner, watch })
</p>
</div>
)}
{isBanner && (
<div>
<Label className="mb-1.5 block">Size</Label>
<Select value={watch("size") ?? "md"} onValueChange={(v) => setValue("size", v)}>
<SelectTrigger>
<SelectValue placeholder="Select a size" />
</SelectTrigger>
<SelectContent>
{BANNER_SIZES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1.5">
Controls the banner's height. Width always stretches full-width.
</p>
</div>
)}
</div>
);
}
@@ -468,7 +441,6 @@ function StepReview({ data, selectedAsset, imageUrl }) {
{placementMeta && <Badge variant="secondary" className="ml-auto capitalize">{placementMeta.format}</Badge>}
</div>
<SummaryRow label="Page" value={placementMeta?.pageLabel} />
<SummaryRow label="Size" value={data.size} />
</div>
<div className="border rounded-lg p-4 space-y-1">
@@ -567,7 +539,6 @@ export default function AddAdvertisement() {
end_date: "",
order: 0,
is_active: true,
size: null,
},
});
@@ -584,7 +555,6 @@ export default function AddAdvertisement() {
const description = watch("description");
const redirectLink = watch("redirect_link");
const format = PLACEMENT_MAP[placement]?.format;
const isBanner = format === "banner";
const steps = useMemo(
() => ALL_STEPS.filter((s) => !s.skippable || !redirectLink?.trim()),
@@ -620,7 +590,6 @@ export default function AddAdvertisement() {
landing_page: values.redirect_link ? null : values.landing_page,
start_date: values.start_date || null,
end_date: values.end_date || null,
size: PLACEMENT_MAP[values.placement]?.format === "banner" ? (values.size || "md") : null,
createdBy: user?.user_id ?? null,
};
@@ -655,8 +624,6 @@ export default function AddAdvertisement() {
setValue={setValue}
errors={errors}
format={format}
isBanner={isBanner}
watch={watch}
/>
)}
{current.id === "content" && (
@@ -56,7 +56,6 @@ const schema = z.object({
end_date: z.string().optional(),
order: z.coerce.number().min(0).default(0),
is_active: z.boolean().default(true),
size: z.enum(["sm", "md", "lg", "xl"]).nullable().optional(),
}).superRefine((data, ctx) => {
const format = PLACEMENT_MAP[data.placement]?.format;
if (format === "hero" && (data.description?.length ?? 0) > 200) {
@@ -92,13 +91,6 @@ function SectionCard({ title, description, children }) {
);
}
const BANNER_SIZES = [
{ value: "sm", label: "Small" },
{ value: "md", label: "Medium" },
{ value: "lg", label: "Large" },
{ value: "xl", label: "Extra Large" },
];
const CTA_VARIANTS = [
{ value: "default", label: "Primary" },
{ value: "outline", label: "Outline" },
@@ -144,7 +136,6 @@ export default function EditAdvertisement() {
end_date: "",
order: 0,
is_active: true,
size: null,
},
});
@@ -158,7 +149,6 @@ export default function EditAdvertisement() {
const contentMode = watch("content_mode");
const redirectLink = watch("redirect_link");
const format = PLACEMENT_MAP[placement]?.format;
const isBanner = format === "banner";
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -204,7 +194,6 @@ export default function EditAdvertisement() {
end_date: ad.end_date ?? "",
order: ad.order ?? 0,
is_active: ad.is_active ?? true,
size: ad.size ?? null,
});
setReady(true);
@@ -222,7 +211,6 @@ export default function EditAdvertisement() {
landing_page: values.redirect_link ? null : values.landing_page,
start_date: values.start_date || null,
end_date: values.end_date || null,
size: PLACEMENT_MAP[values.placement]?.format === "banner" ? (values.size || "md") : null,
updatedBy: user?.user_id ?? null,
};
@@ -277,25 +265,6 @@ export default function EditAdvertisement() {
Format: <span className="font-medium text-foreground capitalize">{format}</span> — determined by the placement above.
</p>
)}
{isBanner && (
<div>
<Label className="mb-1.5 block">Size</Label>
<Select value={watch("size") ?? "md"} onValueChange={(v) => setValue("size", v, { shouldDirty: true })}>
<SelectTrigger>
<SelectValue placeholder="Select a size" />
</SelectTrigger>
<SelectContent>
{BANNER_SIZES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1.5">
Controls the banner's height. Width always stretches full-width.
</p>
</div>
)}
</SectionCard>
<SectionCard title="Content" description="Full image, or content with badge, headline, description, and CTAs.">
+24 -14
View File
@@ -5,7 +5,7 @@ import api from "@/utils/api.util";
import {
House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon,
SendHorizonal, CheckCheck, CheckCircle2, Clock, FileQuestion, ClipboardList,
Hourglass, Check,
Hourglass, Check, Megaphone
} from "lucide-react";
import { cn } from "@/lib/utils";
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
@@ -516,7 +516,7 @@ const CourseDetails = () => {
const { getCourse, course, courseBlocked, courseLoading, resetCourse } = useClientCourses();
const { myTier, getMyTier } = useClientTiers();
const { fetchCourseProgress, fetchCourseProgressSummary, summary: progressSummary, isCompleted, resetProgress } = useCourseReadingProgress();
const { advertisements, loading: adLoading, getActiveAdvertisements, handleAdCtaClick } = useClientAdvertisements();
const { adLists, listLoading: adLoading, getActiveAdvertisementList, handleAdCtaClick } = useClientAdvertisements();
const [tierMap, setTierMap] = useState({});
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
@@ -544,12 +544,12 @@ const CourseDetails = () => {
getCourse(courseId);
fetchCourseProgress(courseId);
fetchCourseProgressSummary(courseId);
getActiveAdvertisements(["course_details.banner"]);
getActiveAdvertisementList("course_details.banner");
return () => { resetCourse(); resetProgress(); setBadgeImageUrl(null); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courseId]);
const bannerAd = advertisements["course_details.banner"] ?? null;
const bannerAds = adLists["course_details.banner"] ?? [];
// Resolve badge image once course loads — issue a client stream token for
// private S3 assets so the badge preview works on this page.
@@ -613,7 +613,24 @@ const CourseDetails = () => {
<Hourglass className="size-3" /> Coming Soon
</Badge>
) : (
<>
<div className="flex gap-2 items-center">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">
<Megaphone /> View Ads
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Advertisements</DialogTitle>
</DialogHeader>
{adLoading["course_details.banner"] ? (
<BannerSkeleton />
) : (
<Banner ads={bannerAds} onCtaClick={handleAdCtaClick} />
)}
</DialogContent>
</Dialog>
<Button size="sm" className="lg:hidden" onClick={() => navigate(`/course/${courseId}/unit`)}>
{hasCompleted
? <><CheckCheck /> Start Again</>
@@ -626,7 +643,7 @@ const CourseDetails = () => {
: <><SendHorizonal /> Start Learning</>
}
</Button>
</>
</div>
)}
</div>
</div>
@@ -713,14 +730,7 @@ const CourseDetails = () => {
</div>
</div>
{/* Advertisement Banner */}
<div className="lg:container lg:mx-auto xs:px-6 lg:px-4">
{adLoading["course_details.banner"] ? (
<BannerSkeleton />
) : (
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
)}
</div>
{/* Body */}
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-3 xs:-mt-5 lg:-mt-0">
+65 -63
View File
@@ -84,8 +84,8 @@ const PREVIEW_COURSE_LIMIT = 2;
const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSecsLeft }) => {
const { fmtCurrency } = useDateFormat();
const [coursesOpen, setCoursesOpen] = useState(false);
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
const [coursesOpen, setCoursesOpen] = useState(false);
const [notAvailableOpen, setNotAvailableOpen] = useState(false);
const { label: tierLabel, cls: badgeCls, rank } = resolveTierBadge(plan.tier, tierMap);
const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag;
const ring = rank > 0 ? "ring-2 ring-primary/30" : "";
@@ -166,7 +166,7 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
type="button"
// onClick={() => setCoursesOpen(true)}
// DIALOG DISABLED
variant="secondary"
variant="secondary"
>
+{extraCount} more course{extraCount !== 1 ? "s" : ""}
@@ -344,7 +344,7 @@ export default function PlanList() {
const navigate = useNavigate();
const { plans, plansLoading, myTier, tierLoading, tierMap, getPlans, getMyTier, getTierCategories, resetMyTier } = useClientTiers();
const { fmtDate, fmtCurrency } = useDateFormat();
const { advertisements, loading: adLoading, getActiveAdvertisement, handleAdCtaClick } = useClientAdvertisements();
const { adLists, listLoading: adLoading, getActiveAdvertisementList, handleAdCtaClick } = useClientAdvertisements();
const [view, setView] = useState("grid");
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
@@ -356,11 +356,11 @@ export default function PlanList() {
getPlans();
getMyTier();
getTierCategories();
getActiveAdvertisement("tier_plans.banner");
getActiveAdvertisementList("tier_plans.banner");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getPlans, getMyTier]);
const bannerAd = advertisements["tier_plans.banner"] ?? null;
const bannerAds = adLists["tier_plans.banner"] ?? [];
useEffect(() => {
clearInterval(refundTimerRef.current);
@@ -404,68 +404,70 @@ export default function PlanList() {
<PageMeta title="Plans - STARR" description="Browse available subscription plans." />
<div className="bg-muted min-h-screen">
<div className="lg:container lg:mx-auto space-y-8 p-6">
<div className="xs:pt-2 lg:pt-8 space-y-4">
{/* Advertisement Banner */}
{adLoading["tier_plans.banner"] ? (
<BannerSkeleton />
) : (
<Banner ads={bannerAds} onCtaClick={handleAdCtaClick} />
)}
{/* Advertisement Banner */}
{adLoading["tier_plans.banner"] ? (
<BannerSkeleton />
) : (
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
)}
{/* Section Header */}
<div className="flex flex-col items-center gap-4 mt-6">
<div className="text-center">
<h2 className="text-3xl font-bold">Available Plans</h2>
<p className="text-muted-foreground mt-2">
Choose a subscription that matches your goals.
</p>
{/* Section Header */}
<div className="flex flex-col items-center gap-4 mt-6">
<div className="text-center my-8">
<h2 className="text-3xl font-bold">Available Plans</h2>
<p className="text-muted-foreground mt-2">
Choose a subscription that matches your goals.
</p>
</div>
{!plansLoading && plans.length > 0 && (
<div className="flex items-center gap-2">
<Button size="sm" variant={view === "grid" ? "default" : "outline"} onClick={() => setView("grid")}>
<LaptopMinimal /> Cards
</Button>
<Button size="sm" variant={view === "table" ? "default" : "outline"} onClick={() => setView("table")}>
<TableIcon /> Compare
</Button>
</div>
)}
</div>
{!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>
{/* Plan Cards / Comparison Table */}
{plansLoading || tierLoading ? (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => <PlanSkeleton key={i} />)}
</div>
) : plans.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<BookOpen className="size-10 mb-3" />
<p className="text-sm">No plans available at the moment.</p>
</div>
) : view === "table" ? (
<PlanComparisonTable
plans={plans}
myTier={myTier}
tierMap={tierMap}
fmtCurrency={fmtCurrency}
onSelect={handleSelectPlan}
/>
) : (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{plans.map((plan) => (
<PlanCard
key={plan.plan_id}
plan={plan}
myTier={myTier}
tierMap={tierMap}
onSelect={handleSelectPlan}
onView={handleViewPlan}
onRefund={handleRefundClick}
refundSecsLeft={refundSecsLeft}
/>
))}
</div>
)}
</div>
{/* Plan Cards / Comparison Table */}
{plansLoading || tierLoading ? (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => <PlanSkeleton key={i} />)}
</div>
) : plans.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<BookOpen className="size-10 mb-3" />
<p className="text-sm">No plans available at the moment.</p>
</div>
) : view === "table" ? (
<PlanComparisonTable
plans={plans}
myTier={myTier}
tierMap={tierMap}
fmtCurrency={fmtCurrency}
onSelect={handleSelectPlan}
/>
) : (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{plans.map((plan) => (
<PlanCard
key={plan.plan_id}
plan={plan}
myTier={myTier}
tierMap={tierMap}
onSelect={handleSelectPlan}
onView={handleViewPlan}
onRefund={handleRefundClick}
refundSecsLeft={refundSecsLeft}
/>
))}
</div>
)}
</div>
</div>
</div>