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" />
|
||||
<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 (
|
||||
<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}>
|
||||
{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={handleClick}
|
||||
className={`relative w-full rounded-lg bg-muted overflow-hidden flex items-center justify-center text-left ${heightClass}`}
|
||||
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" />
|
||||
<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>}
|
||||
|
||||
{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>
|
||||
{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>
|
||||
)}
|
||||
</button>
|
||||
</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
@@ -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.">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,17 +404,17 @@ 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 ad={bannerAd} onCtaClick={handleAdCtaClick} />
|
||||
<Banner ads={bannerAds} onCtaClick={handleAdCtaClick} />
|
||||
)}
|
||||
|
||||
{/* Section Header */}
|
||||
<div className="flex flex-col items-center gap-4 mt-6">
|
||||
<div className="text-center">
|
||||
<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.
|
||||
@@ -468,6 +468,8 @@ export default function PlanList() {
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Refund Confirmation Modal ──────────────────────────────────── */}
|
||||
|
||||
Reference in New Issue
Block a user