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
@@ -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]" />;
}