From 04c4a3e1f673f25ed98f076828faf4ee22c6c94b Mon Sep 17 00:00:00 2001 From: rgrgogu Date: Wed, 29 Jul 2026 03:25:31 +0800 Subject: [PATCH] changes to advertisements --- index.html | 5 +- .../Blocks/Client/Advertisements/Banner.jsx | 223 +++++++-- .../Blocks/Client/Advertisements/Hero.jsx | 74 +++ src/index.css | 456 ++++++++++++++++++ .../pages/advertisements/AddAdvertisement.jsx | 35 +- .../advertisements/EditAdvertisement.jsx | 31 -- src/modules/client/pages/CourseDetails.jsx | 38 +- src/modules/client/pages/PlanList.jsx | 130 ++--- 8 files changed, 806 insertions(+), 186 deletions(-) diff --git a/index.html b/index.html index f4b2ba2..42e235c 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,10 @@ - + %VITE_APP_NAME% diff --git a/src/components/generic/Blocks/Client/Advertisements/Banner.jsx b/src/components/generic/Blocks/Client/Advertisements/Banner.jsx index 112fd94..82faf25 100644 --- a/src/components/generic/Blocks/Client/Advertisements/Banner.jsx +++ b/src/components/generic/Blocks/Client/Advertisements/Banner.jsx @@ -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 ( - + return ( + + {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. + + ) : ( + // ── Content + Image ── +
+
+
+ {ad.badge_label && ( + + {ad.badge_label} + + )} + {ad.headline &&
{ad.headline}
} + {ad.description &&

{ad.description}

} + + {ctas.length > 0 && ( +
+ {ctas.map((cta, i) => ( + + ))} +
+ )} +
+ +
+ +
+
+
+ )} +
+ ); + })} + + + {/* Navigation and Progress — only meaningful with more than one slide */} + {ads.length > 1 && ( +
+
+ + +
+
+ +
+
+ )} + + + {/* ── Page Builder content Dialog ─────────────────────────────────── */} + !open && setViewAd(null)}> + + {viewAd && (() => { + const page = viewAd.landing_page ?? {}; + const imageSrc = resolveAssetSrc(viewAd.image) || viewAd.image_url || null; + const links = Array.isArray(page.links) ? page.links : []; + + return ( + <> + + {page.title || viewAd.headline || "Advertisement"} + {page.description && {page.description}} + + + {imageSrc && ( +
+ {page.title +
+ )} + + {page.body && ( +
+ {page.body} +
+ )} + + {links.length > 0 && ( +
+ {links.map((l, i) => ( + + ))} +
+ )} + + ); + })()} +
+
+ ); } // ── BannerSkeleton ─────────────────────────────────────────────────────────── -export function BannerSkeleton({ size = "md" }) { - return ; -} \ No newline at end of file +export function BannerSkeleton() { + return ; +} diff --git a/src/components/generic/Blocks/Client/Advertisements/Hero.jsx b/src/components/generic/Blocks/Client/Advertisements/Hero.jsx index 270c81d..483ce1a 100644 --- a/src/components/generic/Blocks/Client/Advertisements/Hero.jsx +++ b/src/components/generic/Blocks/Client/Advertisements/Hero.jsx @@ -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 */}
+ {hasLandingPage(ad) && ( +
+ +
+ )} + {/* Bottom-left content */}
{ad.badge_label && ( @@ -118,6 +142,56 @@ export function Hero({ ads, onCtaClick }) {
)} + + {/* ── Page Builder content Dialog ─────────────────────────────────── */} + !open && setViewAd(null)}> + + {viewAd && (() => { + const page = viewAd.landing_page ?? {}; + const imageSrc = resolveAssetSrc(viewAd.image) || viewAd.image_url || null; + const links = Array.isArray(page.links) ? page.links : []; + + return ( + <> + + {page.title || viewAd.headline || "Advertisement"} + {page.description && {page.description}} + + + {imageSrc && ( +
+ {page.title +
+ )} + + {page.body && ( +
+ {page.body} +
+ )} + + {links.length > 0 && ( +
+ {links.map((l, i) => ( + + ))} +
+ )} + + ); + })()} +
+
); } diff --git a/src/index.css b/src/index.css index 8a1633d..afec459 100644 --- a/src/index.css +++ b/src/index.css @@ -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 */ @@ -254,4 +256,458 @@ --primary-foreground: oklch(0.98 0.012 302); --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: ''; + inherits: false; + initial-value: 93% +} + +@property --y-0 { + syntax: ''; + inherits: false; + initial-value: 93% +} + +@property --s-start-0 { + syntax: ''; + inherits: false; + initial-value: 14.489998991212337% +} + +@property --s-end-0 { + syntax: ''; + inherits: false; + initial-value: 72% +} + +@property --c-0 { + syntax: ''; + inherits: false; + initial-value: hsla(212, 0%, 0%, 1) +} + +@property --y-1 { + syntax: ''; + inherits: false; + initial-value: 9% +} + +@property --x-1 { + syntax: ''; + inherits: false; + initial-value: 26% +} + +@property --s-start-1 { + syntax: ''; + inherits: false; + initial-value: 0% +} + +@property --s-end-1 { + syntax: ''; + inherits: false; + initial-value: 45% +} + +@property --c-1 { + syntax: ''; + inherits: false; + initial-value: hsla(212, 0%, 0%, 1) +} + +@property --s-start-2 { + syntax: ''; + inherits: false; + initial-value: 2.9253667596993065% +} + +@property --s-end-2 { + syntax: ''; + inherits: false; + initial-value: 22.388851682060018% +} + +@property --c-2 { + syntax: ''; + inherits: false; + initial-value: hsla(257, 91%, 27%, 0.35) +} + +@property --x-2 { + syntax: ''; + inherits: false; + initial-value: 15% +} + +@property --y-2 { + syntax: ''; + inherits: false; + initial-value: 79% +} + +@property --s-start-3 { + syntax: ''; + inherits: false; + initial-value: 3.985353824694249% +} + +@property --s-end-3 { + syntax: ''; + inherits: false; + initial-value: 47.580278608924694% +} + +@property --c-3 { + syntax: ''; + inherits: false; + initial-value: hsla(212, 100%, 50%, 0.5) +} + +@property --y-3 { + syntax: ''; + inherits: false; + initial-value: 104% +} + +@property --x-3 { + syntax: ''; + inherits: false; + initial-value: 40% +} + +@property --x-4 { + syntax: ''; + inherits: false; + initial-value: 0% +} + +@property --y-4 { + syntax: ''; + inherits: false; + initial-value: 60% +} + +@property --s-start-4 { + syntax: ''; + inherits: false; + initial-value: 2.391200382592061% +} + +@property --s-end-4 { + syntax: ''; + inherits: false; + initial-value: 29.307684556768592% +} + +@property --c-4 { + syntax: ''; + inherits: false; + initial-value: hsla(224, 72%, 36%, 1) +} + +@property --s-start-5 { + syntax: ''; + inherits: false; + initial-value: 2.9253667596993065% +} + +@property --s-end-5 { + syntax: ''; + inherits: false; + initial-value: 22.388851682060018% +} + +@property --c-5 { + syntax: ''; + inherits: false; + initial-value: hsla(248, 52%, 24%, 1) +} + +@property --y-5 { + syntax: ''; + inherits: false; + initial-value: 37% +} + +@property --x-5 { + syntax: ''; + inherits: false; + initial-value: 92% +} + +@property --s-start-6 { + syntax: ''; + inherits: false; + initial-value: 13.173642363290591% +} + +@property --s-end-6 { + syntax: ''; + inherits: false; + initial-value: 31.747336520355095% +} + +@property --c-6 { + syntax: ''; + inherits: false; + initial-value: hsla(212, 100%, 50%, 0.19) +} + +@property --y-6 { + syntax: ''; + inherits: false; + initial-value: 16% +} + +@property --x-6 { + syntax: ''; + inherits: false; + initial-value: 101% +} + +@property --y-7 { + syntax: ''; + inherits: false; + initial-value: 13% +} + +@property --x-7 { + syntax: ''; + inherits: false; + initial-value: 90% +} + +@property --s-start-7 { + syntax: ''; + inherits: false; + initial-value: 1% +} + +@property --s-end-7 { + syntax: ''; + inherits: false; + initial-value: 31% +} + +@property --c-7 { + syntax: ''; + inherits: false; + initial-value: hsla(227, 98%, 53%, 1) +} + +@property --s-start-8 { + syntax: ''; + inherits: false; + initial-value: 3.985353824694249% +} + +@property --s-end-8 { + syntax: ''; + inherits: false; + initial-value: 13.103042116379756% +} + +@property --c-8 { + syntax: ''; + inherits: false; + initial-value: hsla(166, 71%, 60%, 0.32) +} + +@property --x-8 { + syntax: ''; + inherits: false; + initial-value: 104% +} + +@property --y-8 { + syntax: ''; + inherits: false; + initial-value: 56% +} + +@property --x-9 { + syntax: ''; + inherits: false; + initial-value: 97% +} + +@property --y-9 { + syntax: ''; + inherits: false; + initial-value: 19% +} + +@property --s-start-9 { + syntax: ''; + inherits: false; + initial-value: 18.597054544690312% +} + +@property --s-end-9 { + syntax: ''; + inherits: false; + initial-value: 31% +} + +@property --c-9 { + syntax: ''; + inherits: false; + initial-value: hsla(219, 83%, 23%, 0.18) } \ No newline at end of file diff --git a/src/modules/admin/pages/advertisements/AddAdvertisement.jsx b/src/modules/admin/pages/advertisements/AddAdvertisement.jsx index 1c1fb95..5cd6aa9 100644 --- a/src/modules/admin/pages/advertisements/AddAdvertisement.jsx +++ b/src/modules/admin/pages/advertisements/AddAdvertisement.jsx @@ -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

