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 && (
-
{
- e.preventDefault();
- e.stopPropagation();
- clickAction.go(navigate);
- }}
- >
- {clickAction.label}
-
- )}
-
-
- {/* 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 (
-
-
-
- Close
-
-
-
-
-
-
{title || "Alert"}
-
- {message || "Your message will appear here."}
-
-
-
- {linkLabel && (
-
-
- {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 type
{CONTENT_MODES.map((m) => (
setValue("content_mode", m.value, { shouldValidate: true })}
+ onClick={() => {
+ setValue("content_mode", m.value, { shouldValidate: true });
+ // redirect_link only applies to Image Only ads (Text with
+ // Image ads click through via their own CTA links instead)
+ if (m.value === "content") setValue("redirect_link", "", { shouldValidate: true });
+ }}
className={cn(
"rounded-lg border p-3 text-left transition-colors",
contentMode === m.value ? "border-primary bg-primary/5" : "hover:bg-muted/50"
@@ -232,7 +238,7 @@ function StepContent({
>
{m.label}
- {m.value === "image" ? "Just an image, no text overlay." : "Badge, headline, description, and CTAs."}
+ {m.value === "image" ? "No text." : "Left-aligned text, image on the right."}
))}
@@ -257,8 +263,28 @@ function StepContent({
{contentMode === "content" && (
-
Badge label
-
+
Badge labels
+
+ {badgeLabelFields.map((_, index) => (
+
+
+
+
+
+
removeBadgeLabel(index)} aria-label="Remove">
+
+
+
+ ))}
+ {badgeLabelFields.length < MAX_BADGE_LABELS ? (
+
+
+ Add badge label
+
+ ) : (
+
Maximum of {MAX_BADGE_LABELS} badges reached.
+ )}
+
Headline
@@ -267,18 +293,19 @@ function StepContent({
Description
- {format === "hero" && (
-
-
- 200 ? "text-destructive" : "text-muted-foreground"}`}>
- {description?.length ?? 0}/200
-
-
- )}
+
+
+ 100 ? "text-destructive" : "text-muted-foreground"}`}>
+ {description?.length ?? 0}/100
+
+
-
Calls to action
+
Links
+
+ Up to {MAX_CTAS} buttons. The first is styled Primary, the second Outline.
+
{ctaFields.map((field, index) => (
@@ -290,21 +317,6 @@ function StepContent({
-
- setValue(`ctas.${index}.variant`, v)}
- >
-
-
-
-
- {CTA_VARIANTS.map((v) => (
- {v.label}
- ))}
-
-
-
removeCta(index)} aria-label="Remove">
@@ -318,7 +330,7 @@ function StepContent({
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
>
- Add CTA
+ Add link
) : (
Maximum of {MAX_CTAS} buttons reached.
@@ -328,15 +340,19 @@ function StepContent({
)}
-
-
-
-
Redirect link
-
-
- Where clicking the ad itself goes to. Leave blank to build an internal landing page in the next step instead.
-
-
+ {contentMode === "image" && (
+ <>
+
+
+
Link
+
+
+
+ Where clicking the image goes to.
+
+
+ >
+ )}
);
}
@@ -451,10 +467,10 @@ function StepReview({ data, selectedAsset, imageUrl }) {
Content
-
+
{data.content_mode === "content" && (
<>
-
+
>
@@ -476,26 +492,28 @@ function StepReview({ data, selectedAsset, imageUrl }) {
{ctas.length > 0 && (
-
Calls to action
+
Links
{ctas.map((c, i) => (
))}
)}
-
-
Click-through
- {data.redirect_link ? (
-
- ) : hasLandingPage ? (
- <>
-
-
l.label || l.link).length || null} />
- >
- ) : (
- No redirect link or landing page set — this ad won't link anywhere when clicked.
- )}
-
+ {data.content_mode === "image" && (
+
+
Click-through
+ {data.redirect_link ? (
+
+ ) : hasLandingPage ? (
+ <>
+
+
l.label || l.link).length || null} />
+ >
+ ) : (
+ No link or landing page set — this ad won't link anywhere when clicked.
+ )}
+
+ )}
Scheduling & display
@@ -533,7 +551,7 @@ export default function AddAdvertisement() {
defaultValues: {
placement: undefined,
content_mode: "image",
- badge_label: "",
+ badge_labels: [],
headline: "",
description: "",
image_asset_id: null,
@@ -558,6 +576,7 @@ export default function AddAdvertisement() {
const placement = watch("placement");
const description = watch("description");
const redirectLink = watch("redirect_link");
+ const contentMode = watch("content_mode");
const format = PLACEMENT_MAP[placement]?.format;
const steps = useMemo(
@@ -567,6 +586,10 @@ export default function AddAdvertisement() {
const stepIndex = Math.min(step, steps.length - 1);
const current = steps[stepIndex];
+ // Image Only ads require a valid Link before advancing past the Type step
+ const linkStepInvalid = current.id === "content" && contentMode === "image"
+ && (!redirectLink?.trim() || !isValidLink(redirectLink));
+
const breadcrumbItems = [
{ label: "Home", icon:
, to: "/admin" },
{ label: "Ads", to: "/admin/advertisements" },
@@ -577,7 +600,7 @@ export default function AddAdvertisement() {
const handleNext = async () => {
let fields = [];
if (current.id === "placement") fields = ["placement"];
- else if (current.id === "content") fields = watch("content_mode") === "content" ? ["headline", "description", "badge_label", "ctas"] : [];
+ else if (current.id === "content") fields = watch("content_mode") === "content" ? ["headline", "description", "badge_labels", "ctas"] : ["redirect_link"];
const valid = fields.length ? await trigger(fields) : true;
if (valid) setStep((s) => Math.min(s + 1, steps.length - 1));
@@ -637,7 +660,6 @@ export default function AddAdvertisement() {
setValue={setValue}
watch={watch}
description={description}
- format={format}
selectedAsset={selectedAsset}
imageUrl={imagePreviewUrl}
setPickerOpen={setPickerOpen}
@@ -679,7 +701,7 @@ export default function AddAdvertisement() {
Create advertisement
) : (
-
+
Next
diff --git a/src/modules/admin/pages/advertisements/AdvertisementList.jsx b/src/modules/admin/pages/advertisements/AdvertisementList.jsx
index a3aa0c4..133f9aa 100644
--- a/src/modules/admin/pages/advertisements/AdvertisementList.jsx
+++ b/src/modules/admin/pages/advertisements/AdvertisementList.jsx
@@ -271,7 +271,7 @@ function AdvertisementCard({ ad, position, onView, onEdit, onArchive, canMoveUp,
- {ad.headline || ad.badge_label || "Untitled ad"}
+ {ad.headline || ad.badge_labels?.[0] || "Untitled ad"}
{placementMeta ? (
{placementMeta.pageLabel} — {placementMeta.slotLabel}
) : (
@@ -305,7 +305,7 @@ function AdvertisementCard({ ad, position, onView, onEdit, onArchive, canMoveUp,
Archive this ad?
- "{ad.headline || ad.badge_label || "This ad"}" will be moved to archived ads. You can restore it later.
+ "{ad.headline || ad.badge_labels?.[0] || "This ad"}" will be moved to archived ads. You can restore it later.
diff --git a/src/modules/admin/pages/advertisements/EditAdvertisement.jsx b/src/modules/admin/pages/advertisements/EditAdvertisement.jsx
index f5e8c6c..abf6d5c 100644
--- a/src/modules/admin/pages/advertisements/EditAdvertisement.jsx
+++ b/src/modules/admin/pages/advertisements/EditAdvertisement.jsx
@@ -11,6 +11,7 @@ import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { useAuth } from "@/contexts/AuthContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
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";
@@ -24,7 +25,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
-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 ─────────────────────────────────────────────────────────────────
@@ -32,14 +33,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(),
@@ -56,16 +57,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).
+ 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 });
}
});
@@ -90,11 +89,6 @@ function SectionCard({ title, description, children }) {
);
}
-const CTA_VARIANTS = [
- { value: "default", label: "Primary" },
- { value: "outline", label: "Outline" },
-];
-
// ─── Page ───────────────────────────────────────────────────────────────────
export default function EditAdvertisement() {
@@ -124,7 +118,7 @@ export default function EditAdvertisement() {
defaultValues: {
placement: undefined,
content_mode: "image",
- badge_label: "",
+ badge_labels: [],
headline: "",
description: "",
image_asset_id: null,
@@ -145,6 +139,9 @@ export default function EditAdvertisement() {
const placement = watch("placement");
const description = watch("description");
const contentMode = watch("content_mode");
+ const badgeLabelFields = watch("badge_labels") ?? [];
+ const appendBadgeLabel = () => setValue("badge_labels", [...badgeLabelFields, ""], { shouldValidate: true, shouldDirty: true });
+ const removeBadgeLabel = (index) => setValue("badge_labels", badgeLabelFields.filter((_, i) => i !== index), { shouldValidate: true, shouldDirty: true });
const redirectLink = watch("redirect_link");
const format = PLACEMENT_MAP[placement]?.format;
@@ -172,7 +169,7 @@ export default function EditAdvertisement() {
reset({
placement: ad.placement ?? undefined,
content_mode: ad.content_mode ?? "image",
- badge_label: ad.badge_label ?? "",
+ badge_labels: ad.badge_labels ?? [],
headline: ad.headline ?? "",
description: ad.description ?? "",
image_asset_id: ad.image?.asset_id ?? null,
@@ -264,13 +261,18 @@ export default function EditAdvertisement() {
)}
-
+
{CONTENT_MODES.map((m) => (
setValue("content_mode", m.value, { shouldValidate: true, shouldDirty: true })}
+ onClick={() => {
+ setValue("content_mode", m.value, { shouldValidate: true, shouldDirty: true });
+ // redirect_link only applies to Image Only ads (Text with
+ // Image ads click through via their own CTA links instead)
+ if (m.value === "content") setValue("redirect_link", "", { shouldValidate: true, shouldDirty: true });
+ }}
className={cn(
"rounded-lg border p-3 text-left transition-colors",
contentMode === m.value ? "border-primary bg-primary/5" : "hover:bg-muted/50"
@@ -278,7 +280,7 @@ export default function EditAdvertisement() {
>
{m.label}
- {m.value === "image" ? "Just an image, no text overlay." : "Badge, headline, description, and CTAs."}
+ {m.value === "image" ? "No text." : "Left-aligned text, image on the right."}
))}
@@ -297,8 +299,28 @@ export default function EditAdvertisement() {
{contentMode === "content" && (
<>
-
Badge label
-
+
Badge labels
+
+ {badgeLabelFields.map((_, index) => (
+
+
+
+
+
+
removeBadgeLabel(index)} aria-label="Remove">
+
+
+
+ ))}
+ {badgeLabelFields.length < MAX_BADGE_LABELS ? (
+
+
+ Add badge label
+
+ ) : (
+
Maximum of {MAX_BADGE_LABELS} badges reached.
+ )}
+
Headline
@@ -307,14 +329,12 @@ export default function EditAdvertisement() {
Description
- {format === "hero" && (
-
-
- 200 ? "text-destructive" : "text-muted-foreground"}`}>
- {description?.length ?? 0}/200
-
-
- )}
+
+
+ 100 ? "text-destructive" : "text-muted-foreground"}`}>
+ {description?.length ?? 0}/100
+
+
>
)}
@@ -349,8 +369,8 @@ export default function EditAdvertisement() {
{contentMode === "content" && (
{ctaFields.map((field, index) => (
@@ -362,21 +382,6 @@ export default function EditAdvertisement() {
-
- setValue(`ctas.${index}.variant`, v, { shouldDirty: true })}
- >
-
-
-
-
- {CTA_VARIANTS.map((v) => (
- {v.label}
- ))}
-
-
-
removeCta(index)} aria-label="Remove">
@@ -390,7 +395,7 @@ export default function EditAdvertisement() {
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
>
- Add CTA
+ Add link
) : (
Maximum of {MAX_CTAS} buttons reached.
@@ -398,51 +403,54 @@ export default function EditAdvertisement() {
)}
-
-
-
Redirect link
-
-
- Leave blank to use the internal landing page below instead.
-
-
+ {contentMode === "image" && (
+
+
+
Link
+
+
+
+ Where clicking the image goes to.
+
+
- {!redirectLink?.trim() && (
- <>
-
-
- Page title
-
-
-
- Page description
-
-
-
- Body
-
-
-
-
Links
-
- >
- )}
-
+
+ Page description
+
+
+
+ Body
+
+
+
+ >
+ )}
+
+ )}
diff --git a/src/modules/admin/pages/advertisements/ViewAdvertisement.jsx b/src/modules/admin/pages/advertisements/ViewAdvertisement.jsx
index 17d1a21..558c005 100644
--- a/src/modules/admin/pages/advertisements/ViewAdvertisement.jsx
+++ b/src/modules/admin/pages/advertisements/ViewAdvertisement.jsx
@@ -112,7 +112,7 @@ export default function ViewAdvertisement() {
- {advertisement.headline || advertisement.badge_label || "Untitled ad"}
+ {advertisement.headline || advertisement.badge_labels?.[0] || "Untitled ad"}
@@ -153,7 +153,7 @@ export default function ViewAdvertisement() {
{/* ── Content ────────────────────────────────────────────────── */}
- {advertisement.badge_label || "—"}
+ {(advertisement.badge_labels ?? []).join(", ") || "—"}
{advertisement.headline || "—"}
{advertisement.description || "—"}
diff --git a/src/modules/admin/pages/notifications/AddNotificationBroadcast.jsx b/src/modules/admin/pages/notifications/AddNotificationBroadcast.jsx
index 75ea2e5..fede3aa 100644
--- a/src/modules/admin/pages/notifications/AddNotificationBroadcast.jsx
+++ b/src/modules/admin/pages/notifications/AddNotificationBroadcast.jsx
@@ -6,14 +6,12 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
-import { House, Check, ArrowLeft, ArrowRight, ImagePlus, X } from "lucide-react";
+import { House, Check, ArrowLeft, ArrowRight } from "lucide-react";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
-import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
-import AlertLayoutPreview from "../../components/notifications/AlertLayoutPreview";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
@@ -28,7 +26,7 @@ import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadca
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { TIER_COLOR_OPTIONS, getTierColor, getContrastText } from "@/utils/tierColors";
import { normalizeExternalUrl } from "@/components/generic/notificationDisplay";
-import { resolveAssetSrc } from "@/utils/media.util";
+import { isValidLink, LINK_ERROR } from "@/utils/link.util";
// Internal paths ("/course/123") pass through untouched — everything else
// gets a scheme so the saved URL always matches what goToLink() will open,
@@ -40,16 +38,13 @@ const normalizeLinkUrl = (raw) => (!raw ? null : raw.startsWith("/") ? raw : nor
const schema = z.object({
title: z.string().min(1, "Title is required."),
- message: z.string().min(1, "Message is required."),
+ message: z.string().optional(),
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { message: "Please select a target." }),
target_id: z.string().nullable().optional(),
show_in_sticky: z.boolean().optional(),
show_in_notifications: z.boolean().optional(),
- link_mode: z.enum(["info", "link"]).optional(),
link_url: z.string().trim().optional(),
- link_label: z.string().trim().optional(),
color: z.string().optional(),
- image_asset_id: z.string().nullable().optional(),
start_date: z.string().optional(),
end_date: z.string().optional(),
}).superRefine((data, ctx) => {
@@ -69,10 +64,26 @@ const schema = z.object({
});
}
- if (data.show_in_sticky && data.link_mode === "link" && !data.link_url) {
+ if (data.show_in_sticky && data.show_in_notifications) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: "Enter a link URL, or switch to text info only.",
+ message: "Choose only one: Sticky or Notifications.",
+ path: ["show_in_notifications"],
+ });
+ }
+
+ if (data.show_in_notifications && !data.message?.trim()) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Message is required for Notifications alerts.",
+ path: ["message"],
+ });
+ }
+
+ if (data.link_url && !isValidLink(data.link_url)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: LINK_ERROR,
path: ["link_url"],
});
}
@@ -89,9 +100,8 @@ const schema = z.object({
// ─── Steps config ───────────────────────────────────────────────────────────
const STEPS = [
- { label: "Content", description: "Title & message" },
+ { label: "Content", description: "What it says & how it's shown" },
{ label: "Target", description: "Who receives it" },
- { label: "Display", description: "Where it shows & schedule" },
{ label: "Review", description: "Confirm & save" },
];
@@ -116,50 +126,6 @@ function SectionCard({ title, description, children }) {
);
}
-function AlertImagePicker({ selectedAsset, onPick, onRemove }) {
- if (!selectedAsset) {
- return (
-
-
- Select an image
-
- );
- }
-
- const imageUrl = resolveAssetSrc(selectedAsset);
-
- return (
-
-
-
- Change image
-
-
{ e.stopPropagation(); onRemove(); }}
- aria-label="Remove image"
- >
-
-
-
- );
-}
-
function StepIndicator({ steps, current, onStepClick }) {
return (
@@ -219,8 +185,6 @@ export default function AddNotificationBroadcast() {
const [currentStep, setCurrentStep] = useState(0);
const [targetLabel, setTargetLabel] = useState(null);
- const [imageAsset, setImageAsset] = useState(null);
- const [imagePickerOpen, setImagePickerOpen] = useState(false);
const {
register,
@@ -238,11 +202,8 @@ export default function AddNotificationBroadcast() {
target_id: null,
show_in_sticky: false,
show_in_notifications: true,
- link_mode: "info",
link_url: "",
- link_label: "",
color: "indigo",
- image_asset_id: null,
start_date: "",
end_date: "",
},
@@ -255,7 +216,6 @@ export default function AddNotificationBroadcast() {
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
const showInSticky = watch("show_in_sticky");
const showInNotifications = watch("show_in_notifications");
- const linkMode = watch("link_mode");
const color = watch("color");
const startDate = watch("start_date");
const endDate = watch("end_date");
@@ -270,31 +230,25 @@ export default function AddNotificationBroadcast() {
// fields from earlier steps.
const goToStep = async (target) => {
if (target > 0) {
- const valid = await trigger(["title", "message"]);
+ const valid = await trigger(["title", "message", "show_in_sticky", "show_in_notifications", "link_url", "end_date"]);
if (!valid) { setCurrentStep(0); return; }
}
if (target > 1) {
const valid = await trigger(["target_type", "target_id"]);
if (!valid) { setCurrentStep(1); return; }
}
- if (target > 2) {
- const valid = await trigger(["show_in_sticky", "show_in_notifications", "link_url", "end_date"]);
- if (!valid) { setCurrentStep(2); return; }
- }
setCurrentStep(target);
};
const saveBroadcast = async (values, { publish = false } = {}) => {
- const { link_mode, ...rest } = values;
const payload = {
- ...rest,
+ ...values,
+ message: values.message?.trim() || null,
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
show_in_sticky: values.show_in_sticky ?? false,
show_in_notifications: values.show_in_notifications ?? true,
- link_url: (values.show_in_sticky && link_mode === "link") ? normalizeLinkUrl(values.link_url.trim()) : null,
- link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
+ link_url: values.link_url?.trim() ? normalizeLinkUrl(values.link_url.trim()) : null,
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
- image_asset_id: values.show_in_sticky ? (values.image_asset_id || null) : null,
start_date: values.start_date || null,
end_date: values.end_date || null,
createdBy: user?.user_id ?? null,
@@ -330,66 +284,22 @@ export default function AddNotificationBroadcast() {
{/* ── Step 0: Content ── */}
{currentStep === 0 && (
-
-
- Title
-
-
-
-
- Message
-
-
-
-
- )}
-
- {/* ── Step 1: Target ── */}
- {currentStep === 1 && (
-
-
-
{
- setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
- setValue("target_id", null, { shouldDirty: true });
- setTargetLabel(null);
- }}
- >
-
-
-
-
- {TARGET_TYPE_OPTIONS.map((t) => (
- {t.label}
- ))}
-
-
-
- {targetType && (
-
- {TARGET_TYPE_MAP[targetType]?.description}
-
- )}
-
-
- {needsTarget && (
-
- setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
- onLabelResolved={setTargetLabel}
- />
-
-
- )}
-
- )}
-
- {/* ── Step 2: Display ── */}
- {currentStep === 2 && (
<>
+
+
+ Title
+
+
+
+ {showInNotifications && (
+
+ Message
+
+
+
+ )}
+
+
@@ -399,10 +309,7 @@ export default function AddNotificationBroadcast() {
onCheckedChange={(v) => {
const checked = v === true;
setValue("show_in_sticky", checked, { shouldValidate: true, shouldDirty: true });
- // Sticky-only alerts vanish forever once dismissed (seen=true drops
- // them from the sticky query, show_in_notifications=false hides them from
- // the list too) — force the list entry so it stays reachable afterward.
- if (checked) setValue("show_in_notifications", true, { shouldValidate: true, shouldDirty: true });
+ if (checked) setValue("show_in_notifications", false, { shouldValidate: true, shouldDirty: true });
}}
/>
@@ -414,18 +321,16 @@ export default function AddNotificationBroadcast() {
setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
+ onCheckedChange={(v) => {
+ const checked = v === true;
+ setValue("show_in_notifications", checked, { shouldValidate: true, shouldDirty: true });
+ if (checked) setValue("show_in_sticky", false, { shouldValidate: true, shouldDirty: true });
+ }}
/>
-
+
Show in Notifications
- {showInSticky && (
-
- Required while sticky is on, so it stays visible after being dismissed.
-
- )}
@@ -476,103 +381,79 @@ export default function AddNotificationBroadcast() {
{showInSticky && (
-
- setImagePickerOpen(true)}
- onRemove={() => {
- setImageAsset(null);
- setValue("image_asset_id", null, { shouldDirty: true });
- }}
- />
-
- )}
-
- {showInSticky && (
-
-
- setValue("link_mode", "info", { shouldDirty: true })}
- >
- Text info only
-
- setValue("link_mode", "link", { shouldDirty: true })}
- >
- Include a link
-
-
-
- {linkMode === "link" ? (
-
-
-
Link URL
-
-
-
- Internal paths (starting with /) navigate in-app; anything else opens in a new tab.
-
-
-
-
Button label
-
-
- Shown as a button right after the title in the sticky banner. Defaults to "Open Link".
-
-
-
- ) : (
-
- The full-content view will show just the title and message, with no action button.
+
+
+
Link URL
+
+
+
+ Internal paths (starting with /) navigate in-app; anything else opens in a new tab. Leave blank for an informational banner with no click action.
- )}
+
)}
{showInSticky && (
-
-
-
-
Sticky banner (collapsed):
-
- {watch("title") || "Sticky banner preview"}
- {linkMode === "link" && (
-
- {watch("link_label") || "Open Link"}
-
- )}
-
-
-
-
-
When clicked (full content):
-
-
+
+
+ {watch("title") || "Sticky banner preview"}
)}
>
)}
- {/* ── Step 3: Review ── */}
- {currentStep === 3 && (
+ {/* ── Step 1: Target ── */}
+ {currentStep === 1 && (
+
+
+
{
+ setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
+ setValue("target_id", null, { shouldDirty: true });
+ setTargetLabel(null);
+ }}
+ >
+
+
+
+
+ {TARGET_TYPE_OPTIONS.map((t) => (
+ {t.label}
+ ))}
+
+
+
+ {targetType && (
+
+ {TARGET_TYPE_MAP[targetType]?.description}
+
+ )}
+
+
+ {needsTarget && (
+
+ setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
+ onLabelResolved={setTargetLabel}
+ />
+
+
+ )}
+
+ )}
+
+ {/* ── Step 2: Review ── */}
+ {currentStep === 2 && (
<>
@@ -580,10 +461,12 @@ export default function AddNotificationBroadcast() {
Title
{watch("title") || "—"}
-
-
Message
-
{watch("message") || "—"}
-
+ {showInNotifications && (
+
+
Message
+
{watch("message") || "—"}
+
+ )}
@@ -619,29 +502,21 @@ export default function AddNotificationBroadcast() {
{showInSticky && (
-
- {watch("title") || "Sticky banner preview"}
- {linkMode === "link" && (
-
- {watch("link_label") || "Open Link"}
-
- )}
-
+ <>
+
+ {watch("title") || "Sticky banner preview"}
+
+
+ {watch("link_url")
+ ? <>Clicking redirects to {watch("link_url")} .>
+ : "No link — informational only, not clickable."}
+
+ >
)}
-
- {showInSticky && (
-
-
- {linkMode === "link"
- ? <>Opens {watch("link_url") || "—"} via a “{watch("link_label") || "Open Link"}” button.>
- : "Text info only — no action button."}
-
-
- )}
>
)}
@@ -689,16 +564,6 @@ export default function AddNotificationBroadcast() {
{unsavedChangesDialog}
-
- {
- setImageAsset(asset);
- setValue("image_asset_id", String(asset.asset_id), { shouldDirty: true });
- }}
- />
);
}
diff --git a/src/modules/admin/pages/notifications/EditNotificationBroadcast.jsx b/src/modules/admin/pages/notifications/EditNotificationBroadcast.jsx
index 4041a37..a33b668 100644
--- a/src/modules/admin/pages/notifications/EditNotificationBroadcast.jsx
+++ b/src/modules/admin/pages/notifications/EditNotificationBroadcast.jsx
@@ -6,14 +6,12 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
-import { House, Check, ArrowLeft, ArrowRight, ImagePlus, X } from "lucide-react";
+import { House, Check, ArrowLeft, ArrowRight } from "lucide-react";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
-import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
-import AlertLayoutPreview from "../../components/notifications/AlertLayoutPreview";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
@@ -32,7 +30,7 @@ import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadca
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { TIER_COLOR_OPTIONS, getTierColor, getContrastText } from "@/utils/tierColors";
import { normalizeExternalUrl } from "@/components/generic/notificationDisplay";
-import { resolveAssetSrc } from "@/utils/media.util";
+import { isValidLink, LINK_ERROR } from "@/utils/link.util";
// Internal paths ("/course/123") pass through untouched — everything else
// gets a scheme so the saved URL always matches what goToLink() will open,
@@ -44,16 +42,13 @@ const normalizeLinkUrl = (raw) => (!raw ? null : raw.startsWith("/") ? raw : nor
const schema = z.object({
title: z.string().min(1, "Title is required."),
- message: z.string().min(1, "Message is required."),
+ message: z.string().optional(),
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { message: "Please select a target." }),
target_id: z.string().nullable().optional(),
show_in_sticky: z.boolean().optional(),
show_in_notifications: z.boolean().optional(),
- link_mode: z.enum(["info", "link"]).optional(),
link_url: z.string().trim().optional(),
- link_label: z.string().trim().optional(),
color: z.string().optional(),
- image_asset_id: z.string().nullable().optional(),
start_date: z.string().optional(),
end_date: z.string().optional(),
}).superRefine((data, ctx) => {
@@ -73,10 +68,26 @@ const schema = z.object({
});
}
- if (data.show_in_sticky && data.link_mode === "link" && !data.link_url) {
+ if (data.show_in_sticky && data.show_in_notifications) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: "Enter a link URL, or switch to text info only.",
+ message: "Choose only one: Sticky or Notifications.",
+ path: ["show_in_notifications"],
+ });
+ }
+
+ if (data.show_in_notifications && !data.message?.trim()) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Message is required for Notifications alerts.",
+ path: ["message"],
+ });
+ }
+
+ if (data.link_url && !isValidLink(data.link_url)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: LINK_ERROR,
path: ["link_url"],
});
}
@@ -93,9 +104,8 @@ const schema = z.object({
// ─── Steps config ───────────────────────────────────────────────────────────
const STEPS = [
- { label: "Content", description: "Title & message" },
+ { label: "Content", description: "What it says & how it's shown" },
{ label: "Target", description: "Who receives it" },
- { label: "Display", description: "Where it shows & schedule" },
{ label: "Review", description: "Confirm & save" },
];
@@ -120,50 +130,6 @@ function SectionCard({ title, description, children }) {
);
}
-function AlertImagePicker({ selectedAsset, onPick, onRemove }) {
- if (!selectedAsset) {
- return (
-
-
- Select an image
-
- );
- }
-
- const imageUrl = resolveAssetSrc(selectedAsset);
-
- return (
-
-
-
- Change image
-
-
{ e.stopPropagation(); onRemove(); }}
- aria-label="Remove image"
- >
-
-
-
- );
-}
-
function StepIndicator({ steps, current, onStepClick }) {
return (
@@ -225,8 +191,6 @@ export default function EditNotificationBroadcast() {
const [currentStep, setCurrentStep] = useState(0);
const [targetLabel, setTargetLabel] = useState(null);
const [broadcastStatus, setBroadcastStatus] = useState("draft");
- const [imageAsset, setImageAsset] = useState(null);
- const [imagePickerOpen, setImagePickerOpen] = useState(false);
const {
register,
@@ -245,11 +209,8 @@ export default function EditNotificationBroadcast() {
target_id: null,
show_in_sticky: false,
show_in_notifications: true,
- link_mode: "info",
link_url: "",
- link_label: "",
color: "indigo",
- image_asset_id: null,
start_date: "",
end_date: "",
},
@@ -262,7 +223,6 @@ export default function EditNotificationBroadcast() {
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
const showInSticky = watch("show_in_sticky");
const showInNotifications = watch("show_in_notifications");
- const linkMode = watch("link_mode");
const color = watch("color");
const startDate = watch("start_date");
const endDate = watch("end_date");
@@ -280,25 +240,18 @@ export default function EditNotificationBroadcast() {
const b = res?.data?.data ?? null;
if (!b) return;
- // Self-heals broadcasts saved before sticky-only was disallowed: sticky
- // without notifications made the announcement unrecoverable once dismissed.
- const stickyOn = b.show_in_sticky ?? false;
reset({
title: b.title ?? "",
message: b.message ?? "",
target_type: b.target_type ?? undefined,
target_id: b.target_id ?? null,
- show_in_sticky: stickyOn,
- show_in_notifications: stickyOn ? true : (b.show_in_notifications ?? true),
- link_mode: b.link_url ? "link" : "info",
+ show_in_sticky: b.show_in_sticky ?? false,
+ show_in_notifications: b.show_in_notifications ?? true,
link_url: b.link_url ?? "",
- link_label: b.link_label ?? "",
color: b.color ?? "indigo",
- image_asset_id: b.image_asset_id ? String(b.image_asset_id) : null,
start_date: b.start_date ?? "",
end_date: b.end_date ?? "",
});
- setImageAsset(b.image ?? null);
setBroadcastStatus(b.status ?? "draft");
setCurrentStep(0);
})();
@@ -309,31 +262,25 @@ export default function EditNotificationBroadcast() {
// fields from earlier steps.
const goToStep = async (target) => {
if (target > 0) {
- const valid = await trigger(["title", "message"]);
+ const valid = await trigger(["title", "message", "show_in_sticky", "show_in_notifications", "link_url", "end_date"]);
if (!valid) { setCurrentStep(0); return; }
}
if (target > 1) {
const valid = await trigger(["target_type", "target_id"]);
if (!valid) { setCurrentStep(1); return; }
}
- if (target > 2) {
- const valid = await trigger(["show_in_sticky", "show_in_notifications", "link_url", "end_date"]);
- if (!valid) { setCurrentStep(2); return; }
- }
setCurrentStep(target);
};
const saveBroadcast = async (values, { publish = false } = {}) => {
- const { link_mode, ...rest } = values;
const payload = {
- ...rest,
+ ...values,
+ message: values.message?.trim() || null,
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
show_in_sticky: values.show_in_sticky ?? false,
show_in_notifications: values.show_in_notifications ?? true,
- link_url: (values.show_in_sticky && link_mode === "link") ? normalizeLinkUrl(values.link_url.trim()) : null,
- link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
+ link_url: values.link_url?.trim() ? normalizeLinkUrl(values.link_url.trim()) : null,
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
- image_asset_id: values.show_in_sticky ? (values.image_asset_id || null) : null,
start_date: values.start_date || null,
end_date: values.end_date || null,
updatedBy: user?.user_id ?? null,
@@ -372,66 +319,22 @@ export default function EditNotificationBroadcast() {
{/* ── Step 0: Content ── */}
{currentStep === 0 && (
-
-
- Title
-
-
-
-
- Message
-
-
-
-
- )}
-
- {/* ── Step 1: Target ── */}
- {currentStep === 1 && (
-
-
-
{
- setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
- setValue("target_id", null, { shouldDirty: true });
- setTargetLabel(null);
- }}
- >
-
-
-
-
- {TARGET_TYPE_OPTIONS.map((t) => (
- {t.label}
- ))}
-
-
-
- {targetType && (
-
- {TARGET_TYPE_MAP[targetType]?.description}
-
- )}
-
-
- {needsTarget && (
-
- setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
- onLabelResolved={setTargetLabel}
- />
-
-
- )}
-
- )}
-
- {/* ── Step 2: Display ── */}
- {currentStep === 2 && (
<>
+
+
+ Title
+
+
+
+ {showInNotifications && (
+
+ Message
+
+
+
+ )}
+
+
@@ -441,10 +344,7 @@ export default function EditNotificationBroadcast() {
onCheckedChange={(v) => {
const checked = v === true;
setValue("show_in_sticky", checked, { shouldValidate: true, shouldDirty: true });
- // Sticky-only alerts vanish forever once dismissed (seen=true drops
- // them from the sticky query, show_in_notifications=false hides them from
- // the list too) — force the list entry so it stays reachable afterward.
- if (checked) setValue("show_in_notifications", true, { shouldValidate: true, shouldDirty: true });
+ if (checked) setValue("show_in_notifications", false, { shouldValidate: true, shouldDirty: true });
}}
/>
@@ -456,18 +356,16 @@ export default function EditNotificationBroadcast() {
setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
+ onCheckedChange={(v) => {
+ const checked = v === true;
+ setValue("show_in_notifications", checked, { shouldValidate: true, shouldDirty: true });
+ if (checked) setValue("show_in_sticky", false, { shouldValidate: true, shouldDirty: true });
+ }}
/>
-
+
Show in Notifications
- {showInSticky && (
-
- Required while sticky is on, so it stays visible after being dismissed.
-
- )}
@@ -518,103 +416,79 @@ export default function EditNotificationBroadcast() {
{showInSticky && (
-
- setImagePickerOpen(true)}
- onRemove={() => {
- setImageAsset(null);
- setValue("image_asset_id", null, { shouldDirty: true });
- }}
- />
-
- )}
-
- {showInSticky && (
-
-
- setValue("link_mode", "info", { shouldDirty: true })}
- >
- Text info only
-
- setValue("link_mode", "link", { shouldDirty: true })}
- >
- Include a link
-
-
-
- {linkMode === "link" ? (
-
-
-
Link URL
-
-
-
- Internal paths (starting with /) navigate in-app; anything else opens in a new tab.
-
-
-
-
Button label
-
-
- Shown as a button right after the title in the sticky banner. Defaults to "Open Link".
-
-
-
- ) : (
-
- The full-content view will show just the title and message, with no action button.
+
+
+
Link URL
+
+
+
+ Internal paths (starting with /) navigate in-app; anything else opens in a new tab. Leave blank for an informational banner with no click action.
- )}
+
)}
{showInSticky && (
-
-
-
-
Sticky banner (collapsed):
-
- {watch("title") || "Sticky banner preview"}
- {linkMode === "link" && (
-
- {watch("link_label") || "Open Link"}
-
- )}
-
-
-
-
-
When clicked (full content):
-
-
+
+
+ {watch("title") || "Sticky banner preview"}
)}
>
)}
- {/* ── Step 3: Review ── */}
- {currentStep === 3 && (
+ {/* ── Step 1: Target ── */}
+ {currentStep === 1 && (
+
+
+
{
+ setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
+ setValue("target_id", null, { shouldDirty: true });
+ setTargetLabel(null);
+ }}
+ >
+
+
+
+
+ {TARGET_TYPE_OPTIONS.map((t) => (
+ {t.label}
+ ))}
+
+
+
+ {targetType && (
+
+ {TARGET_TYPE_MAP[targetType]?.description}
+
+ )}
+
+
+ {needsTarget && (
+
+ setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
+ onLabelResolved={setTargetLabel}
+ />
+
+
+ )}
+
+ )}
+
+ {/* ── Step 2: Review ── */}
+ {currentStep === 2 && (
<>
@@ -622,10 +496,12 @@ export default function EditNotificationBroadcast() {
Title
{watch("title") || "—"}
-
-
Message
-
{watch("message") || "—"}
-
+ {showInNotifications && (
+
+
Message
+
{watch("message") || "—"}
+
+ )}
@@ -661,29 +537,21 @@ export default function EditNotificationBroadcast() {
{showInSticky && (
-
- {watch("title") || "Sticky banner preview"}
- {linkMode === "link" && (
-
- {watch("link_label") || "Open Link"}
-
- )}
-
+ <>
+
+ {watch("title") || "Sticky banner preview"}
+
+
+ {watch("link_url")
+ ? <>Clicking redirects to {watch("link_url")} .>
+ : "No link — informational only, not clickable."}
+
+ >
)}
-
- {showInSticky && (
-
-
- {linkMode === "link"
- ? <>Opens {watch("link_url") || "—"} via a “{watch("link_label") || "Open Link"}” button.>
- : "Text info only — no action button."}
-
-
- )}
>
)}
@@ -754,16 +622,6 @@ export default function EditNotificationBroadcast() {
{unsavedChangesDialog}
-
- {
- setImageAsset(asset);
- setValue("image_asset_id", String(asset.asset_id), { shouldDirty: true });
- }}
- />
);
}
diff --git a/src/modules/admin/pages/notifications/NotificationBroadcastList.jsx b/src/modules/admin/pages/notifications/NotificationBroadcastList.jsx
index 48ccb7f..79bf1c8 100644
--- a/src/modules/admin/pages/notifications/NotificationBroadcastList.jsx
+++ b/src/modules/admin/pages/notifications/NotificationBroadcastList.jsx
@@ -205,7 +205,9 @@ function BroadcastCard({ broadcast, onView, onEdit, onSend, onArchive }) {
{broadcast.title || "Untitled alert"}
-
{broadcast.message}
+ {broadcast.message && (
+
{broadcast.message}
+ )}
{broadcast.sent_at && (
Sent {fmtDateTime(broadcast.sent_at)} · {broadcast.recipient_count ?? 0} recipient(s)
diff --git a/src/modules/admin/pages/notifications/ViewNotificationBroadcast.jsx b/src/modules/admin/pages/notifications/ViewNotificationBroadcast.jsx
index 3227195..f56dab7 100644
--- a/src/modules/admin/pages/notifications/ViewNotificationBroadcast.jsx
+++ b/src/modules/admin/pages/notifications/ViewNotificationBroadcast.jsx
@@ -47,9 +47,11 @@ function ContentTab({ broadcast }) {
return (
{broadcast.title || "—"}
-
- {broadcast.message || "—"}
-
+ {broadcast.show_in_notifications && (
+
+ {broadcast.message || "—"}
+
+ )}
);
}
diff --git a/src/utils/link.util.js b/src/utils/link.util.js
new file mode 100644
index 0000000..86ac082
--- /dev/null
+++ b/src/utils/link.util.js
@@ -0,0 +1,66 @@
+// utils/link.util.js
+
+// Flat list of client route path patterns, hand-flattened from
+// modules/client/routes/ClientRoutes.jsx (mounted at root, no /client
+// prefix — confirmed via navigate("/dashboard") calls in that module).
+// Keep this in sync manually when ClientRoutes.jsx changes; importing the
+// real route tree here would drag every client page component into this
+// util's module graph.
+export const CLIENT_ROUTE_PATHS = [
+ "intro",
+ "dashboard",
+ "profile",
+ "profile/edit",
+ "certificates",
+ "achievements",
+ "completed",
+ "settings",
+ "notifications",
+ "ads/:uuid",
+ "plans",
+ "plans/view/:id",
+ "plans/checkout",
+ "course",
+ "course/:id",
+ "course/:id/unit",
+ "course/:id/checkout",
+ "units",
+ "units/:uuid",
+ "units/:uuid/read",
+ "units/:uuid/checkout",
+ "lessons",
+ "lessons/:uuid",
+ "lessons/:uuid/checkout",
+ "group/:groupId",
+ "group/:groupId/view/:taskListId",
+ "group/:groupId/view/:taskListId/task/:taskId",
+];
+
+const ROUTE_SEGMENTS = CLIENT_ROUTE_PATHS.map((p) => p.split("/"));
+
+export function isKnownClientPath(pathname) {
+ const normalized = pathname.split(/[?#]/)[0].replace(/^\/+|\/+$/g, "");
+ if (!normalized) return false;
+ const segments = normalized.split("/");
+ return ROUTE_SEGMENTS.some((pattern) => {
+ if (pattern.length !== segments.length) return false;
+ return pattern.every((seg, i) => seg.startsWith(":") || seg === segments[i]);
+ });
+}
+
+// Accepts an internal path matching a real client route (e.g. /courses) or
+// a URL with a real domain (protocol optional) — rejects garbage like
+// "534252345" while still allowing any valid TLD, not just .com/.net.
+export const LINK_ERROR = "Enter a valid URL (e.g. https://example.com) or an internal path that matches a real client route.";
+
+export function isValidLink(value) {
+ if (!value) return true;
+ const v = value.trim();
+ if (v.startsWith("/")) return isKnownClientPath(v);
+ try {
+ const url = new URL(/^https?:\/\//i.test(v) ? v : `https://${v}`);
+ return /\.[a-zA-Z]{2,}$/.test(url.hostname);
+ } catch {
+ return false;
+ }
+}