mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
git-subtree-dir: apps/web git-subtree-mainline:eaa6d2276agit-subtree-split:459af9d46a
225 lines
13 KiB
React
225 lines
13 KiB
React
// 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";
|
|
|
|
// 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 ───────────────────────────────────────────────────────────────────
|
|
/**
|
|
* 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.
|
|
* Every current-shape ad (content_mode "content") always shows its image with
|
|
* badge/headline/description layered on top, same as Hero — the whole slide
|
|
* is clickable through to the ad's redirect_link/landing page whenever it has
|
|
* no CTA buttons of its own. content_mode "image" is legacy-only (pre-dates
|
|
* the mandatory badge/headline/description/image/link shape) and keeps
|
|
* rendering as a bare clickable image with no text overlay.
|
|
*
|
|
* Props:
|
|
* ads — array of advertisement objects { content_mode, badge_labels, headline, description, ctas, redirect_link, image, image_url, advertisement_id }
|
|
* onCtaClick — (ad, cta) => void. cta is undefined for the whole-slide click.
|
|
*/
|
|
export function Banner({ ads, onCtaClick }) {
|
|
const [api, setApi] = useState();
|
|
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;
|
|
|
|
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 (
|
|
<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 : [];
|
|
const badgeLabels = Array.isArray(ad.badge_labels) ? ad.badge_labels : [];
|
|
|
|
// Whole-slide click-through only makes sense when there's no CTA
|
|
// row of its own to carry that responsibility instead — a landing
|
|
// page (Page Builder content) always wins over the plain redirect.
|
|
const clickable = ctas.length === 0 && (hasLandingPage(ad) || ad.redirect_link);
|
|
const handleSlideClick = () => (hasLandingPage(ad) ? setViewAd(ad) : onCtaClick?.(ad, undefined));
|
|
|
|
return (
|
|
<CarouselItem key={ad.advertisement_id}>
|
|
{ad.content_mode !== "content" ? (
|
|
// ── Full Image (legacy Image Only ads with no badge/headline/description) ──
|
|
<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>
|
|
) : (
|
|
// ── Image + Content overlay ──
|
|
<div
|
|
role={clickable ? "button" : undefined}
|
|
tabIndex={clickable ? 0 : undefined}
|
|
onClick={clickable ? handleSlideClick : undefined}
|
|
onKeyDown={clickable ? (e) => { if (e.key === "Enter" || e.key === " ") handleSlideClick(); } : undefined}
|
|
className={"relative w-full rounded-xl overflow-hidden border text-left h-[clamp(180px,22vw,320px)]" + (clickable ? " cursor-pointer" : "")}
|
|
>
|
|
{imageSrc ? (
|
|
<img
|
|
src={imageSrc}
|
|
alt={ad.headline || "Advertisement"}
|
|
className="absolute inset-0 w-full h-full object-cover pointer-events-none select-none"
|
|
/>
|
|
) : (
|
|
<div className="absolute inset-0 bg-muted flex items-center justify-center">
|
|
<Megaphone className="size-6 text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent" />
|
|
|
|
<div className="relative h-full w-full flex items-center xs:p-5 sm:p-4 lg:p-10 text-white overflow-hidden">
|
|
<div className="md:w-2xl space-y-4 xs:p-1.5 lg:p-0">
|
|
{badgeLabels.length > 0 && (
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
{badgeLabels.map((label, i) => (
|
|
<Badge key={i} variant="outline" className="pointer-events-none select-none border-white/50 bg-white/10 text-white">
|
|
{label}
|
|
</Badge>
|
|
))}
|
|
</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="pointer-events-none select-none leading-relaxed text-lg line-clamp-2">{ad.description}</p>}
|
|
|
|
{ctas.length > 0 && (
|
|
<div className="flex gap-3 items-center">
|
|
{ctas.map((cta, i) => (
|
|
<Button
|
|
key={i}
|
|
variant={cta.variant === "outline" ? "outline" : "secondary"}
|
|
onClick={() => onCtaClick?.(ad, cta)}
|
|
>
|
|
{cta.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</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() {
|
|
return <Skeleton className="w-full rounded-xl h-[220px]" />;
|
|
}
|