diff --git a/src/components/generic/AdminStickyAnnouncementBar.jsx b/src/components/generic/AdminStickyAnnouncementBar.jsx index b90906d..e021454 100644 --- a/src/components/generic/AdminStickyAnnouncementBar.jsx +++ b/src/components/generic/AdminStickyAnnouncementBar.jsx @@ -1,15 +1,12 @@ -import { useCallback, useState } from "react"; import { X } from "lucide-react"; import { useNavigate } from "react-router-dom"; -import { Button } from "@/components/ui/button"; import { useAdminNotifications } from "@/contexts/AdminNotificationContext"; import { getTierColor, getContrastText } from "@/utils/tierColors"; import { goToLink } from "@/components/generic/notificationDisplay"; -import AnnouncementDetailsDialog from "@/components/generic/AnnouncementDetailsDialog"; // Admin counterpart to StickyAnnouncementBar (client). Only supports the -// explicit link_url from the "On Open" section — the type-based fallback -// resolver in notificationDisplay.jsx points at client-only routes +// explicit link_url from the composer's "Link" section — the type-based +// fallback resolver in notificationDisplay.jsx points at client-only routes // (/course/:id, /plans, /group/:id), which don't exist in the admin app. function resolveClickAction(stickyAnnouncement) { const linkUrl = stickyAnnouncement.data?.linkUrl || null; @@ -20,92 +17,70 @@ function resolveClickAction(stickyAnnouncement) { }; } -export default function AdminStickyAnnouncementBar() { +// Up to 2 sticky alerts shown at once, stacked — no dialog on click anymore; +// the centered title text itself is the click target and redirects straight +// to the alert's link (if any). +const MAX_VISIBLE_STICKY = 2; + +function AdminStickyAnnouncementRow({ announcement, onDismiss }) { const navigate = useNavigate(); - const { stickyAnnouncements, markSeen } = useAdminNotifications(); - const [detailsOpen, setDetailsOpen] = useState(false); - - // Only one sticky alert shows at a time — no rotation/autoplay. Dismissing - // it (X on the bar) reveals whichever is next in the queue. - const current = stickyAnnouncements[0]; - - const onDismiss = useCallback(async () => { - if (!current) return; - await markSeen(current.notification_id); - }, [current, markSeen]); - - const onClickBanner = useCallback(() => { - if (!current) return; - setDetailsOpen(true); - }, [current]); - - if (!current) return null; - - const swatch = getTierColor(current.color || "indigo").swatch; - const textColor = getContrastText(swatch, current.color || "indigo"); - const clickAction = resolveClickAction(current); + const swatch = getTierColor(announcement.color || "indigo").swatch; + const textColor = getContrastText(swatch, announcement.color || "indigo"); + const clickAction = resolveClickAction(announcement); return ( - <> -
-
+
+

clickAction.go(navigate) : undefined} > -

-

- {current.title || "Announcement"} -

+ {announcement.title || "Announcement"} +

- {clickAction && ( - - )} -
- - {/* Only way to dismiss a sticky alert — closing the details dialog - no longer dismisses it. */} -
{ - e.preventDefault(); - e.stopPropagation(); - void onDismiss(); - }} - onKeyDown={(e) => { - if (e.key !== "Enter" && e.key !== " ") return; - e.preventDefault(); - e.stopPropagation(); - void onDismiss(); - }} - aria-label="Dismiss sticky announcement" - title="Dismiss" - className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity" - style={{ color: textColor }} - > - -
+ {/* Only way to dismiss a sticky alert. */} +
{ + e.preventDefault(); + e.stopPropagation(); + void onDismiss(); + }} + onKeyDown={(e) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + e.stopPropagation(); + void onDismiss(); + }} + aria-label="Dismiss sticky announcement" + title="Dismiss" + className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity" + style={{ color: textColor }} + > +
+
+ ); +} - - +export default function AdminStickyAnnouncementBar() { + const { stickyAnnouncements, markSeen } = useAdminNotifications(); + + const visible = stickyAnnouncements.slice(0, MAX_VISIBLE_STICKY); + if (visible.length === 0) return null; + + return ( +
+ {visible.map((announcement) => ( + markSeen(announcement.notification_id)} + /> + ))} +
); } diff --git a/src/components/generic/AnnouncementDetailsDialog.jsx b/src/components/generic/AnnouncementDetailsDialog.jsx deleted file mode 100644 index ed0b41f..0000000 --- a/src/components/generic/AnnouncementDetailsDialog.jsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { resolveAssetSrc } from "@/utils/media.util"; - -// Shared details view for the sticky announcement bar (client + admin -// variants). Only one sticky alert is ever shown at a time — no carousel, -// no autoplay. Optional per-alert layout image renders alongside the text -// when present, single column when not. -export default function AnnouncementDetailsDialog({ - open, - onOpenChange, - announcement, - resolveClickAction, - navigate, -}) { - if (!announcement) return null; - - const clickAction = resolveClickAction(announcement); - const imageSrc = announcement.image ? resolveAssetSrc(announcement.image) : null; - - return ( - - -
-
- - - {announcement.title || "Alert"} - - -