{message}

; } -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 (
@@ -184,25 +176,6 @@ function StepPlacement({ placement, setValue, errors, format, isBanner, watch })

)} - - {isBanner && ( -
- - -

- Controls the banner's height. Width always stretches full-width. -

-
- )}
); } @@ -468,7 +441,6 @@ function StepReview({ data, selectedAsset, imageUrl }) { {placementMeta && {placementMeta.format}} -
@@ -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" && ( diff --git a/src/modules/admin/pages/advertisements/EditAdvertisement.jsx b/src/modules/admin/pages/advertisements/EditAdvertisement.jsx index 8fde34c..beee98c 100644 --- a/src/modules/admin/pages/advertisements/EditAdvertisement.jsx +++ b/src/modules/admin/pages/advertisements/EditAdvertisement.jsx @@ -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: , 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: {format} — determined by the placement above.

)} - - {isBanner && ( -
- - -

- Controls the banner's height. Width always stretches full-width. -

-
- )} diff --git a/src/modules/client/pages/CourseDetails.jsx b/src/modules/client/pages/CourseDetails.jsx index dfecf73..941478e 100644 --- a/src/modules/client/pages/CourseDetails.jsx +++ b/src/modules/client/pages/CourseDetails.jsx @@ -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 = () => { Coming Soon ) : ( - <> +
+ + + + + + + Advertisements + + {adLoading["course_details.banner"] ? ( + + ) : ( + + )} + + - +
)}
@@ -713,14 +730,7 @@ const CourseDetails = () => { - {/* Advertisement Banner */} -
- {adLoading["course_details.banner"] ? ( - - ) : ( - - )} -
+ {/* Body */}
diff --git a/src/modules/client/pages/PlanList.jsx b/src/modules/client/pages/PlanList.jsx index fc7e314..b4cf78f 100644 --- a/src/modules/client/pages/PlanList.jsx +++ b/src/modules/client/pages/PlanList.jsx @@ -84,8 +84,8 @@ const PREVIEW_COURSE_LIMIT = 2; const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSecsLeft }) => { const { fmtCurrency } = useDateFormat(); - const [coursesOpen, setCoursesOpen] = useState(false); - const [notAvailableOpen, setNotAvailableOpen] = useState(false); + const [coursesOpen, setCoursesOpen] = useState(false); + const [notAvailableOpen, setNotAvailableOpen] = useState(false); const { label: tierLabel, cls: badgeCls, rank } = resolveTierBadge(plan.tier, tierMap); const Icon = LucideIcons[tierMap[plan.tier]?.badge_icon] ?? Tag; const ring = rank > 0 ? "ring-2 ring-primary/30" : ""; @@ -166,8 +166,8 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec type="button" // onClick={() => setCoursesOpen(true)} // DIALOG DISABLED - variant="secondary" - + variant="secondary" + > +{extraCount} more course{extraCount !== 1 ? "s" : ""} @@ -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,68 +404,70 @@ export default function PlanList() {
+
+ {/* Advertisement Banner */} + {adLoading["tier_plans.banner"] ? ( + + ) : ( + + )} - {/* Advertisement Banner */} - {adLoading["tier_plans.banner"] ? ( - - ) : ( - - )} - - {/* Section Header */} -
-
-

Available Plans

-

- Choose a subscription that matches your goals. -

+ {/* Section Header */} +
+
+

Available Plans

+

+ Choose a subscription that matches your goals. +

+
+ {!plansLoading && plans.length > 0 && ( +
+ + +
+ )}
- {!plansLoading && plans.length > 0 && ( -
- - + + {/* Plan Cards / Comparison Table */} + {plansLoading || tierLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => )} +
+ ) : plans.length === 0 ? ( +
+ +

No plans available at the moment.

+
+ ) : view === "table" ? ( + + ) : ( +
+ {plans.map((plan) => ( + + ))}
)} -
- {/* Plan Cards / Comparison Table */} - {plansLoading || tierLoading ? ( -
- {Array.from({ length: 3 }).map((_, i) => )} -
- ) : plans.length === 0 ? ( -
- -

No plans available at the moment.

-
- ) : view === "table" ? ( - - ) : ( -
- {plans.map((plan) => ( - - ))} -
- )} +