Files
starr-philproperties/src/components/generic/Blocks/Client/Advertisements/Hero.jsx
T

222 lines
12 KiB
React

// components/blocks/Hero.jsx
import { useEffect, useState } from "react";
import { Megaphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
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.
* Each slide is a full-bleed background image with a bottom gradient overlay,
* badge/headline/description/CTAs anchored bottom-left. Renders null when no
* ads are given — callers should not fall back to placeholder copy.
*
* Props:
* ads — array of advertisement objects { badge_label, headline, description, ctas, image, image_url, advertisement_id }
* onCtaClick — (ad, cta) => void, called when any CTA button is clicked
*/
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;
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 : [];
return (
<CarouselItem key={ad.advertisement_id}>
<Card className="border rounded-2xl overflow-hidden pl-0 py-0">
<CardContent className="relative xs:h-64 lg:h-96 bg-muted flex items-center justify-center">
{imageSrc ? (
<img
src={imageSrc}
alt={ad.headline || "Advertisement"}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<Megaphone className="size-8 text-muted-foreground" />
)}
{/* 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 && (
<Badge variant="outline" className="pointer-events-none select-none w-fit border-white/30 bg-black/20 text-white">
<Megaphone /> {ad.badge_label}
</Badge>
)}
{ad.headline && (
<div className="font-bold text-white xs:text-2xl lg:text-4xl leading-tight tracking-tighter pointer-events-none select-none">
{ad.headline}
</div>
)}
{ad.description && (
<p className="text-gray-200 xs:text-sm lg:text-md leading-relaxed pointer-events-none select-none line-clamp-2">
{ad.description}
</p>
)}
{ctas.length > 0 && (
<div className="flex items-center gap-2 pt-1">
{ctas.map((cta, i) => (
<Button
key={i}
variant={cta.variant === "outline" ? "outline" : "default"}
className={cta.variant !== "outline" ? "bg-[oklch(0.63_0.25_302)] text-white hover:bg-[oklch(0.63_0.25_302)]/85" : undefined}
onClick={() => onCtaClick?.(ad, cta)}
>
{cta.label}
</Button>
))}
</div>
)}
</div>
</CardContent>
</Card>
</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>
);
}
// ── HeroSkeleton ─────────────────────────────────────────────────────────────
export function HeroSkeleton() {
return (
<div className="w-full">
<Card className="border rounded-2xl overflow-hidden pl-0 py-0">
<CardContent className="relative xs:h-64 lg:h-96">
<Skeleton className="absolute inset-0 h-full w-full rounded-none" />
<div className="absolute bottom-0 left-0 p-6 flex flex-col gap-3 w-full max-w-lg">
<Skeleton className="h-6 w-32 rounded-full" />
<Skeleton className="h-10 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<div className="flex gap-2 pt-1">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-28" />
</div>
</div>
</CardContent>
</Card>
</div>
);
}