mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
changes to advertisements
This commit is contained in:
+4
-1
@@ -6,7 +6,10 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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>
|
<title>%VITE_APP_NAME%</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,64 +1,203 @@
|
|||||||
// components/blocks/Banner.jsx
|
// components/blocks/Banner.jsx
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Megaphone } from "lucide-react";
|
import { Megaphone } from "lucide-react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
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";
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
|
|
||||||
// Height per size — controls the strip's visual weight, not its width (always full-width).
|
// An ad only ever has an internally-authored landing page when it has no
|
||||||
// sm/md are fixed; lg/xl use clamp() so they scale with viewport width between
|
// redirect_link (see applyAdvertisementFields on the backend, which clears
|
||||||
// a min and max instead of jumping at breakpoints.
|
// whichever one the admin didn't choose) — so its presence alone is enough
|
||||||
const SIZE_HEIGHT = {
|
// to tell whether there's Page Builder content worth surfacing.
|
||||||
sm: "h-[80px]",
|
function hasLandingPage(ad) {
|
||||||
md: "h-[140px]",
|
const page = ad?.landing_page;
|
||||||
lg: "h-[clamp(220px,26vw,320px)]",
|
if (!page) return false;
|
||||||
xl: "h-[clamp(320px,34vw,460px)]",
|
return Boolean(page.title || page.description || page.body || page.links?.length);
|
||||||
};
|
}
|
||||||
|
|
||||||
// ── Banner ───────────────────────────────────────────────────────────────────
|
// ── Banner ───────────────────────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* Generic banner advertisement block.
|
* Banner advertisement carousel — same carousel shell as Hero, so a placement
|
||||||
* Full-width horizontal strip, image-led with optional headline overlay.
|
* with several live ads (e.g. tier_plans.banner) rotates through all of them
|
||||||
* Click anywhere on the banner triggers the first available CTA (or just tracks
|
* instead of only ever showing the single highest-priority one.
|
||||||
* the click if no CTA exists) — banners don't carry their own button row.
|
* 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:
|
* Props:
|
||||||
* ad — advertisement object { headline, ctas, image, image_url, advertisement_id }
|
* ads — array of advertisement objects { content_mode, badge_label, headline, description, ctas, image, image_url, advertisement_id }
|
||||||
* size — "sm" | "md" | "lg" | "xl" (default "md")
|
* onCtaClick — (ad, cta) => void. cta is undefined for the whole-banner click on image-only ads.
|
||||||
* onCtaClick — (ad, cta) => void, called on click. cta may be undefined if the ad has none.
|
|
||||||
*/
|
*/
|
||||||
export function Banner({ ad, size, onCtaClick }) {
|
export function Banner({ ads, onCtaClick }) {
|
||||||
if (!ad) return null;
|
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;
|
useEffect(() => {
|
||||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
if (!api) return;
|
||||||
const resolvedSize = ad.size || size || "md";
|
|
||||||
const heightClass = SIZE_HEIGHT[resolvedSize] ?? SIZE_HEIGHT.md;
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<button
|
<div className="w-full">
|
||||||
type="button"
|
<Carousel setApi={setApi} className="w-full">
|
||||||
onClick={handleClick}
|
<CarouselContent>
|
||||||
className={`relative w-full rounded-lg bg-muted overflow-hidden flex items-center justify-center text-left ${heightClass}`}
|
{ads.map((ad) => {
|
||||||
>
|
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
|
||||||
{imageSrc ? (
|
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||||
<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" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ad.headline && (
|
return (
|
||||||
<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">
|
<CarouselItem key={ad.advertisement_id}>
|
||||||
<p className="text-white font-medium text-sm sm:text-base">{ad.headline}</p>
|
{ad.content_mode !== "content" ? (
|
||||||
</div>
|
// ── Full Image ──
|
||||||
)}
|
// A landing page (Page Builder content) always wins over the CTA
|
||||||
</button>
|
// 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 ───────────────────────────────────────────────────────────
|
// ── BannerSkeleton ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function BannerSkeleton({ size = "md" }) {
|
export function BannerSkeleton() {
|
||||||
return <Skeleton className={`w-full rounded-lg ${SIZE_HEIGHT[size] ?? SIZE_HEIGHT.md}`} />;
|
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 { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext } from "@/components/ui/carousel";
|
import { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext } from "@/components/ui/carousel";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { resolveAssetSrc } from "@/utils/media.util";
|
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 ─────────────────────────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* Hero advertisement carousel.
|
* Hero advertisement carousel.
|
||||||
@@ -25,6 +36,7 @@ export function Hero({ ads, onCtaClick }) {
|
|||||||
const [api, setApi] = useState();
|
const [api, setApi] = useState();
|
||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
const [count, setCount] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
|
const [viewAd, setViewAd] = useState(null); // ad whose Page Builder content is open in the Dialog
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
@@ -66,6 +78,18 @@ export function Hero({ ads, onCtaClick }) {
|
|||||||
{/* Bottom gradient overlay */}
|
{/* Bottom gradient overlay */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent" />
|
<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 */}
|
{/* 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">
|
<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 && (
|
{ad.badge_label && (
|
||||||
@@ -118,6 +142,56 @@ export function Hero({ ads, onCtaClick }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Carousel>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+456
@@ -22,6 +22,8 @@
|
|||||||
@theme inline {
|
@theme inline {
|
||||||
/* From myself */
|
/* From myself */
|
||||||
--font-geist: "Instrument Sans", sans-serif;
|
--font-geist: "Instrument Sans", sans-serif;
|
||||||
|
--font-tier-ads: "Abril Fatface", serif;
|
||||||
|
|
||||||
--breakpoint-xs: 320px;
|
--breakpoint-xs: 320px;
|
||||||
|
|
||||||
/* white fixed */
|
/* white fixed */
|
||||||
@@ -254,4 +256,458 @@
|
|||||||
--primary-foreground: oklch(0.98 0.012 302);
|
--primary-foreground: oklch(0.98 0.012 302);
|
||||||
--ring: oklch(0.63 0.25 302);
|
--ring: oklch(0.63 0.25 302);
|
||||||
--sidebar-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(),
|
end_date: z.string().optional(),
|
||||||
order: z.coerce.number().min(0).default(0),
|
order: z.coerce.number().min(0).default(0),
|
||||||
is_active: z.boolean().default(true),
|
is_active: z.boolean().default(true),
|
||||||
size: z.enum(["sm", "md", "lg", "xl"]).nullable().optional(),
|
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
const format = PLACEMENT_MAP[data.placement]?.format;
|
const format = PLACEMENT_MAP[data.placement]?.format;
|
||||||
if (format === "hero" && (data.description?.length ?? 0) > 200) {
|
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>;
|
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 = [
|
const CTA_VARIANTS = [
|
||||||
{ value: "default", label: "Primary" },
|
{ value: "default", label: "Primary" },
|
||||||
{ value: "outline", label: "Outline" },
|
{ value: "outline", label: "Outline" },
|
||||||
@@ -152,7 +144,7 @@ function Stepper({ steps, stepIndex }) {
|
|||||||
|
|
||||||
// ─── Step 1: Placement ──────────────────────────────────────────────────────
|
// ─── Step 1: Placement ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StepPlacement({ placement, setValue, errors, format, isBanner, watch }) {
|
function StepPlacement({ placement, setValue, errors, format }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
@@ -184,25 +176,6 @@ function StepPlacement({ placement, setValue, errors, format, isBanner, watch })
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -468,7 +441,6 @@ function StepReview({ data, selectedAsset, imageUrl }) {
|
|||||||
{placementMeta && <Badge variant="secondary" className="ml-auto capitalize">{placementMeta.format}</Badge>}
|
{placementMeta && <Badge variant="secondary" className="ml-auto capitalize">{placementMeta.format}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<SummaryRow label="Page" value={placementMeta?.pageLabel} />
|
<SummaryRow label="Page" value={placementMeta?.pageLabel} />
|
||||||
<SummaryRow label="Size" value={data.size} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border rounded-lg p-4 space-y-1">
|
<div className="border rounded-lg p-4 space-y-1">
|
||||||
@@ -567,7 +539,6 @@ export default function AddAdvertisement() {
|
|||||||
end_date: "",
|
end_date: "",
|
||||||
order: 0,
|
order: 0,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
size: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -584,7 +555,6 @@ export default function AddAdvertisement() {
|
|||||||
const description = watch("description");
|
const description = watch("description");
|
||||||
const redirectLink = watch("redirect_link");
|
const redirectLink = watch("redirect_link");
|
||||||
const format = PLACEMENT_MAP[placement]?.format;
|
const format = PLACEMENT_MAP[placement]?.format;
|
||||||
const isBanner = format === "banner";
|
|
||||||
|
|
||||||
const steps = useMemo(
|
const steps = useMemo(
|
||||||
() => ALL_STEPS.filter((s) => !s.skippable || !redirectLink?.trim()),
|
() => 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,
|
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,
|
||||||
size: PLACEMENT_MAP[values.placement]?.format === "banner" ? (values.size || "md") : null,
|
|
||||||
createdBy: user?.user_id ?? null,
|
createdBy: user?.user_id ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -655,8 +624,6 @@ export default function AddAdvertisement() {
|
|||||||
setValue={setValue}
|
setValue={setValue}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
format={format}
|
format={format}
|
||||||
isBanner={isBanner}
|
|
||||||
watch={watch}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{current.id === "content" && (
|
{current.id === "content" && (
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ const schema = z.object({
|
|||||||
end_date: z.string().optional(),
|
end_date: z.string().optional(),
|
||||||
order: z.coerce.number().min(0).default(0),
|
order: z.coerce.number().min(0).default(0),
|
||||||
is_active: z.boolean().default(true),
|
is_active: z.boolean().default(true),
|
||||||
size: z.enum(["sm", "md", "lg", "xl"]).nullable().optional(),
|
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
const format = PLACEMENT_MAP[data.placement]?.format;
|
const format = PLACEMENT_MAP[data.placement]?.format;
|
||||||
if (format === "hero" && (data.description?.length ?? 0) > 200) {
|
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 = [
|
const CTA_VARIANTS = [
|
||||||
{ value: "default", label: "Primary" },
|
{ value: "default", label: "Primary" },
|
||||||
{ value: "outline", label: "Outline" },
|
{ value: "outline", label: "Outline" },
|
||||||
@@ -144,7 +136,6 @@ export default function EditAdvertisement() {
|
|||||||
end_date: "",
|
end_date: "",
|
||||||
order: 0,
|
order: 0,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
size: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,7 +149,6 @@ export default function EditAdvertisement() {
|
|||||||
const contentMode = watch("content_mode");
|
const contentMode = watch("content_mode");
|
||||||
const redirectLink = watch("redirect_link");
|
const redirectLink = watch("redirect_link");
|
||||||
const format = PLACEMENT_MAP[placement]?.format;
|
const format = PLACEMENT_MAP[placement]?.format;
|
||||||
const isBanner = format === "banner";
|
|
||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
@@ -204,7 +194,6 @@ export default function EditAdvertisement() {
|
|||||||
end_date: ad.end_date ?? "",
|
end_date: ad.end_date ?? "",
|
||||||
order: ad.order ?? 0,
|
order: ad.order ?? 0,
|
||||||
is_active: ad.is_active ?? true,
|
is_active: ad.is_active ?? true,
|
||||||
size: ad.size ?? null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setReady(true);
|
setReady(true);
|
||||||
@@ -222,7 +211,6 @@ export default function EditAdvertisement() {
|
|||||||
landing_page: values.redirect_link ? null : values.landing_page,
|
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,
|
||||||
size: PLACEMENT_MAP[values.placement]?.format === "banner" ? (values.size || "md") : null,
|
|
||||||
updatedBy: user?.user_id ?? 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.
|
Format: <span className="font-medium text-foreground capitalize">{format}</span> — determined by the placement above.
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<SectionCard title="Content" description="Full image, or content with badge, headline, description, and CTAs.">
|
<SectionCard title="Content" description="Full image, or content with badge, headline, description, and CTAs.">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import api from "@/utils/api.util";
|
|||||||
import {
|
import {
|
||||||
House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon,
|
House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon,
|
||||||
SendHorizonal, CheckCheck, CheckCircle2, Clock, FileQuestion, ClipboardList,
|
SendHorizonal, CheckCheck, CheckCircle2, Clock, FileQuestion, ClipboardList,
|
||||||
Hourglass, Check,
|
Hourglass, Check, Megaphone
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
||||||
@@ -516,7 +516,7 @@ const CourseDetails = () => {
|
|||||||
const { getCourse, course, courseBlocked, courseLoading, resetCourse } = useClientCourses();
|
const { getCourse, course, courseBlocked, courseLoading, resetCourse } = useClientCourses();
|
||||||
const { myTier, getMyTier } = useClientTiers();
|
const { myTier, getMyTier } = useClientTiers();
|
||||||
const { fetchCourseProgress, fetchCourseProgressSummary, summary: progressSummary, isCompleted, resetProgress } = useCourseReadingProgress();
|
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 [tierMap, setTierMap] = useState({});
|
||||||
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
|
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
|
||||||
@@ -544,12 +544,12 @@ const CourseDetails = () => {
|
|||||||
getCourse(courseId);
|
getCourse(courseId);
|
||||||
fetchCourseProgress(courseId);
|
fetchCourseProgress(courseId);
|
||||||
fetchCourseProgressSummary(courseId);
|
fetchCourseProgressSummary(courseId);
|
||||||
getActiveAdvertisements(["course_details.banner"]);
|
getActiveAdvertisementList("course_details.banner");
|
||||||
return () => { resetCourse(); resetProgress(); setBadgeImageUrl(null); };
|
return () => { resetCourse(); resetProgress(); setBadgeImageUrl(null); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [courseId]);
|
}, [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
|
// Resolve badge image once course loads — issue a client stream token for
|
||||||
// private S3 assets so the badge preview works on this page.
|
// private S3 assets so the badge preview works on this page.
|
||||||
@@ -613,7 +613,24 @@ const CourseDetails = () => {
|
|||||||
<Hourglass className="size-3" /> Coming Soon
|
<Hourglass className="size-3" /> Coming Soon
|
||||||
</Badge>
|
</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`)}>
|
<Button size="sm" className="lg:hidden" onClick={() => navigate(`/course/${courseId}/unit`)}>
|
||||||
{hasCompleted
|
{hasCompleted
|
||||||
? <><CheckCheck /> Start Again</>
|
? <><CheckCheck /> Start Again</>
|
||||||
@@ -626,7 +643,7 @@ const CourseDetails = () => {
|
|||||||
: <><SendHorizonal /> Start Learning</>
|
: <><SendHorizonal /> Start Learning</>
|
||||||
}
|
}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -713,14 +730,7 @@ const CourseDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* 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:px-3 xs:-mt-5 lg:-mt-0">
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ const PREVIEW_COURSE_LIMIT = 2;
|
|||||||
|
|
||||||
const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSecsLeft }) => {
|
const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSecsLeft }) => {
|
||||||
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, rank } = 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" : "";
|
const ring = rank > 0 ? "ring-2 ring-primary/30" : "";
|
||||||
@@ -166,8 +166,8 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
|
|||||||
type="button"
|
type="button"
|
||||||
// onClick={() => setCoursesOpen(true)}
|
// onClick={() => setCoursesOpen(true)}
|
||||||
// DIALOG DISABLED
|
// DIALOG DISABLED
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
||||||
>
|
>
|
||||||
+{extraCount} more course{extraCount !== 1 ? "s" : ""}
|
+{extraCount} more course{extraCount !== 1 ? "s" : ""}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -344,7 +344,7 @@ export default function PlanList() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { plans, plansLoading, myTier, tierLoading, tierMap, getPlans, getMyTier, getTierCategories, resetMyTier } = useClientTiers();
|
const { plans, plansLoading, myTier, tierLoading, tierMap, getPlans, getMyTier, getTierCategories, resetMyTier } = useClientTiers();
|
||||||
const { fmtDate, fmtCurrency } = useDateFormat();
|
const { fmtDate, fmtCurrency } = useDateFormat();
|
||||||
const { advertisements, loading: adLoading, getActiveAdvertisement, handleAdCtaClick } = useClientAdvertisements();
|
const { adLists, listLoading: adLoading, getActiveAdvertisementList, handleAdCtaClick } = useClientAdvertisements();
|
||||||
|
|
||||||
const [view, setView] = useState("grid");
|
const [view, setView] = useState("grid");
|
||||||
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
|
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
|
||||||
@@ -356,11 +356,11 @@ export default function PlanList() {
|
|||||||
getPlans();
|
getPlans();
|
||||||
getMyTier();
|
getMyTier();
|
||||||
getTierCategories();
|
getTierCategories();
|
||||||
getActiveAdvertisement("tier_plans.banner");
|
getActiveAdvertisementList("tier_plans.banner");
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [getPlans, getMyTier]);
|
}, [getPlans, getMyTier]);
|
||||||
|
|
||||||
const bannerAd = advertisements["tier_plans.banner"] ?? null;
|
const bannerAds = adLists["tier_plans.banner"] ?? [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearInterval(refundTimerRef.current);
|
clearInterval(refundTimerRef.current);
|
||||||
@@ -404,68 +404,70 @@ export default function PlanList() {
|
|||||||
<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="bg-muted 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">
|
||||||
|
{/* Advertisement Banner */}
|
||||||
|
{adLoading["tier_plans.banner"] ? (
|
||||||
|
<BannerSkeleton />
|
||||||
|
) : (
|
||||||
|
<Banner ads={bannerAds} onCtaClick={handleAdCtaClick} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Advertisement Banner */}
|
{/* Section Header */}
|
||||||
{adLoading["tier_plans.banner"] ? (
|
<div className="flex flex-col items-center gap-4 mt-6">
|
||||||
<BannerSkeleton />
|
<div className="text-center my-8">
|
||||||
) : (
|
<h2 className="text-3xl font-bold">Available Plans</h2>
|
||||||
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
|
<p className="text-muted-foreground mt-2">
|
||||||
)}
|
Choose a subscription that matches your goals.
|
||||||
|
</p>
|
||||||
{/* Section Header */}
|
</div>
|
||||||
<div className="flex flex-col items-center gap-4 mt-6">
|
{!plansLoading && plans.length > 0 && (
|
||||||
<div className="text-center">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-3xl font-bold">Available Plans</h2>
|
<Button size="sm" variant={view === "grid" ? "default" : "outline"} onClick={() => setView("grid")}>
|
||||||
<p className="text-muted-foreground mt-2">
|
<LaptopMinimal /> Cards
|
||||||
Choose a subscription that matches your goals.
|
</Button>
|
||||||
</p>
|
<Button size="sm" variant={view === "table" ? "default" : "outline"} onClick={() => setView("table")}>
|
||||||
|
<TableIcon /> Compare
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!plansLoading && plans.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2">
|
{/* Plan Cards / Comparison Table */}
|
||||||
<Button size="sm" variant={view === "grid" ? "default" : "outline"} onClick={() => setView("grid")}>
|
{plansLoading || tierLoading ? (
|
||||||
<LaptopMinimal /> Cards
|
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||||
</Button>
|
{Array.from({ length: 3 }).map((_, i) => <PlanSkeleton key={i} />)}
|
||||||
<Button size="sm" variant={view === "table" ? "default" : "outline"} onClick={() => setView("table")}>
|
</div>
|
||||||
<TableIcon /> Compare
|
) : plans.length === 0 ? (
|
||||||
</Button>
|
<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>
|
|
||||||
|
|
||||||
{/* Plan Cards / Comparison Table */}
|
</div>
|
||||||
{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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user