- {announcement.message || ""} -

-
-
- - {clickAction && ( -
- -
- )} -
- - {imageSrc && ( -
- -
- )} -
-
-
- ); -} diff --git a/src/components/generic/Blocks/Client/Advertisements/Banner.jsx b/src/components/generic/Blocks/Client/Advertisements/Banner.jsx index 82faf25..d04c172 100644 --- a/src/components/generic/Blocks/Client/Advertisements/Banner.jsx +++ b/src/components/generic/Blocks/Client/Advertisements/Banner.jsx @@ -30,7 +30,7 @@ function hasLandingPage(ad) { * "image" — full-bleed image, clickable through to the ad's redirect/CTA * * Props: - * ads — array of advertisement objects { content_mode, badge_label, headline, description, ctas, image, image_url, advertisement_id } + * ads — array of advertisement objects { content_mode, badge_labels, 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({ ads, onCtaClick }) { @@ -61,6 +61,7 @@ export function Banner({ ads, onCtaClick }) { {ads.map((ad) => { const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null; const ctas = Array.isArray(ad.ctas) ? ad.ctas : []; + const badgeLabels = Array.isArray(ad.badge_labels) ? ad.badge_labels : []; return ( @@ -88,10 +89,14 @@ export function Banner({ ads, onCtaClick }) {
- {ad.badge_label && ( - - {ad.badge_label} - + {badgeLabels.length > 0 && ( +
+ {badgeLabels.map((label, i) => ( + + {label} + + ))} +
)} {ad.headline &&
{ad.headline}
} {ad.description &&

{ad.description}

} diff --git a/src/components/generic/Blocks/Client/Advertisements/Hero.jsx b/src/components/generic/Blocks/Client/Advertisements/Hero.jsx index 2780d2b..f5bf958 100644 --- a/src/components/generic/Blocks/Client/Advertisements/Hero.jsx +++ b/src/components/generic/Blocks/Client/Advertisements/Hero.jsx @@ -29,7 +29,7 @@ function hasLandingPage(ad) { * ads are given — callers should not fall back to placeholder copy. * * Props: - * ads — array of advertisement objects { badge_label, headline, description, ctas, image, image_url, advertisement_id } + * ads — array of advertisement objects { badge_labels, headline, description, ctas, image, image_url, advertisement_id } * onCtaClick — (ad, cta) => void, called when any CTA button is clicked */ export function Hero({ ads, onCtaClick }) { @@ -60,6 +60,7 @@ export function Hero({ ads, onCtaClick }) { {ads.map((ad) => { const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null; const ctas = Array.isArray(ad.ctas) ? ad.ctas : []; + const badgeLabels = Array.isArray(ad.badge_labels) ? ad.badge_labels : []; return ( @@ -92,10 +93,14 @@ export function Hero({ ads, onCtaClick }) { {/* Bottom-left content */}
- {ad.badge_label && ( - - {ad.badge_label} - + {badgeLabels.length > 0 && ( +
+ {badgeLabels.map((label, i) => ( + + {label} + + ))} +
)} {ad.headline && (
diff --git a/src/components/generic/StickyAnnouncementBar.jsx b/src/components/generic/StickyAnnouncementBar.jsx index 235ac37..4eb0521 100644 --- a/src/components/generic/StickyAnnouncementBar.jsx +++ b/src/components/generic/StickyAnnouncementBar.jsx @@ -1,105 +1,77 @@ -import { useCallback, useState } from "react"; import { X } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { useNavigate } from "react-router-dom"; import { useClientNotifications } from "@/contexts/ClientNotificationContext"; import { resolveNotificationLink } from "@/components/generic/notificationDisplay"; import { getTierColor, getContrastText } from "@/utils/tierColors"; -import AnnouncementDetailsDialog from "@/components/generic/AnnouncementDetailsDialog"; -// resolveNotificationLink already gives explicit link_url (the admin "On -// Open" section) precedence over the type-based fallbacks, for broadcasts -// sent before that field existed. +// Up to 2 sticky alerts shown at once, stacked — no dialog on click anymore; +// the centered title text itself is the click target and redirects straight +// to the alert's link (if any). +const MAX_VISIBLE_STICKY = 2; + function resolveClickAction(stickyAnnouncement) { return resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data); } -export default function StickyAnnouncementBar() { +function StickyAnnouncementRow({ announcement, onDismiss }) { const navigate = useNavigate(); - const { stickyAnnouncements, markSeen } = useClientNotifications(); - const [detailsOpen, setDetailsOpen] = useState(false); - - // Only one sticky alert shows at a time — no rotation/autoplay. Dismissing - // it (X on the bar) reveals whichever is next in the queue. - const current = stickyAnnouncements[0]; - - const onDismiss = useCallback(async () => { - if (!current) return; - await markSeen(current.notification_id); - }, [current, markSeen]); - - const onClickBanner = useCallback(() => { - if (!current) return; - setDetailsOpen(true); - }, [current]); - - if (!current) return null; - - const swatch = getTierColor(current.color || "indigo").swatch; - const textColor = getContrastText(swatch, current.color || "indigo"); - const clickAction = resolveClickAction(current); + const swatch = getTierColor(announcement.color || "indigo").swatch; + const textColor = getContrastText(swatch, announcement.color || "indigo"); + const clickAction = resolveClickAction(announcement); return ( - <> -
-
+
+

clickAction.go(navigate) : undefined} > -

-

- {current.title || "Announcement"} -

+ {announcement.title || "Announcement"} +

- {clickAction && ( - - )} -
- - {/* Only way to dismiss a sticky alert — closing the details dialog - no longer dismisses it. */} -
{ - e.preventDefault(); - e.stopPropagation(); - void onDismiss(); - }} - onKeyDown={(e) => { - if (e.key !== "Enter" && e.key !== " ") return; - e.preventDefault(); - e.stopPropagation(); - void onDismiss(); - }} - aria-label="Dismiss sticky announcement" - title="Dismiss" - className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity" - style={{ color: textColor }} - > - -
+ {/* Only way to dismiss a sticky alert. */} +
{ + e.preventDefault(); + e.stopPropagation(); + void onDismiss(); + }} + onKeyDown={(e) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + e.stopPropagation(); + void onDismiss(); + }} + aria-label="Dismiss sticky announcement" + title="Dismiss" + className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity" + style={{ color: textColor }} + > +
+
+ ); +} - - +export default function StickyAnnouncementBar() { + const { stickyAnnouncements, markSeen } = useClientNotifications(); + + const visible = stickyAnnouncements.slice(0, MAX_VISIBLE_STICKY); + if (visible.length === 0) return null; + + return ( +
+ {visible.map((announcement) => ( + markSeen(announcement.notification_id)} + /> + ))} +
); } diff --git a/src/data/advertisement.data.js b/src/data/advertisement.data.js index 3ceab27..8b2183c 100644 --- a/src/data/advertisement.data.js +++ b/src/data/advertisement.data.js @@ -19,8 +19,8 @@ export const ADVERTISEMENT_TYPE_MAP = Object.fromEntries( // Whether an ad is image-only or carries badge/headline/description/CTAs // alongside the image — an explicit admin choice, decoupled from placement/format. export const CONTENT_MODES = [ - { value: "image", label: "Full image" }, - { value: "content", label: "Content + image" }, + { value: "image", label: "Image Only" }, + { value: "content", label: "Text with Image" }, ]; // ─── Statuses ─────────────────────────────────────────────────────────────── @@ -46,4 +46,7 @@ export const ADVERTISEMENT_FILTERABLE_STATUSES = ADVERTISEMENT_STATUSES.filter( ); // Max number of CTAs allowed per advertisement (matches backend normalizeCtas slice(0,2)) -export const MAX_CTAS = 2; \ No newline at end of file +export const MAX_CTAS = 2; + +// Max number of badge label chips allowed per advertisement (matches backend normalizeBadgeLabels slice(0,2)) +export const MAX_BADGE_LABELS = 2; \ No newline at end of file diff --git a/src/modules/admin/components/advertisements/ArchivedAdvertisementsTable.jsx b/src/modules/admin/components/advertisements/ArchivedAdvertisementsTable.jsx index 4d9ff33..68d5c2f 100644 --- a/src/modules/admin/components/advertisements/ArchivedAdvertisementsTable.jsx +++ b/src/modules/admin/components/advertisements/ArchivedAdvertisementsTable.jsx @@ -156,7 +156,7 @@ export default function ArchivedAdvertisementsTable() { onOpenChange={(v) => !v && setRestoreTarget(null)} entity={restoreTarget} entityLabel="Ad" - getName={(a) => a?.headline ?? a?.badge_label ?? "this ad"} + getName={(a) => a?.headline ?? a?.badge_labels?.[0] ?? "this ad"} onRestore={(a) => restoreAdvertisement(a?.advertisement_id)} loading={loading} onSuccess={handleRestoreSuccess} @@ -179,7 +179,7 @@ export default function ArchivedAdvertisementsTable() { onOpenChange={(v) => !v && setDeleteTarget(null)} entity={deleteTarget} entityLabel="Ad" - getName={(a) => a?.headline ?? a?.badge_label ?? "this ad"} + getName={(a) => a?.headline ?? a?.badge_labels?.[0] ?? "this ad"} onDelete={(a) => permanentlyDeleteAdvertisement(a?.advertisement_id)} loading={loading} onSuccess={handleDeleteSuccess} diff --git a/src/modules/admin/components/notifications/AlertLayoutPreview.jsx b/src/modules/admin/components/notifications/AlertLayoutPreview.jsx deleted file mode 100644 index 34233ea..0000000 --- a/src/modules/admin/components/notifications/AlertLayoutPreview.jsx +++ /dev/null @@ -1,51 +0,0 @@ -import { resolveAssetSrc } from "@/utils/media.util"; -import { Button } from "@/components/ui/button"; -import { XIcon } from "lucide-react"; - -// Mirrors components/generic/AnnouncementDetailsDialog.jsx's real markup/classes -// (text left, optional image right, single column when no image) so what's -// shown here while composing an alert is what recipients actually see when -// they open it from the sticky banner. Plain divs, not an actual Dialog — -// this renders inline inside the admin wizard, not as an overlay. -export default function AlertLayoutPreview({ title, message, imageAsset, linkLabel }) { - const imageSrc = imageAsset ? resolveAssetSrc(imageAsset) : null; - - return ( -
- - -
-
-
-

{title || "Alert"}

-

- {message || "Your message will appear here."} -

-
- - {linkLabel && ( -
- -
- )} -
- - {imageSrc && ( -
- -
- )} -
-
- ); -} diff --git a/src/modules/admin/pages/advertisements/AddAdvertisement.jsx b/src/modules/admin/pages/advertisements/AddAdvertisement.jsx index 509ae1c..e75304a 100644 --- a/src/modules/admin/pages/advertisements/AddAdvertisement.jsx +++ b/src/modules/admin/pages/advertisements/AddAdvertisement.jsx @@ -7,7 +7,7 @@ import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { House, Plus, Trash2, ImagePlus, MapPin, FileText, - LayoutTemplate, CalendarClock, Check, ChevronLeft, ChevronRight, + CalendarClock, Check, ChevronLeft, ChevronRight, } from "lucide-react"; import { useAdvertisements } from "@/contexts/AdminAdvertisementContext"; @@ -15,6 +15,7 @@ import { useAuth } from "@/contexts/AuthContext"; import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; import { useDateFormat } from "@/hooks/useDateFormat"; import { resolveAssetSrc } from "@/utils/media.util"; +import { isValidLink, LINK_ERROR } from "@/utils/link.util"; import { cn } from "@/lib/utils"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import { Button } from "@/components/ui/button"; @@ -30,7 +31,7 @@ import { DateTimePicker } from "@/components/ui/date-time-picker"; import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet"; import { PlacementSkeleton } from "@/components/generic/PlacementSkeleton"; -import { MAX_CTAS, CONTENT_MODES } from "@/data/advertisement.data"; +import { MAX_CTAS, MAX_BADGE_LABELS, CONTENT_MODES } from "@/data/advertisement.data"; import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data"; // ─── Schema ───────────────────────────────────────────────────────────────── @@ -38,14 +39,14 @@ import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data"; const schema = z.object({ placement: z.string().min(1, "Placement is required."), content_mode: z.enum(["image", "content"]).default("image"), - badge_label: z.string().optional(), + badge_labels: z.array(z.string().min(1)).max(MAX_BADGE_LABELS, `A maximum of ${MAX_BADGE_LABELS} badges is allowed.`).default([]), headline: z.string().optional(), - description: z.string().optional(), + description: z.string().max(100, "Description must be 100 characters or fewer.").optional(), image_asset_id: z.union([z.string(), z.number()]).nullable().optional(), // Hard cap of 2 CTAs per advertisement (max() backs up the UI-level append guard) ctas: z.array(z.object({ label: z.string().min(1, "Label is required."), - link: z.string().min(1, "Link is required."), + link: z.string().min(1, "Link is required.").refine(isValidLink, LINK_ERROR), variant: z.enum(["default", "outline"]).default("default"), })).max(MAX_CTAS, `A maximum of ${MAX_CTAS} buttons is allowed.`).default([]), redirect_link: z.string().optional(), @@ -62,16 +63,14 @@ const schema = z.object({ end_date: z.string().optional(), is_active: z.boolean().default(true), }).superRefine((data, ctx) => { - const format = PLACEMENT_MAP[data.placement]?.format; - if (format === "hero" && (data.description?.length ?? 0) > 200) { - ctx.addIssue({ - code: z.ZodIssueCode.too_big, - maximum: 200, - type: "string", - inclusive: true, - message: "Description must be 200 characters or fewer for hero placements.", - path: ["description"], - }); + // Image Only ads have no other click-through — the Link is their only + // destination, so it's required (Text with Image ads click through via + // their own CTA links instead, see redirect_link comment above). + if (data.content_mode !== "image") return; + if (!data.redirect_link?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["redirect_link"], message: "Link is required." }); + } else if (!isValidLink(data.redirect_link)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["redirect_link"], message: LINK_ERROR }); } }); @@ -81,8 +80,8 @@ const schema = z.object({ // page) for ads that don't link straight out to a URL. const ALL_STEPS = [ { id: "placement", label: "Placement", icon: MapPin, description: "Choose where this ad appears — Dashboard, Tier Plans, or Course Details." }, - { id: "content", label: "Content", icon: FileText, description: "Full image, or content with badge, headline, description, and CTAs." }, - { id: "pageBuilder", label: "Page Builder", icon: LayoutTemplate, description: "No redirect link? Build an internal landing page for this ad instead.", skippable: true }, + { id: "content", label: "Type", icon: FileText, description: "Image Only, or Text with Image with badges, headline, description, and links." }, + // { id: "pageBuilder", label: "Page Builder", icon: LayoutTemplate, description: "No redirect link? Build an internal landing page for this ad instead.", skippable: true }, { id: "scheduling", label: "Scheduling & Display", icon: CalendarClock, description: "Start/end dates and draft/active status." }, { id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this ad." }, ]; @@ -94,11 +93,6 @@ function FieldError({ message }) { return

{message}

; } -const CTA_VARIANTS = [ - { value: "default", label: "Primary" }, - { value: "outline", label: "Outline" }, -]; - // ─── Stepper header ───────────────────────────────────────────────────────── function Stepper({ steps, stepIndex }) { @@ -109,9 +103,13 @@ function Stepper({ steps, stepIndex }) { const isActive = stepIndex === i; const isDone = stepIndex > i; + // "contents" drops this wrapper out of layout so the node and its + // trailing line become direct siblings in the outer flex row — that + // way every line shares the leftover space equally (flex-1), instead + // of being sized off its own step's (possibly short) label width. return ( -
-
+
+
setValue("badge_labels", [...badgeLabelFields, ""], { shouldValidate: true, shouldDirty: true }); + const removeBadgeLabel = (index) => setValue("badge_labels", badgeLabelFields.filter((_, i) => i !== index), { shouldValidate: true, shouldDirty: true }); return (
-
+
{CONTENT_MODES.map((m) => ( ))} @@ -257,8 +263,28 @@ function StepContent({ {contentMode === "content" && (
- - + +
+ {badgeLabelFields.map((_, index) => ( +
+
+ + +
+ +
+ ))} + {badgeLabelFields.length < MAX_BADGE_LABELS ? ( + + ) : ( +

Maximum of {MAX_BADGE_LABELS} badges reached.

+ )} +
@@ -267,18 +293,19 @@ function StepContent({