mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
added more things
This commit is contained in:
@@ -1,15 +1,12 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
|
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
|
||||||
import { getTierColor, getContrastText } from "@/utils/tierColors";
|
import { getTierColor, getContrastText } from "@/utils/tierColors";
|
||||||
import { goToLink } from "@/components/generic/notificationDisplay";
|
import { goToLink } from "@/components/generic/notificationDisplay";
|
||||||
import AnnouncementDetailsDialog from "@/components/generic/AnnouncementDetailsDialog";
|
|
||||||
|
|
||||||
// Admin counterpart to StickyAnnouncementBar (client). Only supports the
|
// Admin counterpart to StickyAnnouncementBar (client). Only supports the
|
||||||
// explicit link_url from the "On Open" section — the type-based fallback
|
// explicit link_url from the composer's "Link" section — the type-based
|
||||||
// resolver in notificationDisplay.jsx points at client-only routes
|
// fallback resolver in notificationDisplay.jsx points at client-only routes
|
||||||
// (/course/:id, /plans, /group/:id), which don't exist in the admin app.
|
// (/course/:id, /plans, /group/:id), which don't exist in the admin app.
|
||||||
function resolveClickAction(stickyAnnouncement) {
|
function resolveClickAction(stickyAnnouncement) {
|
||||||
const linkUrl = stickyAnnouncement.data?.linkUrl || null;
|
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 navigate = useNavigate();
|
||||||
const { stickyAnnouncements, markSeen } = useAdminNotifications();
|
const swatch = getTierColor(announcement.color || "indigo").swatch;
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
const textColor = getContrastText(swatch, announcement.color || "indigo");
|
||||||
|
const clickAction = resolveClickAction(announcement);
|
||||||
// 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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div role="status" className="w-full shadow-sm px-4 md:px-6" style={{ backgroundColor: swatch }}>
|
||||||
<div role="status" className="w-full shadow-sm px-4 md:px-6" style={{ backgroundColor: swatch }}>
|
<div className="relative w-full rounded-none py-3 flex items-center justify-center px-10">
|
||||||
<div
|
<p
|
||||||
onClick={onClickBanner}
|
className={["text-sm font-semibold leading-snug truncate", clickAction ? "cursor-pointer" : ""].join(" ")}
|
||||||
className="relative w-full cursor-pointer rounded-none py-3 flex items-center justify-center gap-4 px-10"
|
style={{ color: textColor }}
|
||||||
|
onClick={clickAction ? () => clickAction.go(navigate) : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
{announcement.title || "Announcement"}
|
||||||
<p className="text-sm font-semibold leading-snug truncate" style={{ color: textColor }}>
|
</p>
|
||||||
{current.title || "Announcement"}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{clickAction && (
|
{/* Only way to dismiss a sticky alert. */}
|
||||||
<Button
|
<div
|
||||||
variant="outline"
|
role="button"
|
||||||
size="sm"
|
tabIndex={0}
|
||||||
className="shrink-0 text-foreground"
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.preventDefault();
|
||||||
e.preventDefault();
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
void onDismiss();
|
||||||
clickAction.go(navigate);
|
}}
|
||||||
}}
|
onKeyDown={(e) => {
|
||||||
>
|
if (e.key !== "Enter" && e.key !== " ") return;
|
||||||
{clickAction.label}
|
e.preventDefault();
|
||||||
</Button>
|
e.stopPropagation();
|
||||||
)}
|
void onDismiss();
|
||||||
</div>
|
}}
|
||||||
|
aria-label="Dismiss sticky announcement"
|
||||||
{/* Only way to dismiss a sticky alert — closing the details dialog
|
title="Dismiss"
|
||||||
no longer dismisses it. */}
|
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity"
|
||||||
<div
|
style={{ color: textColor }}
|
||||||
role="button"
|
>
|
||||||
tabIndex={0}
|
<X className="size-4" />
|
||||||
onClick={(e) => {
|
|
||||||
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 }}
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<AnnouncementDetailsDialog
|
export default function AdminStickyAnnouncementBar() {
|
||||||
open={detailsOpen}
|
const { stickyAnnouncements, markSeen } = useAdminNotifications();
|
||||||
onOpenChange={setDetailsOpen}
|
|
||||||
announcement={current}
|
const visible = stickyAnnouncements.slice(0, MAX_VISIBLE_STICKY);
|
||||||
resolveClickAction={resolveClickAction}
|
if (visible.length === 0) return null;
|
||||||
navigate={navigate}
|
|
||||||
/>
|
return (
|
||||||
</>
|
<div className="w-full flex flex-col">
|
||||||
|
{visible.map((announcement) => (
|
||||||
|
<AdminStickyAnnouncementRow
|
||||||
|
key={announcement.notification_id}
|
||||||
|
announcement={announcement}
|
||||||
|
onDismiss={() => markSeen(announcement.notification_id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className={["p-0 overflow-hidden gap-0", imageSrc ? "sm:max-w-2xl" : "sm:max-w-md"].join(" ")}>
|
|
||||||
<div className={imageSrc ? "grid sm:grid-cols-2" : ""}>
|
|
||||||
<div className="p-6 flex flex-col gap-3 min-w-0">
|
|
||||||
<DialogHeader className="text-left">
|
|
||||||
<DialogTitle className="text-lg">
|
|
||||||
{announcement.title || "Alert"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription asChild>
|
|
||||||
<p className="whitespace-pre-wrap text-left text-foreground">
|
|
||||||
{announcement.message || ""}
|
|
||||||
</p>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{clickAction && (
|
|
||||||
<div className="mt-auto pt-4">
|
|
||||||
<Button
|
|
||||||
className="self-start"
|
|
||||||
onClick={() => clickAction.go(navigate)}
|
|
||||||
>
|
|
||||||
{clickAction.label}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{imageSrc && (
|
|
||||||
<div className="aspect-video sm:aspect-auto sm:h-96 bg-muted flex items-center justify-center overflow-hidden">
|
|
||||||
<img src={imageSrc} alt="" className="w-full h-full object-cover" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,7 @@ function hasLandingPage(ad) {
|
|||||||
* "image" — full-bleed image, clickable through to the ad's redirect/CTA
|
* "image" — full-bleed image, clickable through to the ad's redirect/CTA
|
||||||
*
|
*
|
||||||
* Props:
|
* 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.
|
* onCtaClick — (ad, cta) => void. cta is undefined for the whole-banner click on image-only ads.
|
||||||
*/
|
*/
|
||||||
export function Banner({ ads, onCtaClick }) {
|
export function Banner({ ads, onCtaClick }) {
|
||||||
@@ -61,6 +61,7 @@ export function Banner({ ads, onCtaClick }) {
|
|||||||
{ads.map((ad) => {
|
{ads.map((ad) => {
|
||||||
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
|
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
|
||||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||||
|
const badgeLabels = Array.isArray(ad.badge_labels) ? ad.badge_labels : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CarouselItem key={ad.advertisement_id}>
|
<CarouselItem key={ad.advertisement_id}>
|
||||||
@@ -88,10 +89,14 @@ export function Banner({ ads, onCtaClick }) {
|
|||||||
<div className="tier_plans_banner w-full rounded-xl xs:p-5 sm:p-4 lg:p-10 text-white">
|
<div className="tier_plans_banner w-full rounded-xl xs:p-5 sm:p-4 lg:p-10 text-white">
|
||||||
<div className="relative w-full items-start flex justify-between">
|
<div className="relative w-full items-start flex justify-between">
|
||||||
<div className="md:w-2xl space-y-4 xs:p-1.5 lg:p-0">
|
<div className="md:w-2xl space-y-4 xs:p-1.5 lg:p-0">
|
||||||
{ad.badge_label && (
|
{badgeLabels.length > 0 && (
|
||||||
<Badge className="bg-white text-black">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
{ad.badge_label}
|
{badgeLabels.map((label, i) => (
|
||||||
</Badge>
|
<Badge key={i} variant="outline" className="border-white/50 bg-white/10 text-white">
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{ad.headline && <div className="lg:text-5xl xs:text-4xl font-tier-ads lg:leading-16">{ad.headline}</div>}
|
{ad.headline && <div className="lg:text-5xl xs:text-4xl font-tier-ads lg:leading-16">{ad.headline}</div>}
|
||||||
{ad.description && <p className="leading-relaxed text-lg">{ad.description}</p>}
|
{ad.description && <p className="leading-relaxed text-lg">{ad.description}</p>}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function hasLandingPage(ad) {
|
|||||||
* ads are given — callers should not fall back to placeholder copy.
|
* ads are given — callers should not fall back to placeholder copy.
|
||||||
*
|
*
|
||||||
* Props:
|
* 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
|
* onCtaClick — (ad, cta) => void, called when any CTA button is clicked
|
||||||
*/
|
*/
|
||||||
export function Hero({ ads, onCtaClick }) {
|
export function Hero({ ads, onCtaClick }) {
|
||||||
@@ -60,6 +60,7 @@ export function Hero({ ads, onCtaClick }) {
|
|||||||
{ads.map((ad) => {
|
{ads.map((ad) => {
|
||||||
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
|
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
|
||||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||||
|
const badgeLabels = Array.isArray(ad.badge_labels) ? ad.badge_labels : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CarouselItem key={ad.advertisement_id}>
|
<CarouselItem key={ad.advertisement_id}>
|
||||||
@@ -92,10 +93,14 @@ export function Hero({ ads, onCtaClick }) {
|
|||||||
|
|
||||||
{/* Bottom-left content */}
|
{/* Bottom-left content */}
|
||||||
<div className="absolute bottom-0 left-0 p-6 flex flex-col gap-3 xs:max-w-[280px] sm:max-w-sm lg:max-w-xl">
|
<div className="absolute bottom-0 left-0 p-6 flex flex-col gap-3 xs:max-w-[280px] sm:max-w-sm lg:max-w-xl">
|
||||||
{ad.badge_label && (
|
{badgeLabels.length > 0 && (
|
||||||
<Badge variant="outline" className="pointer-events-none select-none w-fit border-white/30 bg-black/20 text-white">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<Megaphone /> {ad.badge_label}
|
{badgeLabels.map((label, i) => (
|
||||||
</Badge>
|
<Badge key={i} variant="outline" className="pointer-events-none select-none w-fit border-white/30 bg-black/20 text-white">
|
||||||
|
<Megaphone /> {label}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{ad.headline && (
|
{ad.headline && (
|
||||||
<div className="font-bold text-white xs:text-2xl lg:text-4xl leading-tight tracking-tighter pointer-events-none select-none">
|
<div className="font-bold text-white xs:text-2xl lg:text-4xl leading-tight tracking-tighter pointer-events-none select-none">
|
||||||
|
|||||||
@@ -1,105 +1,77 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
|
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
|
||||||
import { resolveNotificationLink } from "@/components/generic/notificationDisplay";
|
import { resolveNotificationLink } from "@/components/generic/notificationDisplay";
|
||||||
import { getTierColor, getContrastText } from "@/utils/tierColors";
|
import { getTierColor, getContrastText } from "@/utils/tierColors";
|
||||||
import AnnouncementDetailsDialog from "@/components/generic/AnnouncementDetailsDialog";
|
|
||||||
|
|
||||||
// resolveNotificationLink already gives explicit link_url (the admin "On
|
// Up to 2 sticky alerts shown at once, stacked — no dialog on click anymore;
|
||||||
// Open" section) precedence over the type-based fallbacks, for broadcasts
|
// the centered title text itself is the click target and redirects straight
|
||||||
// sent before that field existed.
|
// to the alert's link (if any).
|
||||||
|
const MAX_VISIBLE_STICKY = 2;
|
||||||
|
|
||||||
function resolveClickAction(stickyAnnouncement) {
|
function resolveClickAction(stickyAnnouncement) {
|
||||||
return resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data);
|
return resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StickyAnnouncementBar() {
|
function StickyAnnouncementRow({ announcement, onDismiss }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { stickyAnnouncements, markSeen } = useClientNotifications();
|
const swatch = getTierColor(announcement.color || "indigo").swatch;
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
const textColor = getContrastText(swatch, announcement.color || "indigo");
|
||||||
|
const clickAction = resolveClickAction(announcement);
|
||||||
// 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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div role="status" className="w-full shadow-sm px-4 md:px-6" style={{ backgroundColor: swatch }}>
|
||||||
<div role="status" className="w-full shadow-sm px-4 md:px-6" style={{ backgroundColor: swatch }}>
|
<div className="relative w-full rounded-none py-3 flex items-center justify-center px-10">
|
||||||
<div
|
<p
|
||||||
onClick={onClickBanner}
|
className={["text-sm font-semibold leading-snug truncate", clickAction ? "cursor-pointer" : ""].join(" ")}
|
||||||
className="relative w-full cursor-pointer rounded-none py-3 flex items-center justify-center gap-4 px-10"
|
style={{ color: textColor }}
|
||||||
|
onClick={clickAction ? () => clickAction.go(navigate) : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
{announcement.title || "Announcement"}
|
||||||
<p className="text-sm font-semibold leading-snug truncate" style={{ color: textColor }}>
|
</p>
|
||||||
{current.title || "Announcement"}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{clickAction && (
|
{/* Only way to dismiss a sticky alert. */}
|
||||||
<Button
|
<div
|
||||||
variant="outline"
|
role="button"
|
||||||
size="sm"
|
tabIndex={0}
|
||||||
className="shrink-0 text-foreground"
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.preventDefault();
|
||||||
e.preventDefault();
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
void onDismiss();
|
||||||
clickAction.go(navigate);
|
}}
|
||||||
}}
|
onKeyDown={(e) => {
|
||||||
>
|
if (e.key !== "Enter" && e.key !== " ") return;
|
||||||
{clickAction.label}
|
e.preventDefault();
|
||||||
</Button>
|
e.stopPropagation();
|
||||||
)}
|
void onDismiss();
|
||||||
</div>
|
}}
|
||||||
|
aria-label="Dismiss sticky announcement"
|
||||||
{/* Only way to dismiss a sticky alert — closing the details dialog
|
title="Dismiss"
|
||||||
no longer dismisses it. */}
|
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity"
|
||||||
<div
|
style={{ color: textColor }}
|
||||||
role="button"
|
>
|
||||||
tabIndex={0}
|
<X className="size-4" />
|
||||||
onClick={(e) => {
|
|
||||||
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 }}
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<AnnouncementDetailsDialog
|
export default function StickyAnnouncementBar() {
|
||||||
open={detailsOpen}
|
const { stickyAnnouncements, markSeen } = useClientNotifications();
|
||||||
onOpenChange={setDetailsOpen}
|
|
||||||
announcement={current}
|
const visible = stickyAnnouncements.slice(0, MAX_VISIBLE_STICKY);
|
||||||
resolveClickAction={resolveClickAction}
|
if (visible.length === 0) return null;
|
||||||
navigate={navigate}
|
|
||||||
/>
|
return (
|
||||||
</>
|
<div className="w-full flex flex-col">
|
||||||
|
{visible.map((announcement) => (
|
||||||
|
<StickyAnnouncementRow
|
||||||
|
key={announcement.notification_id}
|
||||||
|
announcement={announcement}
|
||||||
|
onDismiss={() => markSeen(announcement.notification_id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export const ADVERTISEMENT_TYPE_MAP = Object.fromEntries(
|
|||||||
// Whether an ad is image-only or carries badge/headline/description/CTAs
|
// Whether an ad is image-only or carries badge/headline/description/CTAs
|
||||||
// alongside the image — an explicit admin choice, decoupled from placement/format.
|
// alongside the image — an explicit admin choice, decoupled from placement/format.
|
||||||
export const CONTENT_MODES = [
|
export const CONTENT_MODES = [
|
||||||
{ value: "image", label: "Full image" },
|
{ value: "image", label: "Image Only" },
|
||||||
{ value: "content", label: "Content + image" },
|
{ value: "content", label: "Text with Image" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Statuses ───────────────────────────────────────────────────────────────
|
// ─── 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))
|
// Max number of CTAs allowed per advertisement (matches backend normalizeCtas slice(0,2))
|
||||||
export const MAX_CTAS = 2;
|
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;
|
||||||
@@ -156,7 +156,7 @@ export default function ArchivedAdvertisementsTable() {
|
|||||||
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
||||||
entity={restoreTarget}
|
entity={restoreTarget}
|
||||||
entityLabel="Ad"
|
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)}
|
onRestore={(a) => restoreAdvertisement(a?.advertisement_id)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleRestoreSuccess}
|
onSuccess={handleRestoreSuccess}
|
||||||
@@ -179,7 +179,7 @@ export default function ArchivedAdvertisementsTable() {
|
|||||||
onOpenChange={(v) => !v && setDeleteTarget(null)}
|
onOpenChange={(v) => !v && setDeleteTarget(null)}
|
||||||
entity={deleteTarget}
|
entity={deleteTarget}
|
||||||
entityLabel="Ad"
|
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)}
|
onDelete={(a) => permanentlyDeleteAdvertisement(a?.advertisement_id)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleDeleteSuccess}
|
onSuccess={handleDeleteSuccess}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
"relative rounded-xl bg-popover text-popover-foreground ring-1 ring-foreground/10 overflow-hidden",
|
|
||||||
imageSrc ? "sm:max-w-2xl" : "sm:max-w-md",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<Button type="button" variant="ghost" size="icon-sm" className="absolute top-2 right-2" tabIndex={-1}>
|
|
||||||
<XIcon />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className={imageSrc ? "grid sm:grid-cols-2" : ""}>
|
|
||||||
<div className="p-6 flex flex-col gap-3 min-w-0">
|
|
||||||
<div className="flex flex-col gap-2 text-left">
|
|
||||||
<p className="font-heading text-lg leading-none font-medium">{title || "Alert"}</p>
|
|
||||||
<p className="whitespace-pre-wrap text-left text-foreground text-sm">
|
|
||||||
{message || "Your message will appear here."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{linkLabel && (
|
|
||||||
<div className="mt-auto pt-4">
|
|
||||||
<Button type="button" className="self-start">
|
|
||||||
{linkLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{imageSrc && (
|
|
||||||
<div className="aspect-video sm:aspect-auto sm:h-96 bg-muted flex items-center justify-center overflow-hidden">
|
|
||||||
<img src={imageSrc} alt="" className="w-full h-full object-cover" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import { z } from "zod";
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import {
|
||||||
House, Plus, Trash2, ImagePlus, MapPin, FileText,
|
House, Plus, Trash2, ImagePlus, MapPin, FileText,
|
||||||
LayoutTemplate, CalendarClock, Check, ChevronLeft, ChevronRight,
|
CalendarClock, Check, ChevronLeft, ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||||
@@ -15,6 +15,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
|||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
import { resolveAssetSrc } from "@/utils/media.util";
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
|
import { isValidLink, LINK_ERROR } from "@/utils/link.util";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||||
import { PlacementSkeleton } from "@/components/generic/PlacementSkeleton";
|
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";
|
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
|
||||||
|
|
||||||
// ─── Schema ─────────────────────────────────────────────────────────────────
|
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||||
@@ -38,14 +39,14 @@ import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
|
|||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
placement: z.string().min(1, "Placement is required."),
|
placement: z.string().min(1, "Placement is required."),
|
||||||
content_mode: z.enum(["image", "content"]).default("image"),
|
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(),
|
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(),
|
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)
|
// Hard cap of 2 CTAs per advertisement (max() backs up the UI-level append guard)
|
||||||
ctas: z.array(z.object({
|
ctas: z.array(z.object({
|
||||||
label: z.string().min(1, "Label is required."),
|
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"),
|
variant: z.enum(["default", "outline"]).default("default"),
|
||||||
})).max(MAX_CTAS, `A maximum of ${MAX_CTAS} buttons is allowed.`).default([]),
|
})).max(MAX_CTAS, `A maximum of ${MAX_CTAS} buttons is allowed.`).default([]),
|
||||||
redirect_link: z.string().optional(),
|
redirect_link: z.string().optional(),
|
||||||
@@ -62,16 +63,14 @@ const schema = z.object({
|
|||||||
end_date: z.string().optional(),
|
end_date: z.string().optional(),
|
||||||
is_active: z.boolean().default(true),
|
is_active: z.boolean().default(true),
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
const format = PLACEMENT_MAP[data.placement]?.format;
|
// Image Only ads have no other click-through — the Link is their only
|
||||||
if (format === "hero" && (data.description?.length ?? 0) > 200) {
|
// destination, so it's required (Text with Image ads click through via
|
||||||
ctx.addIssue({
|
// their own CTA links instead, see redirect_link comment above).
|
||||||
code: z.ZodIssueCode.too_big,
|
if (data.content_mode !== "image") return;
|
||||||
maximum: 200,
|
if (!data.redirect_link?.trim()) {
|
||||||
type: "string",
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["redirect_link"], message: "Link is required." });
|
||||||
inclusive: true,
|
} else if (!isValidLink(data.redirect_link)) {
|
||||||
message: "Description must be 200 characters or fewer for hero placements.",
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["redirect_link"], message: LINK_ERROR });
|
||||||
path: ["description"],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,8 +80,8 @@ const schema = z.object({
|
|||||||
// page) for ads that don't link straight out to a URL.
|
// page) for ads that don't link straight out to a URL.
|
||||||
const ALL_STEPS = [
|
const ALL_STEPS = [
|
||||||
{ id: "placement", label: "Placement", icon: MapPin, description: "Choose where this ad appears — Dashboard, Tier Plans, or Course Details." },
|
{ 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: "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: "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: "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." },
|
{ id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this ad." },
|
||||||
];
|
];
|
||||||
@@ -94,11 +93,6 @@ function FieldError({ message }) {
|
|||||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CTA_VARIANTS = [
|
|
||||||
{ value: "default", label: "Primary" },
|
|
||||||
{ value: "outline", label: "Outline" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── Stepper header ─────────────────────────────────────────────────────────
|
// ─── Stepper header ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Stepper({ steps, stepIndex }) {
|
function Stepper({ steps, stepIndex }) {
|
||||||
@@ -109,9 +103,13 @@ function Stepper({ steps, stepIndex }) {
|
|||||||
const isActive = stepIndex === i;
|
const isActive = stepIndex === i;
|
||||||
const isDone = 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 (
|
return (
|
||||||
<div key={s.id} className="flex items-center flex-1 last:flex-none">
|
<div key={s.id} className="contents">
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1 shrink-0">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"h-8 w-8 rounded-full flex items-center justify-center border transition-colors shrink-0",
|
"h-8 w-8 rounded-full flex items-center justify-center border transition-colors shrink-0",
|
||||||
isDone && "bg-emerald-600 border-emerald-600 text-white",
|
isDone && "bg-emerald-600 border-emerald-600 text-white",
|
||||||
@@ -209,22 +207,30 @@ function StepImagePicker({ selectedAsset, imageUrl, setPickerOpen }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StepContent({
|
function StepContent({
|
||||||
register, errors, setValue, watch, description, format,
|
register, errors, setValue, watch, description,
|
||||||
selectedAsset, imageUrl, setPickerOpen,
|
selectedAsset, imageUrl, setPickerOpen,
|
||||||
ctaFields, appendCta, removeCta,
|
ctaFields, appendCta, removeCta,
|
||||||
}) {
|
}) {
|
||||||
const contentMode = watch("content_mode");
|
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 });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div className="mt-4">
|
||||||
<Label className="mb-1.5 block">Content type</Label>
|
<Label className="mb-1.5 block">Content type</Label>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{CONTENT_MODES.map((m) => (
|
{CONTENT_MODES.map((m) => (
|
||||||
<button
|
<button
|
||||||
key={m.value}
|
key={m.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => 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(
|
className={cn(
|
||||||
"rounded-lg border p-3 text-left transition-colors",
|
"rounded-lg border p-3 text-left transition-colors",
|
||||||
contentMode === m.value ? "border-primary bg-primary/5" : "hover:bg-muted/50"
|
contentMode === m.value ? "border-primary bg-primary/5" : "hover:bg-muted/50"
|
||||||
@@ -232,7 +238,7 @@ function StepContent({
|
|||||||
>
|
>
|
||||||
<p className="text-sm font-medium">{m.label}</p>
|
<p className="text-sm font-medium">{m.label}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{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."}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -257,8 +263,28 @@ function StepContent({
|
|||||||
{contentMode === "content" && (
|
{contentMode === "content" && (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Badge label</Label>
|
<Label className="mb-1.5 block">Badge labels</Label>
|
||||||
<Input placeholder="e.g. Ad" {...register("badge_label")} />
|
<div className="space-y-2">
|
||||||
|
{badgeLabelFields.map((_, index) => (
|
||||||
|
<div key={index} className="flex gap-2 items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input placeholder="e.g. Ad" {...register(`badge_labels.${index}`)} />
|
||||||
|
<FieldError message={errors.badge_labels?.[index]?.message} />
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={() => removeBadgeLabel(index)} aria-label="Remove">
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{badgeLabelFields.length < MAX_BADGE_LABELS ? (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={appendBadgeLabel}>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Add badge label
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">Maximum of {MAX_BADGE_LABELS} badges reached.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Headline</Label>
|
<Label className="mb-1.5 block">Headline</Label>
|
||||||
@@ -267,18 +293,19 @@ function StepContent({
|
|||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Description</Label>
|
<Label className="mb-1.5 block">Description</Label>
|
||||||
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
|
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
|
||||||
{format === "hero" && (
|
<div className="flex justify-between items-start mt-1">
|
||||||
<div className="flex justify-between items-start mt-1">
|
<FieldError message={errors.description?.message} />
|
||||||
<FieldError message={errors.description?.message} />
|
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 100 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||||
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
|
{description?.length ?? 0}/100
|
||||||
{description?.length ?? 0}/200
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Calls to action</Label>
|
<Label className="mb-1.5 block">Links</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
Up to {MAX_CTAS} buttons. The first is styled Primary, the second Outline.
|
||||||
|
</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{ctaFields.map((field, index) => (
|
{ctaFields.map((field, index) => (
|
||||||
<div key={field.id} className="flex gap-2 items-start">
|
<div key={field.id} className="flex gap-2 items-start">
|
||||||
@@ -290,21 +317,6 @@ function StepContent({
|
|||||||
<Input placeholder="Link (e.g. /courses)" {...register(`ctas.${index}.link`)} />
|
<Input placeholder="Link (e.g. /courses)" {...register(`ctas.${index}.link`)} />
|
||||||
<FieldError message={errors.ctas?.[index]?.link?.message} />
|
<FieldError message={errors.ctas?.[index]?.link?.message} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[120px]">
|
|
||||||
<Select
|
|
||||||
value={watch(`ctas.${index}.variant`) ?? "default"}
|
|
||||||
onValueChange={(v) => setValue(`ctas.${index}.variant`, v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Style" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{CTA_VARIANTS.map((v) => (
|
|
||||||
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
|
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -318,7 +330,7 @@ function StepContent({
|
|||||||
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
|
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
Add CTA
|
Add link
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
|
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
|
||||||
@@ -328,15 +340,19 @@ function StepContent({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Separator />
|
{contentMode === "image" && (
|
||||||
|
<>
|
||||||
<div>
|
<Separator />
|
||||||
<Label className="mb-1.5 block">Redirect link</Label>
|
<div>
|
||||||
<Input placeholder="e.g. /courses or https://example.com" {...register("redirect_link")} />
|
<Label className="mb-1.5 block">Link</Label>
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
<Input placeholder="e.g. /courses or https://example.com" {...register("redirect_link")} />
|
||||||
Where clicking the ad itself goes to. Leave blank to build an internal landing page in the next step instead.
|
<FieldError message={errors.redirect_link?.message} />
|
||||||
</p>
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
</div>
|
Where clicking the image goes to.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -451,10 +467,10 @@ function StepReview({ data, selectedAsset, imageUrl }) {
|
|||||||
|
|
||||||
<div className="border rounded-lg p-4 space-y-1">
|
<div className="border rounded-lg p-4 space-y-1">
|
||||||
<p className="text-sm font-medium mb-2">Content</p>
|
<p className="text-sm font-medium mb-2">Content</p>
|
||||||
<SummaryRow label="Type" value={data.content_mode === "content" ? "Content + image" : "Full image"} />
|
<SummaryRow label="Type" value={data.content_mode === "content" ? "Text with Image" : "Image Only"} />
|
||||||
{data.content_mode === "content" && (
|
{data.content_mode === "content" && (
|
||||||
<>
|
<>
|
||||||
<SummaryRow label="Badge" value={data.badge_label} />
|
<SummaryRow label="Badges" value={(data.badge_labels ?? []).filter(Boolean).join(", ")} />
|
||||||
<SummaryRow label="Headline" value={data.headline} />
|
<SummaryRow label="Headline" value={data.headline} />
|
||||||
<SummaryRow label="Description" value={data.description} />
|
<SummaryRow label="Description" value={data.description} />
|
||||||
</>
|
</>
|
||||||
@@ -476,26 +492,28 @@ function StepReview({ data, selectedAsset, imageUrl }) {
|
|||||||
|
|
||||||
{ctas.length > 0 && (
|
{ctas.length > 0 && (
|
||||||
<div className="border rounded-lg p-4 space-y-1">
|
<div className="border rounded-lg p-4 space-y-1">
|
||||||
<p className="text-sm font-medium mb-2">Calls to action</p>
|
<p className="text-sm font-medium mb-2">Links</p>
|
||||||
{ctas.map((c, i) => (
|
{ctas.map((c, i) => (
|
||||||
<SummaryRow key={i} label={c.label || "—"} value={c.link} />
|
<SummaryRow key={i} label={c.label || "—"} value={c.link} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border rounded-lg p-4 space-y-1">
|
{data.content_mode === "image" && (
|
||||||
<p className="text-sm font-medium mb-2">Click-through</p>
|
<div className="border rounded-lg p-4 space-y-1">
|
||||||
{data.redirect_link ? (
|
<p className="text-sm font-medium mb-2">Click-through</p>
|
||||||
<SummaryRow label="Redirect link" value={data.redirect_link} />
|
{data.redirect_link ? (
|
||||||
) : hasLandingPage ? (
|
<SummaryRow label="Link" value={data.redirect_link} />
|
||||||
<>
|
) : hasLandingPage ? (
|
||||||
<SummaryRow label="Page title" value={data.landing_page?.title} />
|
<>
|
||||||
<SummaryRow label="Links" value={(data.landing_page?.links ?? []).filter((l) => l.label || l.link).length || null} />
|
<SummaryRow label="Page title" value={data.landing_page?.title} />
|
||||||
</>
|
<SummaryRow label="Links" value={(data.landing_page?.links ?? []).filter((l) => l.label || l.link).length || null} />
|
||||||
) : (
|
</>
|
||||||
<p className="text-sm text-muted-foreground">No redirect link or landing page set — this ad won't link anywhere when clicked.</p>
|
) : (
|
||||||
)}
|
<p className="text-sm text-muted-foreground">No link or landing page set — this ad won't link anywhere when clicked.</p>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="border rounded-lg p-4 space-y-1">
|
<div className="border rounded-lg p-4 space-y-1">
|
||||||
<p className="text-sm font-medium mb-2">Scheduling & display</p>
|
<p className="text-sm font-medium mb-2">Scheduling & display</p>
|
||||||
@@ -533,7 +551,7 @@ export default function AddAdvertisement() {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
placement: undefined,
|
placement: undefined,
|
||||||
content_mode: "image",
|
content_mode: "image",
|
||||||
badge_label: "",
|
badge_labels: [],
|
||||||
headline: "",
|
headline: "",
|
||||||
description: "",
|
description: "",
|
||||||
image_asset_id: null,
|
image_asset_id: null,
|
||||||
@@ -558,6 +576,7 @@ export default function AddAdvertisement() {
|
|||||||
const placement = watch("placement");
|
const placement = watch("placement");
|
||||||
const description = watch("description");
|
const description = watch("description");
|
||||||
const redirectLink = watch("redirect_link");
|
const redirectLink = watch("redirect_link");
|
||||||
|
const contentMode = watch("content_mode");
|
||||||
const format = PLACEMENT_MAP[placement]?.format;
|
const format = PLACEMENT_MAP[placement]?.format;
|
||||||
|
|
||||||
const steps = useMemo(
|
const steps = useMemo(
|
||||||
@@ -567,6 +586,10 @@ export default function AddAdvertisement() {
|
|||||||
const stepIndex = Math.min(step, steps.length - 1);
|
const stepIndex = Math.min(step, steps.length - 1);
|
||||||
const current = steps[stepIndex];
|
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 = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Ads", to: "/admin/advertisements" },
|
{ label: "Ads", to: "/admin/advertisements" },
|
||||||
@@ -577,7 +600,7 @@ export default function AddAdvertisement() {
|
|||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
let fields = [];
|
let fields = [];
|
||||||
if (current.id === "placement") fields = ["placement"];
|
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;
|
const valid = fields.length ? await trigger(fields) : true;
|
||||||
if (valid) setStep((s) => Math.min(s + 1, steps.length - 1));
|
if (valid) setStep((s) => Math.min(s + 1, steps.length - 1));
|
||||||
@@ -637,7 +660,6 @@ export default function AddAdvertisement() {
|
|||||||
setValue={setValue}
|
setValue={setValue}
|
||||||
watch={watch}
|
watch={watch}
|
||||||
description={description}
|
description={description}
|
||||||
format={format}
|
|
||||||
selectedAsset={selectedAsset}
|
selectedAsset={selectedAsset}
|
||||||
imageUrl={imagePreviewUrl}
|
imageUrl={imagePreviewUrl}
|
||||||
setPickerOpen={setPickerOpen}
|
setPickerOpen={setPickerOpen}
|
||||||
@@ -679,7 +701,7 @@ export default function AddAdvertisement() {
|
|||||||
Create advertisement
|
Create advertisement
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button type="button" onClick={handleNext}>
|
<Button type="button" onClick={handleNext} disabled={linkStepInvalid}>
|
||||||
Next
|
Next
|
||||||
<ChevronRight className="size-4" />
|
<ChevronRight className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ function AdvertisementCard({ ad, position, onView, onEdit, onArchive, canMoveUp,
|
|||||||
|
|
||||||
<div className="p-3 flex flex-col gap-2 flex-1">
|
<div className="p-3 flex flex-col gap-2 flex-1">
|
||||||
<button type="button" onClick={onView} className="text-left">
|
<button type="button" onClick={onView} className="text-left">
|
||||||
<p className="text-sm font-medium leading-snug truncate hover:underline">{ad.headline || ad.badge_label || "Untitled ad"}</p>
|
<p className="text-sm font-medium leading-snug truncate hover:underline">{ad.headline || ad.badge_labels?.[0] || "Untitled ad"}</p>
|
||||||
{placementMeta ? (
|
{placementMeta ? (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{placementMeta.pageLabel} — {placementMeta.slotLabel}</p>
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">{placementMeta.pageLabel} — {placementMeta.slotLabel}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -305,7 +305,7 @@ function AdvertisementCard({ ad, position, onView, onEdit, onArchive, canMoveUp,
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Archive this ad?</AlertDialogTitle>
|
<AlertDialogTitle>Archive this ad?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
"{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.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
|||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
import { resolveAssetSrc } from "@/utils/media.util";
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
|
import { isValidLink, LINK_ERROR } from "@/utils/link.util";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { DateTimePicker } from "@/components/ui/date-time-picker";
|
||||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
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";
|
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
|
||||||
|
|
||||||
// ─── Schema ─────────────────────────────────────────────────────────────────
|
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||||
@@ -32,14 +33,14 @@ import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
|
|||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
placement: z.string().min(1, "Placement is required."),
|
placement: z.string().min(1, "Placement is required."),
|
||||||
content_mode: z.enum(["image", "content"]).default("image"),
|
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(),
|
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(),
|
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)
|
// Hard cap of 2 CTAs per advertisement (max() backs up the UI-level append guard)
|
||||||
ctas: z.array(z.object({
|
ctas: z.array(z.object({
|
||||||
label: z.string().min(1, "Label is required."),
|
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"),
|
variant: z.enum(["default", "outline"]).default("default"),
|
||||||
})).max(MAX_CTAS, `A maximum of ${MAX_CTAS} buttons is allowed.`).default([]),
|
})).max(MAX_CTAS, `A maximum of ${MAX_CTAS} buttons is allowed.`).default([]),
|
||||||
redirect_link: z.string().optional(),
|
redirect_link: z.string().optional(),
|
||||||
@@ -56,16 +57,14 @@ const schema = z.object({
|
|||||||
end_date: z.string().optional(),
|
end_date: z.string().optional(),
|
||||||
is_active: z.boolean().default(true),
|
is_active: z.boolean().default(true),
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
const format = PLACEMENT_MAP[data.placement]?.format;
|
// Image Only ads have no other click-through — the Link is their only
|
||||||
if (format === "hero" && (data.description?.length ?? 0) > 200) {
|
// destination, so it's required (Text with Image ads click through via
|
||||||
ctx.addIssue({
|
// their own CTA links instead).
|
||||||
code: z.ZodIssueCode.too_big,
|
if (data.content_mode !== "image") return;
|
||||||
maximum: 200,
|
if (!data.redirect_link?.trim()) {
|
||||||
type: "string",
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["redirect_link"], message: "Link is required." });
|
||||||
inclusive: true,
|
} else if (!isValidLink(data.redirect_link)) {
|
||||||
message: "Description must be 200 characters or fewer for hero placements.",
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["redirect_link"], message: LINK_ERROR });
|
||||||
path: ["description"],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,11 +89,6 @@ function SectionCard({ title, description, children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CTA_VARIANTS = [
|
|
||||||
{ value: "default", label: "Primary" },
|
|
||||||
{ value: "outline", label: "Outline" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── Page ───────────────────────────────────────────────────────────────────
|
// ─── Page ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function EditAdvertisement() {
|
export default function EditAdvertisement() {
|
||||||
@@ -124,7 +118,7 @@ export default function EditAdvertisement() {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
placement: undefined,
|
placement: undefined,
|
||||||
content_mode: "image",
|
content_mode: "image",
|
||||||
badge_label: "",
|
badge_labels: [],
|
||||||
headline: "",
|
headline: "",
|
||||||
description: "",
|
description: "",
|
||||||
image_asset_id: null,
|
image_asset_id: null,
|
||||||
@@ -145,6 +139,9 @@ export default function EditAdvertisement() {
|
|||||||
const placement = watch("placement");
|
const placement = watch("placement");
|
||||||
const description = watch("description");
|
const description = watch("description");
|
||||||
const contentMode = watch("content_mode");
|
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 redirectLink = watch("redirect_link");
|
||||||
const format = PLACEMENT_MAP[placement]?.format;
|
const format = PLACEMENT_MAP[placement]?.format;
|
||||||
|
|
||||||
@@ -172,7 +169,7 @@ export default function EditAdvertisement() {
|
|||||||
reset({
|
reset({
|
||||||
placement: ad.placement ?? undefined,
|
placement: ad.placement ?? undefined,
|
||||||
content_mode: ad.content_mode ?? "image",
|
content_mode: ad.content_mode ?? "image",
|
||||||
badge_label: ad.badge_label ?? "",
|
badge_labels: ad.badge_labels ?? [],
|
||||||
headline: ad.headline ?? "",
|
headline: ad.headline ?? "",
|
||||||
description: ad.description ?? "",
|
description: ad.description ?? "",
|
||||||
image_asset_id: ad.image?.asset_id ?? null,
|
image_asset_id: ad.image?.asset_id ?? null,
|
||||||
@@ -264,13 +261,18 @@ export default function EditAdvertisement() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="Content" description="Full image, or content with badge, headline, description, and CTAs.">
|
<SectionCard title="Type" description="Image Only, or Text with Image with badges, headline, description, and links.">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{CONTENT_MODES.map((m) => (
|
{CONTENT_MODES.map((m) => (
|
||||||
<button
|
<button
|
||||||
key={m.value}
|
key={m.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => 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(
|
className={cn(
|
||||||
"rounded-lg border p-3 text-left transition-colors",
|
"rounded-lg border p-3 text-left transition-colors",
|
||||||
contentMode === m.value ? "border-primary bg-primary/5" : "hover:bg-muted/50"
|
contentMode === m.value ? "border-primary bg-primary/5" : "hover:bg-muted/50"
|
||||||
@@ -278,7 +280,7 @@ export default function EditAdvertisement() {
|
|||||||
>
|
>
|
||||||
<p className="text-sm font-medium">{m.label}</p>
|
<p className="text-sm font-medium">{m.label}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{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."}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -297,8 +299,28 @@ export default function EditAdvertisement() {
|
|||||||
{contentMode === "content" && (
|
{contentMode === "content" && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Badge label</Label>
|
<Label className="mb-1.5 block">Badge labels</Label>
|
||||||
<Input placeholder="e.g. Ad" {...register("badge_label")} />
|
<div className="space-y-2">
|
||||||
|
{badgeLabelFields.map((_, index) => (
|
||||||
|
<div key={index} className="flex gap-2 items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input placeholder="e.g. Ad" {...register(`badge_labels.${index}`)} />
|
||||||
|
<FieldError message={errors.badge_labels?.[index]?.message} />
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={() => removeBadgeLabel(index)} aria-label="Remove">
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{badgeLabelFields.length < MAX_BADGE_LABELS ? (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={appendBadgeLabel}>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Add badge label
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">Maximum of {MAX_BADGE_LABELS} badges reached.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Headline</Label>
|
<Label className="mb-1.5 block">Headline</Label>
|
||||||
@@ -307,14 +329,12 @@ export default function EditAdvertisement() {
|
|||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Description</Label>
|
<Label className="mb-1.5 block">Description</Label>
|
||||||
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
|
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
|
||||||
{format === "hero" && (
|
<div className="flex justify-between items-start mt-1">
|
||||||
<div className="flex justify-between items-start mt-1">
|
<FieldError message={errors.description?.message} />
|
||||||
<FieldError message={errors.description?.message} />
|
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 100 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||||
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
|
{description?.length ?? 0}/100
|
||||||
{description?.length ?? 0}/200
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -349,8 +369,8 @@ export default function EditAdvertisement() {
|
|||||||
|
|
||||||
{contentMode === "content" && (
|
{contentMode === "content" && (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Calls to action"
|
title="Links"
|
||||||
description={`Up to ${MAX_CTAS} buttons shown on the placement. Each button can be styled as Primary or Outline.`}
|
description={`Up to ${MAX_CTAS} buttons shown on the placement. The first is styled Primary, the second Outline.`}
|
||||||
>
|
>
|
||||||
{ctaFields.map((field, index) => (
|
{ctaFields.map((field, index) => (
|
||||||
<div key={field.id} className="flex gap-2 items-start">
|
<div key={field.id} className="flex gap-2 items-start">
|
||||||
@@ -362,21 +382,6 @@ export default function EditAdvertisement() {
|
|||||||
<Input placeholder="Link (e.g. /courses)" {...register(`ctas.${index}.link`)} />
|
<Input placeholder="Link (e.g. /courses)" {...register(`ctas.${index}.link`)} />
|
||||||
<FieldError message={errors.ctas?.[index]?.link?.message} />
|
<FieldError message={errors.ctas?.[index]?.link?.message} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[120px]">
|
|
||||||
<Select
|
|
||||||
value={watch(`ctas.${index}.variant`) ?? "default"}
|
|
||||||
onValueChange={(v) => setValue(`ctas.${index}.variant`, v, { shouldDirty: true })}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Style" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{CTA_VARIANTS.map((v) => (
|
|
||||||
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
|
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -390,7 +395,7 @@ export default function EditAdvertisement() {
|
|||||||
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
|
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
Add CTA
|
Add link
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
|
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
|
||||||
@@ -398,51 +403,54 @@ export default function EditAdvertisement() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SectionCard title="Click-through" description="Where clicking the ad itself goes to.">
|
{contentMode === "image" && (
|
||||||
<div>
|
<SectionCard title="Click-through" description="Where clicking the image goes to.">
|
||||||
<Label className="mb-1.5 block">Redirect link</Label>
|
<div>
|
||||||
<Input placeholder="e.g. /courses or https://example.com" {...register("redirect_link")} />
|
<Label className="mb-1.5 block">Link</Label>
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
<Input placeholder="e.g. /courses or https://example.com" {...register("redirect_link")} />
|
||||||
Leave blank to use the internal landing page below instead.
|
<FieldError message={errors.redirect_link?.message} />
|
||||||
</p>
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
</div>
|
Where clicking the image goes to.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!redirectLink?.trim() && (
|
{!redirectLink?.trim() && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Page title</Label>
|
<Label className="mb-1.5 block">Page title</Label>
|
||||||
<Input placeholder="e.g. Why upgrade to Pro" {...register("landing_page.title")} />
|
<Input placeholder="e.g. Why upgrade to Pro" {...register("landing_page.title")} />
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Page description</Label>
|
|
||||||
<Textarea rows={2} placeholder="Short summary shown under the title" {...register("landing_page.description")} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Body</Label>
|
|
||||||
<Textarea rows={6} placeholder="Main page content" {...register("landing_page.body")} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Links</Label>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{linkFields.map((field, index) => (
|
|
||||||
<div key={field.id} className="flex gap-2 items-start">
|
|
||||||
<Input placeholder="Label" className="flex-1" {...register(`landing_page.links.${index}.label`)} />
|
|
||||||
<Input placeholder="URL or path" className="flex-1" {...register(`landing_page.links.${index}.link`)} />
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLink(index)} aria-label="Remove">
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => appendLink({ label: "", link: "" })}>
|
|
||||||
<Plus className="size-3.5" />
|
|
||||||
Add link
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
</>
|
<Label className="mb-1.5 block">Page description</Label>
|
||||||
)}
|
<Textarea rows={2} placeholder="Short summary shown under the title" {...register("landing_page.description")} />
|
||||||
</SectionCard>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Body</Label>
|
||||||
|
<Textarea rows={6} placeholder="Main page content" {...register("landing_page.body")} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Links</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{linkFields.map((field, index) => (
|
||||||
|
<div key={field.id} className="flex gap-2 items-start">
|
||||||
|
<Input placeholder="Label" className="flex-1" {...register(`landing_page.links.${index}.label`)} />
|
||||||
|
<Input placeholder="URL or path" className="flex-1" {...register(`landing_page.links.${index}.link`)} />
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={() => removeLink(index)} aria-label="Remove">
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => appendLink({ label: "", link: "" })}>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Add link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
<SectionCard title="Scheduling" description="Optional start and end dates for this placement.">
|
<SectionCard title="Scheduling" description="Optional start and end dates for this placement.">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export default function ViewAdvertisement() {
|
|||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold tracking-tight">
|
<h1 className="text-xl font-semibold tracking-tight">
|
||||||
{advertisement.headline || advertisement.badge_label || "Untitled ad"}
|
{advertisement.headline || advertisement.badge_labels?.[0] || "Untitled ad"}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||||
<Badge variant="secondary" className="gap-1">
|
<Badge variant="secondary" className="gap-1">
|
||||||
@@ -153,7 +153,7 @@ export default function ViewAdvertisement() {
|
|||||||
|
|
||||||
{/* ── Content ────────────────────────────────────────────────── */}
|
{/* ── Content ────────────────────────────────────────────────── */}
|
||||||
<SectionCard title="Content">
|
<SectionCard title="Content">
|
||||||
<Field label="Badge label">{advertisement.badge_label || "—"}</Field>
|
<Field label="Badge labels">{(advertisement.badge_labels ?? []).join(", ") || "—"}</Field>
|
||||||
<Field label="Headline">{advertisement.headline || "—"}</Field>
|
<Field label="Headline">{advertisement.headline || "—"}</Field>
|
||||||
<Field label="Description">{advertisement.description || "—"}</Field>
|
<Field label="Description">{advertisement.description || "—"}</Field>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ import { useForm } from "react-hook-form";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { format } from "date-fns";
|
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 { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
import { TIER_COLOR_OPTIONS, getTierColor, getContrastText } from "@/utils/tierColors";
|
import { TIER_COLOR_OPTIONS, getTierColor, getContrastText } from "@/utils/tierColors";
|
||||||
import { normalizeExternalUrl } from "@/components/generic/notificationDisplay";
|
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
|
// Internal paths ("/course/123") pass through untouched — everything else
|
||||||
// gets a scheme so the saved URL always matches what goToLink() will open,
|
// 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({
|
const schema = z.object({
|
||||||
title: z.string().min(1, "Title is required."),
|
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_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { message: "Please select a target." }),
|
||||||
target_id: z.string().nullable().optional(),
|
target_id: z.string().nullable().optional(),
|
||||||
show_in_sticky: z.boolean().optional(),
|
show_in_sticky: z.boolean().optional(),
|
||||||
show_in_notifications: z.boolean().optional(),
|
show_in_notifications: z.boolean().optional(),
|
||||||
link_mode: z.enum(["info", "link"]).optional(),
|
|
||||||
link_url: z.string().trim().optional(),
|
link_url: z.string().trim().optional(),
|
||||||
link_label: z.string().trim().optional(),
|
|
||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
image_asset_id: z.string().nullable().optional(),
|
|
||||||
start_date: z.string().optional(),
|
start_date: z.string().optional(),
|
||||||
end_date: z.string().optional(),
|
end_date: z.string().optional(),
|
||||||
}).superRefine((data, ctx) => {
|
}).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({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
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"],
|
path: ["link_url"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -89,9 +100,8 @@ const schema = z.object({
|
|||||||
// ─── Steps config ───────────────────────────────────────────────────────────
|
// ─── Steps config ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ label: "Content", description: "Title & message" },
|
{ label: "Content", description: "What it says & how it's shown" },
|
||||||
{ label: "Target", description: "Who receives it" },
|
{ label: "Target", description: "Who receives it" },
|
||||||
{ label: "Display", description: "Where it shows & schedule" },
|
|
||||||
{ label: "Review", description: "Confirm & save" },
|
{ label: "Review", description: "Confirm & save" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -116,50 +126,6 @@ function SectionCard({ title, description, children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertImagePicker({ selectedAsset, onPick, onRemove }) {
|
|
||||||
if (!selectedAsset) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onPick}
|
|
||||||
className="w-full sm:w-48 aspect-video rounded-lg border border-dashed flex flex-col items-center justify-center gap-1.5 text-muted-foreground hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<ImagePlus className="size-4" />
|
|
||||||
<span className="text-xs">Select an image</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageUrl = resolveAssetSrc(selectedAsset);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full sm:w-48 rounded-lg overflow-hidden border aspect-video group">
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt={selectedAsset.display_name}
|
|
||||||
className="w-full h-full object-cover cursor-pointer"
|
|
||||||
onClick={onPick}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
onClick={onPick}
|
|
||||||
className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center cursor-pointer"
|
|
||||||
>
|
|
||||||
<span className="text-white text-xs opacity-0 group-hover:opacity-100">Change image</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="absolute top-1 right-1 size-6"
|
|
||||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
|
||||||
aria-label="Remove image"
|
|
||||||
>
|
|
||||||
<X className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepIndicator({ steps, current, onStepClick }) {
|
function StepIndicator({ steps, current, onStepClick }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start w-full mb-8">
|
<div className="flex items-start w-full mb-8">
|
||||||
@@ -219,8 +185,6 @@ export default function AddNotificationBroadcast() {
|
|||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [targetLabel, setTargetLabel] = useState(null);
|
const [targetLabel, setTargetLabel] = useState(null);
|
||||||
const [imageAsset, setImageAsset] = useState(null);
|
|
||||||
const [imagePickerOpen, setImagePickerOpen] = useState(false);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -238,11 +202,8 @@ export default function AddNotificationBroadcast() {
|
|||||||
target_id: null,
|
target_id: null,
|
||||||
show_in_sticky: false,
|
show_in_sticky: false,
|
||||||
show_in_notifications: true,
|
show_in_notifications: true,
|
||||||
link_mode: "info",
|
|
||||||
link_url: "",
|
link_url: "",
|
||||||
link_label: "",
|
|
||||||
color: "indigo",
|
color: "indigo",
|
||||||
image_asset_id: null,
|
|
||||||
start_date: "",
|
start_date: "",
|
||||||
end_date: "",
|
end_date: "",
|
||||||
},
|
},
|
||||||
@@ -255,7 +216,6 @@ export default function AddNotificationBroadcast() {
|
|||||||
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
|
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
|
||||||
const showInSticky = watch("show_in_sticky");
|
const showInSticky = watch("show_in_sticky");
|
||||||
const showInNotifications = watch("show_in_notifications");
|
const showInNotifications = watch("show_in_notifications");
|
||||||
const linkMode = watch("link_mode");
|
|
||||||
const color = watch("color");
|
const color = watch("color");
|
||||||
const startDate = watch("start_date");
|
const startDate = watch("start_date");
|
||||||
const endDate = watch("end_date");
|
const endDate = watch("end_date");
|
||||||
@@ -270,31 +230,25 @@ export default function AddNotificationBroadcast() {
|
|||||||
// fields from earlier steps.
|
// fields from earlier steps.
|
||||||
const goToStep = async (target) => {
|
const goToStep = async (target) => {
|
||||||
if (target > 0) {
|
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 (!valid) { setCurrentStep(0); return; }
|
||||||
}
|
}
|
||||||
if (target > 1) {
|
if (target > 1) {
|
||||||
const valid = await trigger(["target_type", "target_id"]);
|
const valid = await trigger(["target_type", "target_id"]);
|
||||||
if (!valid) { setCurrentStep(1); return; }
|
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);
|
setCurrentStep(target);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveBroadcast = async (values, { publish = false } = {}) => {
|
const saveBroadcast = async (values, { publish = false } = {}) => {
|
||||||
const { link_mode, ...rest } = values;
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...rest,
|
...values,
|
||||||
|
message: values.message?.trim() || null,
|
||||||
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
|
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
|
||||||
show_in_sticky: values.show_in_sticky ?? false,
|
show_in_sticky: values.show_in_sticky ?? false,
|
||||||
show_in_notifications: values.show_in_notifications ?? true,
|
show_in_notifications: values.show_in_notifications ?? true,
|
||||||
link_url: (values.show_in_sticky && link_mode === "link") ? normalizeLinkUrl(values.link_url.trim()) : null,
|
link_url: values.link_url?.trim() ? normalizeLinkUrl(values.link_url.trim()) : null,
|
||||||
link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
|
|
||||||
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
|
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,
|
start_date: values.start_date || null,
|
||||||
end_date: values.end_date || null,
|
end_date: values.end_date || null,
|
||||||
createdBy: user?.user_id ?? null,
|
createdBy: user?.user_id ?? null,
|
||||||
@@ -330,66 +284,22 @@ export default function AddNotificationBroadcast() {
|
|||||||
|
|
||||||
{/* ── Step 0: Content ── */}
|
{/* ── Step 0: Content ── */}
|
||||||
{currentStep === 0 && (
|
{currentStep === 0 && (
|
||||||
<SectionCard title="Content" description="What admins and/or users will see.">
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Title</Label>
|
|
||||||
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
|
|
||||||
<FieldError message={errors.title?.message} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Message</Label>
|
|
||||||
<Textarea rows={4} placeholder="Full alert text" {...register("message")} />
|
|
||||||
<FieldError message={errors.message?.message} />
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Step 1: Target ── */}
|
|
||||||
{currentStep === 1 && (
|
|
||||||
<SectionCard title="Target" description="Who receives this alert when it's sent.">
|
|
||||||
<div>
|
|
||||||
<Select
|
|
||||||
value={targetType}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
|
|
||||||
setValue("target_id", null, { shouldDirty: true });
|
|
||||||
setTargetLabel(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a target" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{TARGET_TYPE_OPTIONS.map((t) => (
|
|
||||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError message={errors.target_type?.message} />
|
|
||||||
{targetType && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
|
||||||
{TARGET_TYPE_MAP[targetType]?.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{needsTarget && (
|
|
||||||
<div>
|
|
||||||
<BroadcastTargetPicker
|
|
||||||
targetType={targetType}
|
|
||||||
value={targetId}
|
|
||||||
onChange={(id) => setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
|
|
||||||
onLabelResolved={setTargetLabel}
|
|
||||||
/>
|
|
||||||
<FieldError message={errors.target_id?.message} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SectionCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Step 2: Display ── */}
|
|
||||||
{currentStep === 2 && (
|
|
||||||
<>
|
<>
|
||||||
|
<SectionCard title="Content" description="What admins and/or users will see.">
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Title</Label>
|
||||||
|
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
|
||||||
|
<FieldError message={errors.title?.message} />
|
||||||
|
</div>
|
||||||
|
{showInNotifications && (
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Message</Label>
|
||||||
|
<Textarea rows={4} placeholder="Full alert text" {...register("message")} />
|
||||||
|
<FieldError message={errors.message?.message} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="Display" description="Where clients/admins can see this alert.">
|
<SectionCard title="Display" description="Where clients/admins can see this alert.">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -399,10 +309,7 @@ export default function AddNotificationBroadcast() {
|
|||||||
onCheckedChange={(v) => {
|
onCheckedChange={(v) => {
|
||||||
const checked = v === true;
|
const checked = v === true;
|
||||||
setValue("show_in_sticky", checked, { shouldValidate: true, shouldDirty: true });
|
setValue("show_in_sticky", checked, { shouldValidate: true, shouldDirty: true });
|
||||||
// Sticky-only alerts vanish forever once dismissed (seen=true drops
|
if (checked) setValue("show_in_notifications", false, { shouldValidate: true, shouldDirty: true });
|
||||||
// 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 });
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
||||||
@@ -414,18 +321,16 @@ export default function AddNotificationBroadcast() {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
id="show_in_notifications"
|
id="show_in_notifications"
|
||||||
checked={showInNotifications === true}
|
checked={showInNotifications === true}
|
||||||
disabled={showInSticky === true}
|
onCheckedChange={(v) => {
|
||||||
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
|
const checked = v === true;
|
||||||
|
setValue("show_in_notifications", checked, { shouldValidate: true, shouldDirty: true });
|
||||||
|
if (checked) setValue("show_in_sticky", false, { shouldValidate: true, shouldDirty: true });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="show_in_notifications" className={showInSticky ? "text-muted-foreground" : "cursor-pointer"}>
|
<Label htmlFor="show_in_notifications" className="cursor-pointer">
|
||||||
Show in Notifications
|
Show in Notifications
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
{showInSticky && (
|
|
||||||
<p className="text-xs text-muted-foreground pl-7">
|
|
||||||
Required while sticky is on, so it stays visible after being dismissed.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -476,103 +381,79 @@ export default function AddNotificationBroadcast() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{showInSticky && (
|
{showInSticky && (
|
||||||
<SectionCard title="Layout image" description="Shown alongside the message when this alert is opened from the sticky banner. Optional.">
|
<SectionCard title="Link" description="Where the sticky banner goes when someone clicks it. Optional.">
|
||||||
<AlertImagePicker
|
<div>
|
||||||
selectedAsset={imageAsset}
|
<Label className="mb-1.5 block">Link URL</Label>
|
||||||
onPick={() => setImagePickerOpen(true)}
|
<Input placeholder="https://example.com or /course/123" {...register("link_url")} />
|
||||||
onRemove={() => {
|
<FieldError message={errors.link_url?.message} />
|
||||||
setImageAsset(null);
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
setValue("image_asset_id", null, { shouldDirty: true });
|
Internal paths (starting with /) navigate in-app; anything else opens in a new tab. Leave blank for an informational banner with no click action.
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SectionCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showInSticky && (
|
|
||||||
<SectionCard title="On Open" description="What happens when someone opens this alert from the sticky banner.">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={linkMode !== "link" ? "secondary" : "outline"}
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => setValue("link_mode", "info", { shouldDirty: true })}
|
|
||||||
>
|
|
||||||
Text info only
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={linkMode === "link" ? "secondary" : "outline"}
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => setValue("link_mode", "link", { shouldDirty: true })}
|
|
||||||
>
|
|
||||||
Include a link
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{linkMode === "link" ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Link URL</Label>
|
|
||||||
<Input placeholder="https://example.com or /course/123" {...register("link_url")} />
|
|
||||||
<FieldError message={errors.link_url?.message} />
|
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
|
||||||
Internal paths (starting with /) navigate in-app; anything else opens in a new tab.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Button label</Label>
|
|
||||||
<Input placeholder="e.g. Shop now" {...register("link_label")} />
|
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
|
||||||
Shown as a button right after the title in the sticky banner. Defaults to "Open Link".
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
The full-content view will show just the title and message, with no action button.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showInSticky && (
|
{showInSticky && (
|
||||||
<SectionCard title="Preview" description="What this alert looks like when opened from the sticky banner.">
|
<SectionCard title="Preview" description="What the sticky banner looks like. Clicking it redirects to the link above, if set.">
|
||||||
<div className="space-y-3">
|
<div
|
||||||
<div className="space-y-1.5">
|
className="w-full flex items-center justify-center rounded-md px-3 py-2 text-sm font-semibold"
|
||||||
<p className="text-xs text-muted-foreground">Sticky banner (collapsed):</p>
|
style={{
|
||||||
<div
|
backgroundColor: getTierColor(color || "indigo").swatch,
|
||||||
className="w-full flex items-center justify-center gap-3 rounded-md px-3 py-2 text-sm font-semibold"
|
color: getContrastText(getTierColor(color || "indigo").swatch, color || "indigo"),
|
||||||
style={{
|
}}
|
||||||
backgroundColor: getTierColor(color || "indigo").swatch,
|
>
|
||||||
color: getContrastText(getTierColor(color || "indigo").swatch, color || "indigo"),
|
<span className="truncate">{watch("title") || "Sticky banner preview"}</span>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="truncate">{watch("title") || "Sticky banner preview"}</span>
|
|
||||||
{linkMode === "link" && (
|
|
||||||
<Button type="button" variant="outline" size="sm" className="shrink-0 text-foreground">
|
|
||||||
{watch("link_label") || "Open Link"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<p className="text-xs text-muted-foreground">When clicked (full content):</p>
|
|
||||||
<AlertLayoutPreview
|
|
||||||
title={watch("title")}
|
|
||||||
message={watch("message")}
|
|
||||||
imageAsset={imageAsset}
|
|
||||||
linkLabel={linkMode === "link" ? (watch("link_label") || "Open Link") : null}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Step 3: Review ── */}
|
{/* ── Step 1: Target ── */}
|
||||||
{currentStep === 3 && (
|
{currentStep === 1 && (
|
||||||
|
<SectionCard title="Target" description="Who receives this alert when it's sent.">
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
value={targetType}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
|
||||||
|
setValue("target_id", null, { shouldDirty: true });
|
||||||
|
setTargetLabel(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a target" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TARGET_TYPE_OPTIONS.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError message={errors.target_type?.message} />
|
||||||
|
{targetType && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
|
{TARGET_TYPE_MAP[targetType]?.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{needsTarget && (
|
||||||
|
<div>
|
||||||
|
<BroadcastTargetPicker
|
||||||
|
targetType={targetType}
|
||||||
|
value={targetId}
|
||||||
|
onChange={(id) => setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
|
||||||
|
onLabelResolved={setTargetLabel}
|
||||||
|
/>
|
||||||
|
<FieldError message={errors.target_id?.message} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 2: Review ── */}
|
||||||
|
{currentStep === 2 && (
|
||||||
<>
|
<>
|
||||||
<SectionCard title="Content" description="Confirm everything looks right before saving the draft.">
|
<SectionCard title="Content" description="Confirm everything looks right before saving the draft.">
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
@@ -580,10 +461,12 @@ export default function AddNotificationBroadcast() {
|
|||||||
<p className="text-xs text-muted-foreground">Title</p>
|
<p className="text-xs text-muted-foreground">Title</p>
|
||||||
<p className="font-medium">{watch("title") || "—"}</p>
|
<p className="font-medium">{watch("title") || "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{showInNotifications && (
|
||||||
<p className="text-xs text-muted-foreground">Message</p>
|
<div>
|
||||||
<p className="font-medium whitespace-pre-wrap">{watch("message") || "—"}</p>
|
<p className="text-xs text-muted-foreground">Message</p>
|
||||||
</div>
|
<p className="font-medium whitespace-pre-wrap">{watch("message") || "—"}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
@@ -619,29 +502,21 @@ export default function AddNotificationBroadcast() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showInSticky && (
|
{showInSticky && (
|
||||||
<div
|
<>
|
||||||
className="w-full flex items-center justify-center gap-3 rounded-md px-3 py-2 text-sm font-semibold"
|
<div
|
||||||
style={{ backgroundColor: getTierColor(color || "indigo").swatch, color: getContrastText(getTierColor(color || "indigo").swatch, color || "indigo") }}
|
className="w-full flex items-center justify-center rounded-md px-3 py-2 text-sm font-semibold"
|
||||||
>
|
style={{ backgroundColor: getTierColor(color || "indigo").swatch, color: getContrastText(getTierColor(color || "indigo").swatch, color || "indigo") }}
|
||||||
<span className="truncate">{watch("title") || "Sticky banner preview"}</span>
|
>
|
||||||
{linkMode === "link" && (
|
<span className="truncate">{watch("title") || "Sticky banner preview"}</span>
|
||||||
<Button type="button" variant="outline" size="sm" className="shrink-0 text-foreground">
|
</div>
|
||||||
{watch("link_label") || "Open Link"}
|
<p className="text-xs text-muted-foreground">
|
||||||
</Button>
|
{watch("link_url")
|
||||||
)}
|
? <>Clicking redirects to <span className="font-medium text-foreground">{watch("link_url")}</span>.</>
|
||||||
</div>
|
: "No link — informational only, not clickable."}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{showInSticky && (
|
|
||||||
<SectionCard title="On Open">
|
|
||||||
<p className="text-sm">
|
|
||||||
{linkMode === "link"
|
|
||||||
? <>Opens <span className="font-medium">{watch("link_url") || "—"}</span> via a “{watch("link_label") || "Open Link"}” button.</>
|
|
||||||
: "Text info only — no action button."}
|
|
||||||
</p>
|
|
||||||
</SectionCard>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -689,16 +564,6 @@ export default function AddNotificationBroadcast() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{unsavedChangesDialog}
|
{unsavedChangesDialog}
|
||||||
|
|
||||||
<AssetPickerSheet
|
|
||||||
open={imagePickerOpen}
|
|
||||||
onOpenChange={setImagePickerOpen}
|
|
||||||
fileType="image"
|
|
||||||
onSelect={(asset) => {
|
|
||||||
setImageAsset(asset);
|
|
||||||
setValue("image_asset_id", String(asset.asset_id), { shouldDirty: true });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ import { useForm } from "react-hook-form";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { format } from "date-fns";
|
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 { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
import { TIER_COLOR_OPTIONS, getTierColor, getContrastText } from "@/utils/tierColors";
|
import { TIER_COLOR_OPTIONS, getTierColor, getContrastText } from "@/utils/tierColors";
|
||||||
import { normalizeExternalUrl } from "@/components/generic/notificationDisplay";
|
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
|
// Internal paths ("/course/123") pass through untouched — everything else
|
||||||
// gets a scheme so the saved URL always matches what goToLink() will open,
|
// 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({
|
const schema = z.object({
|
||||||
title: z.string().min(1, "Title is required."),
|
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_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { message: "Please select a target." }),
|
||||||
target_id: z.string().nullable().optional(),
|
target_id: z.string().nullable().optional(),
|
||||||
show_in_sticky: z.boolean().optional(),
|
show_in_sticky: z.boolean().optional(),
|
||||||
show_in_notifications: z.boolean().optional(),
|
show_in_notifications: z.boolean().optional(),
|
||||||
link_mode: z.enum(["info", "link"]).optional(),
|
|
||||||
link_url: z.string().trim().optional(),
|
link_url: z.string().trim().optional(),
|
||||||
link_label: z.string().trim().optional(),
|
|
||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
image_asset_id: z.string().nullable().optional(),
|
|
||||||
start_date: z.string().optional(),
|
start_date: z.string().optional(),
|
||||||
end_date: z.string().optional(),
|
end_date: z.string().optional(),
|
||||||
}).superRefine((data, ctx) => {
|
}).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({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
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"],
|
path: ["link_url"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -93,9 +104,8 @@ const schema = z.object({
|
|||||||
// ─── Steps config ───────────────────────────────────────────────────────────
|
// ─── Steps config ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ label: "Content", description: "Title & message" },
|
{ label: "Content", description: "What it says & how it's shown" },
|
||||||
{ label: "Target", description: "Who receives it" },
|
{ label: "Target", description: "Who receives it" },
|
||||||
{ label: "Display", description: "Where it shows & schedule" },
|
|
||||||
{ label: "Review", description: "Confirm & save" },
|
{ label: "Review", description: "Confirm & save" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -120,50 +130,6 @@ function SectionCard({ title, description, children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertImagePicker({ selectedAsset, onPick, onRemove }) {
|
|
||||||
if (!selectedAsset) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onPick}
|
|
||||||
className="w-full sm:w-48 aspect-video rounded-lg border border-dashed flex flex-col items-center justify-center gap-1.5 text-muted-foreground hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<ImagePlus className="size-4" />
|
|
||||||
<span className="text-xs">Select an image</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageUrl = resolveAssetSrc(selectedAsset);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full sm:w-48 rounded-lg overflow-hidden border aspect-video group">
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt={selectedAsset.display_name}
|
|
||||||
className="w-full h-full object-cover cursor-pointer"
|
|
||||||
onClick={onPick}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
onClick={onPick}
|
|
||||||
className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center cursor-pointer"
|
|
||||||
>
|
|
||||||
<span className="text-white text-xs opacity-0 group-hover:opacity-100">Change image</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="absolute top-1 right-1 size-6"
|
|
||||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
|
||||||
aria-label="Remove image"
|
|
||||||
>
|
|
||||||
<X className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepIndicator({ steps, current, onStepClick }) {
|
function StepIndicator({ steps, current, onStepClick }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start w-full mb-8">
|
<div className="flex items-start w-full mb-8">
|
||||||
@@ -225,8 +191,6 @@ export default function EditNotificationBroadcast() {
|
|||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [targetLabel, setTargetLabel] = useState(null);
|
const [targetLabel, setTargetLabel] = useState(null);
|
||||||
const [broadcastStatus, setBroadcastStatus] = useState("draft");
|
const [broadcastStatus, setBroadcastStatus] = useState("draft");
|
||||||
const [imageAsset, setImageAsset] = useState(null);
|
|
||||||
const [imagePickerOpen, setImagePickerOpen] = useState(false);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -245,11 +209,8 @@ export default function EditNotificationBroadcast() {
|
|||||||
target_id: null,
|
target_id: null,
|
||||||
show_in_sticky: false,
|
show_in_sticky: false,
|
||||||
show_in_notifications: true,
|
show_in_notifications: true,
|
||||||
link_mode: "info",
|
|
||||||
link_url: "",
|
link_url: "",
|
||||||
link_label: "",
|
|
||||||
color: "indigo",
|
color: "indigo",
|
||||||
image_asset_id: null,
|
|
||||||
start_date: "",
|
start_date: "",
|
||||||
end_date: "",
|
end_date: "",
|
||||||
},
|
},
|
||||||
@@ -262,7 +223,6 @@ export default function EditNotificationBroadcast() {
|
|||||||
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
|
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
|
||||||
const showInSticky = watch("show_in_sticky");
|
const showInSticky = watch("show_in_sticky");
|
||||||
const showInNotifications = watch("show_in_notifications");
|
const showInNotifications = watch("show_in_notifications");
|
||||||
const linkMode = watch("link_mode");
|
|
||||||
const color = watch("color");
|
const color = watch("color");
|
||||||
const startDate = watch("start_date");
|
const startDate = watch("start_date");
|
||||||
const endDate = watch("end_date");
|
const endDate = watch("end_date");
|
||||||
@@ -280,25 +240,18 @@ export default function EditNotificationBroadcast() {
|
|||||||
const b = res?.data?.data ?? null;
|
const b = res?.data?.data ?? null;
|
||||||
if (!b) return;
|
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({
|
reset({
|
||||||
title: b.title ?? "",
|
title: b.title ?? "",
|
||||||
message: b.message ?? "",
|
message: b.message ?? "",
|
||||||
target_type: b.target_type ?? undefined,
|
target_type: b.target_type ?? undefined,
|
||||||
target_id: b.target_id ?? null,
|
target_id: b.target_id ?? null,
|
||||||
show_in_sticky: stickyOn,
|
show_in_sticky: b.show_in_sticky ?? false,
|
||||||
show_in_notifications: stickyOn ? true : (b.show_in_notifications ?? true),
|
show_in_notifications: b.show_in_notifications ?? true,
|
||||||
link_mode: b.link_url ? "link" : "info",
|
|
||||||
link_url: b.link_url ?? "",
|
link_url: b.link_url ?? "",
|
||||||
link_label: b.link_label ?? "",
|
|
||||||
color: b.color ?? "indigo",
|
color: b.color ?? "indigo",
|
||||||
image_asset_id: b.image_asset_id ? String(b.image_asset_id) : null,
|
|
||||||
start_date: b.start_date ?? "",
|
start_date: b.start_date ?? "",
|
||||||
end_date: b.end_date ?? "",
|
end_date: b.end_date ?? "",
|
||||||
});
|
});
|
||||||
setImageAsset(b.image ?? null);
|
|
||||||
setBroadcastStatus(b.status ?? "draft");
|
setBroadcastStatus(b.status ?? "draft");
|
||||||
setCurrentStep(0);
|
setCurrentStep(0);
|
||||||
})();
|
})();
|
||||||
@@ -309,31 +262,25 @@ export default function EditNotificationBroadcast() {
|
|||||||
// fields from earlier steps.
|
// fields from earlier steps.
|
||||||
const goToStep = async (target) => {
|
const goToStep = async (target) => {
|
||||||
if (target > 0) {
|
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 (!valid) { setCurrentStep(0); return; }
|
||||||
}
|
}
|
||||||
if (target > 1) {
|
if (target > 1) {
|
||||||
const valid = await trigger(["target_type", "target_id"]);
|
const valid = await trigger(["target_type", "target_id"]);
|
||||||
if (!valid) { setCurrentStep(1); return; }
|
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);
|
setCurrentStep(target);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveBroadcast = async (values, { publish = false } = {}) => {
|
const saveBroadcast = async (values, { publish = false } = {}) => {
|
||||||
const { link_mode, ...rest } = values;
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...rest,
|
...values,
|
||||||
|
message: values.message?.trim() || null,
|
||||||
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
|
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
|
||||||
show_in_sticky: values.show_in_sticky ?? false,
|
show_in_sticky: values.show_in_sticky ?? false,
|
||||||
show_in_notifications: values.show_in_notifications ?? true,
|
show_in_notifications: values.show_in_notifications ?? true,
|
||||||
link_url: (values.show_in_sticky && link_mode === "link") ? normalizeLinkUrl(values.link_url.trim()) : null,
|
link_url: values.link_url?.trim() ? normalizeLinkUrl(values.link_url.trim()) : null,
|
||||||
link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
|
|
||||||
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
|
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,
|
start_date: values.start_date || null,
|
||||||
end_date: values.end_date || null,
|
end_date: values.end_date || null,
|
||||||
updatedBy: user?.user_id ?? null,
|
updatedBy: user?.user_id ?? null,
|
||||||
@@ -372,66 +319,22 @@ export default function EditNotificationBroadcast() {
|
|||||||
|
|
||||||
{/* ── Step 0: Content ── */}
|
{/* ── Step 0: Content ── */}
|
||||||
{currentStep === 0 && (
|
{currentStep === 0 && (
|
||||||
<SectionCard title="Content" description="What admins and/or users will see.">
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Title</Label>
|
|
||||||
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
|
|
||||||
<FieldError message={errors.title?.message} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Message</Label>
|
|
||||||
<Textarea rows={4} placeholder="Full alert text" {...register("message")} />
|
|
||||||
<FieldError message={errors.message?.message} />
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Step 1: Target ── */}
|
|
||||||
{currentStep === 1 && (
|
|
||||||
<SectionCard title="Target" description="Who receives this alert when it's sent.">
|
|
||||||
<div>
|
|
||||||
<Select
|
|
||||||
value={targetType}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
|
|
||||||
setValue("target_id", null, { shouldDirty: true });
|
|
||||||
setTargetLabel(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a target" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{TARGET_TYPE_OPTIONS.map((t) => (
|
|
||||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError message={errors.target_type?.message} />
|
|
||||||
{targetType && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
|
||||||
{TARGET_TYPE_MAP[targetType]?.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{needsTarget && (
|
|
||||||
<div>
|
|
||||||
<BroadcastTargetPicker
|
|
||||||
targetType={targetType}
|
|
||||||
value={targetId}
|
|
||||||
onChange={(id) => setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
|
|
||||||
onLabelResolved={setTargetLabel}
|
|
||||||
/>
|
|
||||||
<FieldError message={errors.target_id?.message} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SectionCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Step 2: Display ── */}
|
|
||||||
{currentStep === 2 && (
|
|
||||||
<>
|
<>
|
||||||
|
<SectionCard title="Content" description="What admins and/or users will see.">
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Title</Label>
|
||||||
|
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
|
||||||
|
<FieldError message={errors.title?.message} />
|
||||||
|
</div>
|
||||||
|
{showInNotifications && (
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Message</Label>
|
||||||
|
<Textarea rows={4} placeholder="Full alert text" {...register("message")} />
|
||||||
|
<FieldError message={errors.message?.message} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="Display" description="Where clients/admins can see this alert.">
|
<SectionCard title="Display" description="Where clients/admins can see this alert.">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -441,10 +344,7 @@ export default function EditNotificationBroadcast() {
|
|||||||
onCheckedChange={(v) => {
|
onCheckedChange={(v) => {
|
||||||
const checked = v === true;
|
const checked = v === true;
|
||||||
setValue("show_in_sticky", checked, { shouldValidate: true, shouldDirty: true });
|
setValue("show_in_sticky", checked, { shouldValidate: true, shouldDirty: true });
|
||||||
// Sticky-only alerts vanish forever once dismissed (seen=true drops
|
if (checked) setValue("show_in_notifications", false, { shouldValidate: true, shouldDirty: true });
|
||||||
// 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 });
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
||||||
@@ -456,18 +356,16 @@ export default function EditNotificationBroadcast() {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
id="show_in_notifications"
|
id="show_in_notifications"
|
||||||
checked={showInNotifications === true}
|
checked={showInNotifications === true}
|
||||||
disabled={showInSticky === true}
|
onCheckedChange={(v) => {
|
||||||
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
|
const checked = v === true;
|
||||||
|
setValue("show_in_notifications", checked, { shouldValidate: true, shouldDirty: true });
|
||||||
|
if (checked) setValue("show_in_sticky", false, { shouldValidate: true, shouldDirty: true });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="show_in_notifications" className={showInSticky ? "text-muted-foreground" : "cursor-pointer"}>
|
<Label htmlFor="show_in_notifications" className="cursor-pointer">
|
||||||
Show in Notifications
|
Show in Notifications
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
{showInSticky && (
|
|
||||||
<p className="text-xs text-muted-foreground pl-7">
|
|
||||||
Required while sticky is on, so it stays visible after being dismissed.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -518,103 +416,79 @@ export default function EditNotificationBroadcast() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{showInSticky && (
|
{showInSticky && (
|
||||||
<SectionCard title="Layout image" description="Shown alongside the message when this alert is opened from the sticky banner. Optional.">
|
<SectionCard title="Link" description="Where the sticky banner goes when someone clicks it. Optional.">
|
||||||
<AlertImagePicker
|
<div>
|
||||||
selectedAsset={imageAsset}
|
<Label className="mb-1.5 block">Link URL</Label>
|
||||||
onPick={() => setImagePickerOpen(true)}
|
<Input placeholder="https://example.com or /course/123" {...register("link_url")} />
|
||||||
onRemove={() => {
|
<FieldError message={errors.link_url?.message} />
|
||||||
setImageAsset(null);
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
setValue("image_asset_id", null, { shouldDirty: true });
|
Internal paths (starting with /) navigate in-app; anything else opens in a new tab. Leave blank for an informational banner with no click action.
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SectionCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showInSticky && (
|
|
||||||
<SectionCard title="On Open" description="What happens when someone opens this alert from the sticky banner.">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={linkMode !== "link" ? "secondary" : "outline"}
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => setValue("link_mode", "info", { shouldDirty: true })}
|
|
||||||
>
|
|
||||||
Text info only
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={linkMode === "link" ? "secondary" : "outline"}
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => setValue("link_mode", "link", { shouldDirty: true })}
|
|
||||||
>
|
|
||||||
Include a link
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{linkMode === "link" ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Link URL</Label>
|
|
||||||
<Input placeholder="https://example.com or /course/123" {...register("link_url")} />
|
|
||||||
<FieldError message={errors.link_url?.message} />
|
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
|
||||||
Internal paths (starting with /) navigate in-app; anything else opens in a new tab.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Button label</Label>
|
|
||||||
<Input placeholder="e.g. Shop now" {...register("link_label")} />
|
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
|
||||||
Shown as a button right after the title in the sticky banner. Defaults to "Open Link".
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
The full-content view will show just the title and message, with no action button.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showInSticky && (
|
{showInSticky && (
|
||||||
<SectionCard title="Preview" description="What this alert looks like when opened from the sticky banner.">
|
<SectionCard title="Preview" description="What the sticky banner looks like. Clicking it redirects to the link above, if set.">
|
||||||
<div className="space-y-3">
|
<div
|
||||||
<div className="space-y-1.5">
|
className="w-full flex items-center justify-center rounded-md px-3 py-2 text-sm font-semibold"
|
||||||
<p className="text-xs text-muted-foreground">Sticky banner (collapsed):</p>
|
style={{
|
||||||
<div
|
backgroundColor: getTierColor(color || "indigo").swatch,
|
||||||
className="w-full flex items-center justify-center gap-3 rounded-md px-3 py-2 text-sm font-semibold"
|
color: getContrastText(getTierColor(color || "indigo").swatch, color || "indigo"),
|
||||||
style={{
|
}}
|
||||||
backgroundColor: getTierColor(color || "indigo").swatch,
|
>
|
||||||
color: getContrastText(getTierColor(color || "indigo").swatch, color || "indigo"),
|
<span className="truncate">{watch("title") || "Sticky banner preview"}</span>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="truncate">{watch("title") || "Sticky banner preview"}</span>
|
|
||||||
{linkMode === "link" && (
|
|
||||||
<Button type="button" variant="outline" size="sm" className="shrink-0 text-foreground">
|
|
||||||
{watch("link_label") || "Open Link"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<p className="text-xs text-muted-foreground">When clicked (full content):</p>
|
|
||||||
<AlertLayoutPreview
|
|
||||||
title={watch("title")}
|
|
||||||
message={watch("message")}
|
|
||||||
imageAsset={imageAsset}
|
|
||||||
linkLabel={linkMode === "link" ? (watch("link_label") || "Open Link") : null}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Step 3: Review ── */}
|
{/* ── Step 1: Target ── */}
|
||||||
{currentStep === 3 && (
|
{currentStep === 1 && (
|
||||||
|
<SectionCard title="Target" description="Who receives this alert when it's sent.">
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
value={targetType}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
|
||||||
|
setValue("target_id", null, { shouldDirty: true });
|
||||||
|
setTargetLabel(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a target" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TARGET_TYPE_OPTIONS.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldError message={errors.target_type?.message} />
|
||||||
|
{targetType && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
|
{TARGET_TYPE_MAP[targetType]?.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{needsTarget && (
|
||||||
|
<div>
|
||||||
|
<BroadcastTargetPicker
|
||||||
|
targetType={targetType}
|
||||||
|
value={targetId}
|
||||||
|
onChange={(id) => setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
|
||||||
|
onLabelResolved={setTargetLabel}
|
||||||
|
/>
|
||||||
|
<FieldError message={errors.target_id?.message} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 2: Review ── */}
|
||||||
|
{currentStep === 2 && (
|
||||||
<>
|
<>
|
||||||
<SectionCard title="Content" description="Confirm everything looks right before saving.">
|
<SectionCard title="Content" description="Confirm everything looks right before saving.">
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
@@ -622,10 +496,12 @@ export default function EditNotificationBroadcast() {
|
|||||||
<p className="text-xs text-muted-foreground">Title</p>
|
<p className="text-xs text-muted-foreground">Title</p>
|
||||||
<p className="font-medium">{watch("title") || "—"}</p>
|
<p className="font-medium">{watch("title") || "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{showInNotifications && (
|
||||||
<p className="text-xs text-muted-foreground">Message</p>
|
<div>
|
||||||
<p className="font-medium whitespace-pre-wrap">{watch("message") || "—"}</p>
|
<p className="text-xs text-muted-foreground">Message</p>
|
||||||
</div>
|
<p className="font-medium whitespace-pre-wrap">{watch("message") || "—"}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
@@ -661,29 +537,21 @@ export default function EditNotificationBroadcast() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showInSticky && (
|
{showInSticky && (
|
||||||
<div
|
<>
|
||||||
className="w-full flex items-center justify-center gap-3 rounded-md px-3 py-2 text-sm font-semibold"
|
<div
|
||||||
style={{ backgroundColor: getTierColor(color || "indigo").swatch, color: getContrastText(getTierColor(color || "indigo").swatch, color || "indigo") }}
|
className="w-full flex items-center justify-center rounded-md px-3 py-2 text-sm font-semibold"
|
||||||
>
|
style={{ backgroundColor: getTierColor(color || "indigo").swatch, color: getContrastText(getTierColor(color || "indigo").swatch, color || "indigo") }}
|
||||||
<span className="truncate">{watch("title") || "Sticky banner preview"}</span>
|
>
|
||||||
{linkMode === "link" && (
|
<span className="truncate">{watch("title") || "Sticky banner preview"}</span>
|
||||||
<Button type="button" variant="outline" size="sm" className="shrink-0 text-foreground">
|
</div>
|
||||||
{watch("link_label") || "Open Link"}
|
<p className="text-xs text-muted-foreground">
|
||||||
</Button>
|
{watch("link_url")
|
||||||
)}
|
? <>Clicking redirects to <span className="font-medium text-foreground">{watch("link_url")}</span>.</>
|
||||||
</div>
|
: "No link — informational only, not clickable."}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{showInSticky && (
|
|
||||||
<SectionCard title="On Open">
|
|
||||||
<p className="text-sm">
|
|
||||||
{linkMode === "link"
|
|
||||||
? <>Opens <span className="font-medium">{watch("link_url") || "—"}</span> via a “{watch("link_label") || "Open Link"}” button.</>
|
|
||||||
: "Text info only — no action button."}
|
|
||||||
</p>
|
|
||||||
</SectionCard>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -754,16 +622,6 @@ export default function EditNotificationBroadcast() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{unsavedChangesDialog}
|
{unsavedChangesDialog}
|
||||||
|
|
||||||
<AssetPickerSheet
|
|
||||||
open={imagePickerOpen}
|
|
||||||
onOpenChange={setImagePickerOpen}
|
|
||||||
fileType="image"
|
|
||||||
onSelect={(asset) => {
|
|
||||||
setImageAsset(asset);
|
|
||||||
setValue("image_asset_id", String(asset.asset_id), { shouldDirty: true });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,7 +205,9 @@ function BroadcastCard({ broadcast, onView, onEdit, onSend, onArchive }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium leading-snug truncate hover:underline">{broadcast.title || "Untitled alert"}</p>
|
<p className="text-sm font-medium leading-snug truncate hover:underline">{broadcast.title || "Untitled alert"}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{broadcast.message}</p>
|
{broadcast.message && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{broadcast.message}</p>
|
||||||
|
)}
|
||||||
{broadcast.sent_at && (
|
{broadcast.sent_at && (
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
Sent {fmtDateTime(broadcast.sent_at)} · {broadcast.recipient_count ?? 0} recipient(s)
|
Sent {fmtDateTime(broadcast.sent_at)} · {broadcast.recipient_count ?? 0} recipient(s)
|
||||||
|
|||||||
@@ -47,9 +47,11 @@ function ContentTab({ broadcast }) {
|
|||||||
return (
|
return (
|
||||||
<SectionCard icon={FileText} title="Content">
|
<SectionCard icon={FileText} title="Content">
|
||||||
<Field label="Title">{broadcast.title || "—"}</Field>
|
<Field label="Title">{broadcast.title || "—"}</Field>
|
||||||
<Field label="Message">
|
{broadcast.show_in_notifications && (
|
||||||
<span className="font-normal">{broadcast.message || "—"}</span>
|
<Field label="Message">
|
||||||
</Field>
|
<span className="font-normal">{broadcast.message || "—"}</span>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user