mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -1,116 +1,163 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, 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 { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
AlertDialog, AlertDialogAction, AlertDialogCancel,
|
|
||||||
AlertDialogContent, AlertDialogDescription,
|
|
||||||
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
|
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
|
||||||
import { NotificationIcon, getTypeAccent, resolveStickyStyle } from "@/components/generic/notificationDisplay";
|
import { getTierColor, getContrastText } from "@/utils/tierColors";
|
||||||
|
import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouselDialog";
|
||||||
|
|
||||||
|
const ROTATE_INTERVAL_MS = 6000;
|
||||||
|
|
||||||
// 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 "On Open" section — the type-based fallback
|
||||||
// resolver in notificationDisplay.jsx points at client-only routes
|
// 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) {
|
||||||
|
const linkUrl = stickyAnnouncement.data?.linkUrl || null;
|
||||||
|
if (!linkUrl) return null;
|
||||||
|
return {
|
||||||
|
label: stickyAnnouncement.data?.linkLabel || "Open Link",
|
||||||
|
go: (navigate) => (linkUrl.startsWith("/")
|
||||||
|
? navigate(linkUrl)
|
||||||
|
: window.open(linkUrl, "_blank", "noopener,noreferrer")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminStickyAnnouncementBar() {
|
export default function AdminStickyAnnouncementBar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { stickyAnnouncement, markSeen } = useAdminNotifications();
|
const { stickyAnnouncements, bannerImage, markSeen } = useAdminNotifications();
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
|
|
||||||
|
const count = stickyAnnouncements.length;
|
||||||
|
// Derived rather than clamped via effect — safe the instant the array
|
||||||
|
// shrinks (e.g. after a dismiss), no render with a stale out-of-range index.
|
||||||
|
const safeIndex = count ? Math.min(activeIndex, count - 1) : 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (count <= 1) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setActiveIndex((i) => (i + 1) % count);
|
||||||
|
}, ROTATE_INTERVAL_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [count]);
|
||||||
|
|
||||||
|
const current = stickyAnnouncements[safeIndex];
|
||||||
|
|
||||||
const onDismiss = useCallback(async () => {
|
const onDismiss = useCallback(async () => {
|
||||||
if (!stickyAnnouncement) return;
|
if (!current) return;
|
||||||
await markSeen(stickyAnnouncement.notification_id);
|
await markSeen(current.notification_id);
|
||||||
}, [stickyAnnouncement, markSeen]);
|
}, [current, markSeen]);
|
||||||
|
|
||||||
// Opening the dialog must NOT mark it seen — markSeen clears
|
// Opening the dialog must NOT mark it seen — markSeen removes the row from
|
||||||
// stickyAnnouncement, which would unmount this component (dialog included)
|
// stickyAnnouncements, which would unmount this component (dialog included)
|
||||||
// before it ever shows. Only the X button dismisses/marks seen.
|
// before it ever shows.
|
||||||
const onClickBanner = useCallback(() => {
|
const onClickBanner = useCallback(() => {
|
||||||
if (!stickyAnnouncement) return;
|
if (!current) return;
|
||||||
setDetailsOpen(true);
|
setDetailsOpen(true);
|
||||||
}, [stickyAnnouncement]);
|
}, [current]);
|
||||||
|
|
||||||
if (!stickyAnnouncement) return null;
|
// Closing the details dialog (X, Escape, overlay click — any reason)
|
||||||
|
// dismisses whichever announcement was being viewed at the time. This is
|
||||||
|
// the only dismiss path when multiple are active (no per-item X on the bar
|
||||||
|
// itself — see the count > 1 branch below).
|
||||||
|
const onDialogOpenChange = useCallback((open) => {
|
||||||
|
setDetailsOpen(open);
|
||||||
|
if (!open) void onDismiss();
|
||||||
|
}, [onDismiss]);
|
||||||
|
|
||||||
const accentClass = getTypeAccent(stickyAnnouncement.type);
|
if (!current) return null;
|
||||||
const inlineStyle = resolveStickyStyle(stickyAnnouncement.data);
|
|
||||||
const linkUrl = stickyAnnouncement.data?.linkUrl || null;
|
|
||||||
|
|
||||||
const openLink = () => {
|
const swatch = getTierColor(current.color || "indigo").swatch;
|
||||||
if (!linkUrl) return;
|
const textColor = getContrastText(swatch, current.color || "indigo");
|
||||||
if (linkUrl.startsWith("/")) navigate(linkUrl);
|
const clickAction = resolveClickAction(current);
|
||||||
else window.open(linkUrl, "_blank", "noopener,noreferrer");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div role="status" className="w-full shadow-sm px-4 md:px-6" style={{ backgroundColor: swatch }}>
|
||||||
role="status"
|
|
||||||
className="w-full border-b shadow-sm px-4 md:px-6"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
onClick={onClickBanner}
|
onClick={onClickBanner}
|
||||||
className="w-full cursor-pointer bg-card rounded-none py-3 flex items-center justify-between gap-4"
|
className="relative w-full cursor-pointer rounded-none py-3 flex items-center justify-center gap-4 px-10"
|
||||||
style={inlineStyle ?? undefined}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div className={`flex h-9 w-9 items-center justify-center rounded ${accentClass}`}>
|
<p className="text-sm font-semibold leading-snug truncate" style={{ color: textColor }}>
|
||||||
<NotificationIcon type={stickyAnnouncement.type} className="h-5 w-5" />
|
{current.title || "Announcement"}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-semibold leading-snug truncate">
|
|
||||||
{stickyAnnouncement.title || "Announcement"}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground leading-snug line-clamp-2">
|
|
||||||
{stickyAnnouncement.message || ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{clickAction && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="sm"
|
||||||
|
className="shrink-0 text-foreground"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
clickAction.go(navigate);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{clickAction.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{count > 1 && (
|
||||||
|
<div className="absolute right-2 flex items-center gap-1.5">
|
||||||
|
{stickyAnnouncements.map((a, i) => (
|
||||||
|
<button
|
||||||
|
key={a.notification_id}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Show announcement ${i + 1}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setActiveIndex(i);
|
||||||
|
}}
|
||||||
|
className="size-1.5 rounded-full transition-opacity"
|
||||||
|
style={{ backgroundColor: textColor, opacity: i === safeIndex ? 1 : 0.35 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dismiss-X only makes sense for a single active announcement —
|
||||||
|
with multiple, the dialog's own close button (shadcn Dialog)
|
||||||
|
is the way to close/step away, no per-item dismiss from the bar. */}
|
||||||
|
{count <= 1 && (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void onDismiss();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Enter" && e.key !== " ") return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void onDismiss();
|
void onDismiss();
|
||||||
}}
|
}}
|
||||||
aria-label="Dismiss sticky announcement"
|
aria-label="Dismiss sticky announcement"
|
||||||
title="Dismiss"
|
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" />
|
<X className="size-4" />
|
||||||
</Button>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AlertDialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
<AnnouncementCarouselDialog
|
||||||
<AlertDialogContent>
|
open={detailsOpen}
|
||||||
<AlertDialogHeader>
|
onOpenChange={onDialogOpenChange}
|
||||||
<AlertDialogTitle className="flex items-center gap-2">
|
announcements={stickyAnnouncements}
|
||||||
<div className={`flex h-8 w-8 items-center justify-center rounded shrink-0 ${accentClass}`}>
|
activeIndex={safeIndex}
|
||||||
<NotificationIcon type={stickyAnnouncement.type} className="h-4 w-4" />
|
onIndexChange={setActiveIndex}
|
||||||
</div>
|
resolveClickAction={resolveClickAction}
|
||||||
{stickyAnnouncement.title || "Announcement"}
|
navigate={navigate}
|
||||||
</AlertDialogTitle>
|
bannerImage={bannerImage}
|
||||||
<AlertDialogDescription asChild>
|
/>
|
||||||
<p className="whitespace-pre-wrap text-left pt-1 text-foreground">
|
|
||||||
{stickyAnnouncement.message || ""}
|
|
||||||
</p>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Close</AlertDialogCancel>
|
|
||||||
{linkUrl && (
|
|
||||||
<AlertDialogAction onClick={openLink}>
|
|
||||||
Open Link
|
|
||||||
</AlertDialogAction>
|
|
||||||
)}
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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) — pages through every currently-active sticky announcement (max
|
||||||
|
// 3, see notificationBroadcasts.controller.js's countActiveSticky) with a
|
||||||
|
// segmented progress bar. bannerImage is ONE shared image for the whole set
|
||||||
|
// (not per-announcement) — see NotificationBroadcastList.jsx's banner picker
|
||||||
|
// and the sticky_banner_settings singleton.
|
||||||
|
export default function AnnouncementCarouselDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
announcements,
|
||||||
|
activeIndex,
|
||||||
|
onIndexChange,
|
||||||
|
resolveClickAction,
|
||||||
|
navigate,
|
||||||
|
bannerImage,
|
||||||
|
}) {
|
||||||
|
const current = announcements[activeIndex];
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
|
const clickAction = resolveClickAction(current);
|
||||||
|
const imageSrc = resolveAssetSrc(bannerImage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-2xl p-0 overflow-hidden gap-0">
|
||||||
|
<div className="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">
|
||||||
|
{current.title || "Announcement"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<p className="whitespace-pre-wrap text-left text-foreground">
|
||||||
|
{current.message || ""}
|
||||||
|
</p>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="mt-auto flex flex-col gap-2 pt-4">
|
||||||
|
{clickAction && (
|
||||||
|
<Button
|
||||||
|
className="self-start"
|
||||||
|
onClick={() => clickAction.go(navigate)}
|
||||||
|
>
|
||||||
|
{clickAction.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{announcements.length > 1 && (
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{announcements.map((a, i) => (
|
||||||
|
<button
|
||||||
|
key={a.notification_id}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Show announcement ${i + 1}`}
|
||||||
|
onClick={() => onIndexChange(i)}
|
||||||
|
className={[
|
||||||
|
"h-1.5 flex-1 rounded-full transition-colors",
|
||||||
|
i === activeIndex ? "bg-foreground" : "bg-muted-foreground/25 hover:bg-muted-foreground/40",
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="aspect-video sm:aspect-auto sm:h-96 bg-muted flex items-center justify-center overflow-hidden">
|
||||||
|
{imageSrc ? (
|
||||||
|
<img src={imageSrc} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">No image</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
// components/blocks/Popup.jsx
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
|
||||||
import { resolveAssetSrc } from "@/utils/media.util";
|
|
||||||
|
|
||||||
// ── Popup ────────────────────────────────────────────────────────────────────
|
|
||||||
/**
|
|
||||||
* Generic popup advertisement block.
|
|
||||||
* Modal-style placement shown on page load — wraps ResponsiveModal so it gets
|
|
||||||
* dialog/drawer behavior for free. Caller owns the `open` state (typically set
|
|
||||||
* to true once an active popup ad resolves from the API).
|
|
||||||
*
|
|
||||||
* Props:
|
|
||||||
* ad — advertisement object { headline, description, ctas, image, image_url, advertisement_id }
|
|
||||||
* open — boolean, modal visibility
|
|
||||||
* onOpenChange — (open: boolean) => void
|
|
||||||
* onCtaClick — (ad, cta) => void, called when a footer CTA button is clicked
|
|
||||||
* onDismissForever — () => void, called when the user picks "Don't show this ad again"
|
|
||||||
*/
|
|
||||||
export function Popup({ ad, open, onOpenChange, onCtaClick, onDismissForever }) {
|
|
||||||
if (!ad) return null;
|
|
||||||
|
|
||||||
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
|
|
||||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
|
||||||
|
|
||||||
const handleDismissForever = () => {
|
|
||||||
onOpenChange?.(false);
|
|
||||||
onDismissForever?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsiveModal
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
title={ad.headline || "Announcement"}
|
|
||||||
description={ad.description || undefined}
|
|
||||||
footer={
|
|
||||||
ctas.length > 0 ? (
|
|
||||||
<>
|
|
||||||
{ctas.map((cta, i) => (
|
|
||||||
<Button
|
|
||||||
key={i}
|
|
||||||
variant={cta.variant === "outline" ? "outline" : "default"}
|
|
||||||
onClick={() => onCtaClick?.(ad, cta)}
|
|
||||||
>
|
|
||||||
{cta.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{imageSrc && (
|
|
||||||
<div className="rounded-lg bg-muted aspect-video flex items-center justify-center overflow-hidden pointer-events-none select-none">
|
|
||||||
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onDismissForever && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleDismissForever}
|
|
||||||
className="w-fit justify-self-start rounded text-xs text-muted-foreground hover:text-foreground underline underline-offset-2 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
||||||
>
|
|
||||||
Don't show this ad again
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</ResponsiveModal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// components/blocks/Sidebar.jsx
|
|
||||||
|
|
||||||
import { Megaphone } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { resolveAssetSrc } from "@/utils/media.util";
|
|
||||||
|
|
||||||
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
|
||||||
/**
|
|
||||||
* Generic sidebar advertisement block.
|
|
||||||
* Compact vertical card — image on top, optional short headline/description and
|
|
||||||
* a single CTA below. Meant to sit in a narrow column (sidebars, rail layouts),
|
|
||||||
* not stretch full-width like Hero/Banner.
|
|
||||||
*
|
|
||||||
* Props:
|
|
||||||
* ad — advertisement object { headline, description, ctas, image, image_url, advertisement_id }
|
|
||||||
* onCtaClick — (ad, cta) => void, called when the CTA button (or card, if no CTA) is clicked
|
|
||||||
*/
|
|
||||||
export function Sidebar({ ad, onCtaClick }) {
|
|
||||||
if (!ad) return null;
|
|
||||||
|
|
||||||
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
|
|
||||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
|
||||||
const primaryCta = ctas[0];
|
|
||||||
|
|
||||||
const handleCardClick = () => {
|
|
||||||
if (!primaryCta) onCtaClick?.(ad, undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="w-full max-w-xs rounded-lg border bg-card overflow-hidden flex flex-col cursor-pointer"
|
|
||||||
onClick={handleCardClick}
|
|
||||||
>
|
|
||||||
<div className="aspect-square bg-muted flex items-center justify-center overflow-hidden pointer-events-none select-none">
|
|
||||||
{imageSrc ? (
|
|
||||||
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<Megaphone className="size-6 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(ad.headline || ad.description || primaryCta) && (
|
|
||||||
<div className="p-3 flex flex-col gap-1.5">
|
|
||||||
{ad.headline && <p className="text-sm font-medium leading-snug pointer-events-none select-none">{ad.headline}</p>}
|
|
||||||
{ad.description && <p className="text-xs text-muted-foreground line-clamp-2 pointer-events-none select-none">{ad.description}</p>}
|
|
||||||
{primaryCta && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="mt-1 w-full"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onCtaClick?.(ad, primaryCta);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{primaryCta.label}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SidebarSkeleton ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function SidebarSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-xs rounded-lg border bg-card overflow-hidden flex flex-col">
|
|
||||||
<Skeleton className="aspect-square w-full" />
|
|
||||||
<div className="p-3 flex flex-col gap-1.5">
|
|
||||||
<Skeleton className="h-4 w-3/4" />
|
|
||||||
<Skeleton className="h-3 w-full" />
|
|
||||||
<Skeleton className="h-8 w-full mt-1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -32,7 +32,7 @@ const TARGET_CONFIGS = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BroadcastTargetPicker({ targetType, value, onChange }) {
|
export function BroadcastTargetPicker({ targetType, value, onChange, onLabelResolved }) {
|
||||||
const config = TARGET_CONFIGS[targetType];
|
const config = TARGET_CONFIGS[targetType];
|
||||||
|
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
@@ -57,9 +57,17 @@ export function BroadcastTargetPicker({ targetType, value, onChange }) {
|
|||||||
return items.filter((item) => String(item[config.labelKey] ?? "").toLowerCase().includes(q));
|
return items.filter((item) => String(item[config.labelKey] ?? "").toLowerCase().includes(q));
|
||||||
}, [items, query, config]);
|
}, [items, query, config]);
|
||||||
|
|
||||||
if (!config) return null;
|
const selected = config ? items.find((item) => String(item[config.idKey]) === String(value)) : undefined;
|
||||||
|
|
||||||
const selected = items.find((item) => String(item[config.idKey]) === String(value));
|
// Lets the parent (Review step summaries, etc.) show the resolved name
|
||||||
|
// instead of just the raw id — fires whenever the matched item changes,
|
||||||
|
// including on initial load once the fetched list resolves `value`.
|
||||||
|
useEffect(() => {
|
||||||
|
onLabelResolved?.(selected ? selected[config.labelKey] : null);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (!v) setQuery(""); }}>
|
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (!v) setQuery(""); }}>
|
||||||
|
|||||||
@@ -15,14 +15,22 @@ const FIELD_DISPLAY_MAP = {
|
|||||||
const BOOLEAN_FIELDS = Object.keys(FIELD_DISPLAY_MAP);
|
const BOOLEAN_FIELDS = Object.keys(FIELD_DISPLAY_MAP);
|
||||||
|
|
||||||
// ─── Generic formatter ────────────────────────────────────────────────────────
|
// ─── Generic formatter ────────────────────────────────────────────────────────
|
||||||
|
// Audit fields (createdBy/updatedBy/deletedBy) come back from the field-values
|
||||||
|
// API as { value: user_id, label: full_name } — filtering has to select on the
|
||||||
|
// id, but the sheet should still display the name. Every other field type
|
||||||
|
// still hands this plain primitives, which pass through unchanged.
|
||||||
|
const itemValue = (item) => (item && typeof item === "object" && "value" in item) ? item.value : item;
|
||||||
|
const itemLabel = (item) => (item && typeof item === "object" && "label" in item) ? item.label : item;
|
||||||
|
|
||||||
const formatFilterItem = (item, field, type, fmtDate) => {
|
const formatFilterItem = (item, field, type, fmtDate) => {
|
||||||
|
const label = itemLabel(item);
|
||||||
if (FIELD_DISPLAY_MAP[field]) {
|
if (FIELD_DISPLAY_MAP[field]) {
|
||||||
return FIELD_DISPLAY_MAP[field][String(item)] ?? item;
|
return FIELD_DISPLAY_MAP[field][String(label)] ?? label;
|
||||||
}
|
}
|
||||||
if (type === "date" && item) {
|
if (type === "date" && label) {
|
||||||
return fmtDate(item);
|
return fmtDate(label);
|
||||||
}
|
}
|
||||||
return item;
|
return label;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Reusable empty state ─────────────────────────────────────────────────────
|
// ─── Reusable empty state ─────────────────────────────────────────────────────
|
||||||
@@ -37,17 +45,20 @@ const FilterList = ({ items, field, type, selected, onToggle, inputType = "check
|
|||||||
const { fmtDate } = useDateFormat();
|
const { fmtDate } = useDateFormat();
|
||||||
if (items.length === 0) return <EmptyState />;
|
if (items.length === 0) return <EmptyState />;
|
||||||
|
|
||||||
return items.map((item) => (
|
return items.map((item) => {
|
||||||
<label key={item} className="flex items-center gap-2 text-sm cursor-pointer">
|
const value = itemValue(item);
|
||||||
|
return (
|
||||||
|
<label key={value} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type={inputType}
|
type={inputType}
|
||||||
name={inputType === "radio" ? field : undefined}
|
name={inputType === "radio" ? field : undefined}
|
||||||
checked={selected.includes(String(item))}
|
checked={selected.includes(String(value))}
|
||||||
onChange={() => onToggle(item)}
|
onChange={() => onToggle(value)}
|
||||||
/>
|
/>
|
||||||
{formatFilterItem(item, field, type, fmtDate)}
|
{formatFilterItem(item, field, type, fmtDate)}
|
||||||
</label>
|
</label>
|
||||||
));
|
);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FilterSheet({ open, onOpenChange, column, attr, data = [], loading }) {
|
export function FilterSheet({ open, onOpenChange, column, attr, data = [], loading }) {
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
AlertDialog, AlertDialogAction, AlertDialogCancel,
|
|
||||||
AlertDialogContent, AlertDialogDescription,
|
|
||||||
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
|
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
|
||||||
import { NotificationIcon, getTypeAccent, resolveNotificationLink, resolveStickyStyle } from "@/components/generic/notificationDisplay";
|
import { resolveNotificationLink } from "@/components/generic/notificationDisplay";
|
||||||
|
import { getTierColor, getContrastText } from "@/utils/tierColors";
|
||||||
|
import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouselDialog";
|
||||||
|
|
||||||
// Explicit link_url (from the admin "On Open" section) always wins. Falls back
|
const ROTATE_INTERVAL_MS = 6000;
|
||||||
// to the type-based resolver for broadcasts sent before that field existed.
|
|
||||||
|
// Explicit link_url (from the admin "On Open" section) always wins, using the
|
||||||
|
// admin-authored button label when set. Falls back to the type-based resolver
|
||||||
|
// for broadcasts sent before that field existed.
|
||||||
function resolveClickAction(stickyAnnouncement) {
|
function resolveClickAction(stickyAnnouncement) {
|
||||||
const explicitUrl = stickyAnnouncement.data?.linkUrl || null;
|
const explicitUrl = stickyAnnouncement.data?.linkUrl || null;
|
||||||
if (explicitUrl) {
|
if (explicitUrl) {
|
||||||
return {
|
return {
|
||||||
label: "Open Link",
|
label: stickyAnnouncement.data?.linkLabel || "Open Link",
|
||||||
go: (navigate) => (explicitUrl.startsWith("/")
|
go: (navigate) => (explicitUrl.startsWith("/")
|
||||||
? navigate(explicitUrl)
|
? navigate(explicitUrl)
|
||||||
: window.open(explicitUrl, "_blank", "noopener,noreferrer")),
|
: window.open(explicitUrl, "_blank", "noopener,noreferrer")),
|
||||||
@@ -27,99 +27,140 @@ function resolveClickAction(stickyAnnouncement) {
|
|||||||
|
|
||||||
export default function StickyAnnouncementBar() {
|
export default function StickyAnnouncementBar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { stickyAnnouncement, markSeen } = useClientNotifications();
|
const { stickyAnnouncements, bannerImage, markSeen } = useClientNotifications();
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
|
|
||||||
|
const count = stickyAnnouncements.length;
|
||||||
|
// Derived rather than clamped via effect — safe the instant the array
|
||||||
|
// shrinks (e.g. after a dismiss), no render with a stale out-of-range index.
|
||||||
|
const safeIndex = count ? Math.min(activeIndex, count - 1) : 0;
|
||||||
|
|
||||||
|
// Auto-rotate through active announcements while more than one is live.
|
||||||
|
useEffect(() => {
|
||||||
|
if (count <= 1) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setActiveIndex((i) => (i + 1) % count);
|
||||||
|
}, ROTATE_INTERVAL_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [count]);
|
||||||
|
|
||||||
|
const current = stickyAnnouncements[safeIndex];
|
||||||
|
|
||||||
const onDismiss = useCallback(async () => {
|
const onDismiss = useCallback(async () => {
|
||||||
if (!stickyAnnouncement) return;
|
if (!current) return;
|
||||||
await markSeen(stickyAnnouncement.notification_id);
|
await markSeen(current.notification_id);
|
||||||
}, [stickyAnnouncement, markSeen]);
|
}, [current, markSeen]);
|
||||||
|
|
||||||
// Opening the dialog must NOT mark it seen — markSeen clears
|
// Opening the dialog must NOT mark it seen — markSeen removes the row from
|
||||||
// stickyAnnouncement, which would unmount this component (dialog included)
|
// stickyAnnouncements, which would unmount this component (dialog included)
|
||||||
// before it ever shows. Only the X button dismisses/marks seen.
|
// before it ever shows.
|
||||||
const onClickBanner = useCallback(() => {
|
const onClickBanner = useCallback(() => {
|
||||||
if (!stickyAnnouncement) return;
|
if (!current) return;
|
||||||
setDetailsOpen(true);
|
setDetailsOpen(true);
|
||||||
}, [stickyAnnouncement]);
|
}, [current]);
|
||||||
|
|
||||||
if (!stickyAnnouncement) return null;
|
// Closing the details dialog (X, Escape, overlay click — any reason)
|
||||||
|
// dismisses whichever announcement was being viewed at the time. This is
|
||||||
|
// the only dismiss path when multiple are active (no per-item X on the bar
|
||||||
|
// itself — see the count > 1 branch below).
|
||||||
|
const onDialogOpenChange = useCallback((open) => {
|
||||||
|
setDetailsOpen(open);
|
||||||
|
if (!open) void onDismiss();
|
||||||
|
}, [onDismiss]);
|
||||||
|
|
||||||
const accentClass = getTypeAccent(stickyAnnouncement.type);
|
if (!current) return null;
|
||||||
const inlineStyle = resolveStickyStyle(stickyAnnouncement.data);
|
|
||||||
const clickAction = resolveClickAction(stickyAnnouncement);
|
const swatch = getTierColor(current.color || "indigo").swatch;
|
||||||
|
const textColor = getContrastText(swatch, current.color || "indigo");
|
||||||
|
const clickAction = resolveClickAction(current);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div role="status" className="w-full shadow-sm px-4 md:px-6" style={{ backgroundColor: swatch }}>
|
||||||
role="status"
|
|
||||||
className="w-full border-b shadow-sm px-4 md:px-6"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
onClick={onClickBanner}
|
onClick={onClickBanner}
|
||||||
className="w-full cursor-pointer bg-card rounded-none py-3 flex items-center justify-between gap-4"
|
className="relative w-full cursor-pointer rounded-none py-3 flex items-center justify-center gap-4 px-10"
|
||||||
style={inlineStyle ?? undefined}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div className={`flex h-9 w-9 items-center justify-center rounded ${accentClass}`}>
|
<p className="text-sm font-semibold leading-snug truncate" style={{ color: textColor }}>
|
||||||
<NotificationIcon type={stickyAnnouncement.type} className="h-5 w-5" />
|
{current.title || "Announcement"}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-semibold leading-snug truncate">
|
|
||||||
{stickyAnnouncement.title || "Announcement"}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground leading-snug line-clamp-2">
|
|
||||||
{stickyAnnouncement.message || ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{clickAction && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="sm"
|
||||||
|
className="shrink-0 text-foreground"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
clickAction.go(navigate);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{clickAction.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{count > 1 && (
|
||||||
|
<div className="absolute right-2 flex items-center gap-1.5">
|
||||||
|
{stickyAnnouncements.map((a, i) => (
|
||||||
|
<button
|
||||||
|
key={a.notification_id}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Show announcement ${i + 1}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setActiveIndex(i);
|
||||||
|
}}
|
||||||
|
className="size-1.5 rounded-full transition-opacity"
|
||||||
|
style={{ backgroundColor: textColor, opacity: i === safeIndex ? 1 : 0.35 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dismiss-X only makes sense for a single active announcement —
|
||||||
|
with multiple, the dialog's own close button (shadcn Dialog)
|
||||||
|
is the way to close/step away, no per-item dismiss from the bar. */}
|
||||||
|
{count <= 1 && (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void onDismiss();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== "Enter" && e.key !== " ") return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void onDismiss();
|
void onDismiss();
|
||||||
}}
|
}}
|
||||||
aria-label="Dismiss sticky announcement"
|
aria-label="Dismiss sticky announcement"
|
||||||
title="Dismiss"
|
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" />
|
<X className="size-4" />
|
||||||
</Button>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Full-content view — plain text info, or with an Open Link action
|
<AnnouncementCarouselDialog
|
||||||
when the announcement was created with a link (see AddNotificationBroadcast's
|
open={detailsOpen}
|
||||||
"On Open" section). */}
|
onOpenChange={onDialogOpenChange}
|
||||||
<AlertDialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
announcements={stickyAnnouncements}
|
||||||
<AlertDialogContent>
|
activeIndex={safeIndex}
|
||||||
<AlertDialogHeader>
|
onIndexChange={setActiveIndex}
|
||||||
<AlertDialogTitle className="flex items-center gap-2">
|
resolveClickAction={resolveClickAction}
|
||||||
<div className={`flex h-8 w-8 items-center justify-center rounded shrink-0 ${accentClass}`}>
|
navigate={navigate}
|
||||||
<NotificationIcon type={stickyAnnouncement.type} className="h-4 w-4" />
|
bannerImage={bannerImage}
|
||||||
</div>
|
/>
|
||||||
{stickyAnnouncement.title || "Announcement"}
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription asChild>
|
|
||||||
<p className="whitespace-pre-wrap text-left pt-1 text-foreground">
|
|
||||||
{stickyAnnouncement.message || ""}
|
|
||||||
</p>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Close</AlertDialogCancel>
|
|
||||||
{clickAction && (
|
|
||||||
<AlertDialogAction onClick={() => clickAction.go(navigate)}>
|
|
||||||
{clickAction.label}
|
|
||||||
</AlertDialogAction>
|
|
||||||
)}
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,15 @@ export default function DataTable({
|
|||||||
const handleOpenFilterSheet = async (e, column, attr) => {
|
const handleOpenFilterSheet = async (e, column, attr) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveColumn(null);
|
setActiveColumn(null);
|
||||||
|
|
||||||
|
// Enum/boolean columns already carry their full value set in
|
||||||
|
// attr.options.choices (see FilterSheet.jsx's sourceData) — hitting
|
||||||
|
// the field-values endpoint for them is a wasted round-trip.
|
||||||
|
if (attr?.type === "enum") {
|
||||||
|
setFilterState({ open: true, column, attr, data: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await onFetchFilterData(attr.field);
|
const data = await onFetchFilterData(attr.field);
|
||||||
setFilterState({ open: true, column, attr, data });
|
setFilterState({ open: true, column, attr, data });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,26 +22,6 @@ const TYPE_ACCENT = {
|
|||||||
assessment: "bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400",
|
assessment: "bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shared by StickyAnnouncementBar (client) and AdminStickyAnnouncementBar —
|
|
||||||
// supports multiple possible admin-authored shapes without tightly coupling
|
|
||||||
// to one admin UI.
|
|
||||||
export function resolveStickyStyle(data) {
|
|
||||||
const style = data?.sticky_style ?? data?.stickyStyle ?? data?.stickyColors ?? data?.colors ?? null;
|
|
||||||
if (!style) return null;
|
|
||||||
|
|
||||||
const background = style.background ?? style.bg ?? style.backgroundColor ?? null;
|
|
||||||
const text = style.text ?? style.color ?? style.foreground ?? null;
|
|
||||||
const border = style.border ?? style.borderColor ?? null;
|
|
||||||
|
|
||||||
if (!background && !text && !border) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...(background ? { backgroundColor: background } : null),
|
|
||||||
...(text ? { color: text } : null),
|
|
||||||
...(border ? { borderColor: border } : null),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotificationIcon({ type, className }) {
|
export function NotificationIcon({ type, className }) {
|
||||||
const Icon = TYPE_ICON[type] ?? Bell;
|
const Icon = TYPE_ICON[type] ?? Bell;
|
||||||
return <Icon className={cn("shrink-0", className)} />;
|
return <Icon className={cn("shrink-0", className)} />;
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { createContext, useCallback, useContext, useState } from "react";
|
|
||||||
import api from "@/utils/api.util";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
const AdminAchievementsContext = createContext(null);
|
|
||||||
|
|
||||||
export function useAdminAchievements() {
|
|
||||||
const ctx = useContext(AdminAchievementsContext);
|
|
||||||
if (!ctx) throw new Error("useAdminAchievements must be used inside AdminAchievementsProvider");
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminAchievementsProvider({ children }) {
|
|
||||||
const [achievements, setAchievements] = useState([]);
|
|
||||||
const [achievement, setAchievement] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const request = useCallback(async (fn) => {
|
|
||||||
setLoading(true);
|
|
||||||
try { return await fn(); }
|
|
||||||
catch (err) {
|
|
||||||
toast(err?.response?.data?.message ?? "Something went wrong.");
|
|
||||||
return null;
|
|
||||||
} finally { setLoading(false); }
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchAchievements = useCallback(() =>
|
|
||||||
request(async () => {
|
|
||||||
const { data } = await api.get("/admin/achievements");
|
|
||||||
setAchievements(data.data ?? []);
|
|
||||||
return data.data;
|
|
||||||
}), [request]);
|
|
||||||
|
|
||||||
const fetchAchievement = useCallback((id) =>
|
|
||||||
request(async () => {
|
|
||||||
const { data } = await api.get(`/admin/achievements/${id}`);
|
|
||||||
setAchievement(data.data ?? null);
|
|
||||||
return data.data;
|
|
||||||
}), [request]);
|
|
||||||
|
|
||||||
const createAchievement = useCallback((payload) =>
|
|
||||||
request(async () => {
|
|
||||||
const { data } = await api.post("/admin/achievements", payload);
|
|
||||||
toast("Achievement created.");
|
|
||||||
return data.data;
|
|
||||||
}), [request]);
|
|
||||||
|
|
||||||
const updateAchievement = useCallback((id, payload) =>
|
|
||||||
request(async () => {
|
|
||||||
const { data } = await api.put(`/admin/achievements/${id}`, payload);
|
|
||||||
setAchievements((prev) =>
|
|
||||||
prev.map((a) => (String(a.achievement_definition_id) === String(id) ? data.data : a))
|
|
||||||
);
|
|
||||||
if (achievement && String(achievement.achievement_definition_id) === String(id)) setAchievement(data.data);
|
|
||||||
toast("Achievement updated.");
|
|
||||||
return data.data;
|
|
||||||
}), [request, achievement]);
|
|
||||||
|
|
||||||
const deleteAchievement = useCallback((id) =>
|
|
||||||
request(async () => {
|
|
||||||
await api.delete(`/admin/achievements/${id}`);
|
|
||||||
setAchievements((prev) => prev.filter((a) => String(a.achievement_definition_id) !== String(id)));
|
|
||||||
toast("Achievement deleted.");
|
|
||||||
return true;
|
|
||||||
}), [request]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminAchievementsContext.Provider value={{
|
|
||||||
achievements, achievement, loading,
|
|
||||||
fetchAchievements, fetchAchievement,
|
|
||||||
createAchievement, updateAchievement, deleteAchievement,
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</AdminAchievementsContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -218,9 +218,11 @@ export function AssetsProvider({ children }) {
|
|||||||
});
|
});
|
||||||
if (file) formData.append("file", file);
|
if (file) formData.append("file", file);
|
||||||
|
|
||||||
const res = await api.patch(`/admin/assets/${assetId}`, formData, {
|
// No explicit Content-Type here — axios/the browser must set it
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
// itself so the multipart boundary is included. A hardcoded
|
||||||
});
|
// "multipart/form-data" header (no boundary) makes multer fail
|
||||||
|
// to parse the body, silently dropping the file and every field.
|
||||||
|
const res = await api.patch(`/admin/assets/${assetId}`, formData);
|
||||||
|
|
||||||
const asset = res.data?.data?.data ?? null;
|
const asset = res.data?.data?.data ?? null;
|
||||||
if (asset) {
|
if (asset) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
const [attributes, setAttributes] = useState([]);
|
const [attributes, setAttributes] = useState([]);
|
||||||
const [pagination, setPagination] = useState(PAGINATION_INIT);
|
const [pagination, setPagination] = useState(PAGINATION_INIT);
|
||||||
const [selectedBroadcast, setSelectedBroadcast] = useState(null);
|
const [selectedBroadcast, setSelectedBroadcast] = useState(null);
|
||||||
|
const [stickyBannerSetting, setStickyBannerSetting] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const request = useCallback(async (fn) => {
|
const request = useCallback(async (fn) => {
|
||||||
@@ -207,6 +208,35 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── GET /api/admin/announcements/sticky-banner ───────────────────────────
|
||||||
|
// Shared banner image for the whole rotating sticky bar — one image for
|
||||||
|
// all (up to 3) concurrently-active announcements, not one per announcement.
|
||||||
|
const fetchStickyBannerSetting = useCallback(
|
||||||
|
() =>
|
||||||
|
request(async () => {
|
||||||
|
const res = await api.get("/admin/announcements/sticky-banner");
|
||||||
|
const setting = res.data?.data?.data ?? null;
|
||||||
|
setStickyBannerSetting(setting);
|
||||||
|
return res.data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── PATCH /api/admin/announcements/sticky-banner ────────────────────────
|
||||||
|
const updateStickyBannerSetting = useCallback(
|
||||||
|
(fields) =>
|
||||||
|
request(async () => {
|
||||||
|
const res = await api.patch("/admin/announcements/sticky-banner", fields);
|
||||||
|
const setting = res.data?.data?.data ?? null;
|
||||||
|
if (setting) {
|
||||||
|
setStickyBannerSetting((prev) => ({ ...prev, ...setting }));
|
||||||
|
toast("Sticky banner image updated.");
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
// ─── DELETE /api/admin/announcements/bulk/permanent ──────────────────────
|
// ─── DELETE /api/admin/announcements/bulk/permanent ──────────────────────
|
||||||
const permanentlyDeleteBroadcasts = useCallback(
|
const permanentlyDeleteBroadcasts = useCallback(
|
||||||
({ ids }) =>
|
({ ids }) =>
|
||||||
@@ -225,6 +255,7 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
attributes,
|
attributes,
|
||||||
pagination,
|
pagination,
|
||||||
selectedBroadcast,
|
selectedBroadcast,
|
||||||
|
stickyBannerSetting,
|
||||||
loading,
|
loading,
|
||||||
setPagination,
|
setPagination,
|
||||||
setSelectedBroadcast,
|
setSelectedBroadcast,
|
||||||
@@ -240,6 +271,8 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
restoreBroadcasts,
|
restoreBroadcasts,
|
||||||
permanentlyDeleteBroadcast,
|
permanentlyDeleteBroadcast,
|
||||||
permanentlyDeleteBroadcasts,
|
permanentlyDeleteBroadcasts,
|
||||||
|
fetchStickyBannerSetting,
|
||||||
|
updateStickyBannerSetting,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</NotificationBroadcastsContext.Provider>
|
</NotificationBroadcastsContext.Provider>
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export function useAdminNotifications() {
|
|||||||
export function AdminNotificationProvider({ children }) {
|
export function AdminNotificationProvider({ children }) {
|
||||||
const [notifications, setNotifications] = useState([]);
|
const [notifications, setNotifications] = useState([]);
|
||||||
const [unseenCount, setUnseenCount] = useState(0);
|
const [unseenCount, setUnseenCount] = useState(0);
|
||||||
const [stickyAnnouncement, setStickyAnnouncement] = useState(null);
|
const [stickyAnnouncements, setStickyAnnouncements] = useState([]);
|
||||||
|
const [bannerImage, setBannerImage] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const intervalRef = useRef(null);
|
const intervalRef = useRef(null);
|
||||||
|
|
||||||
@@ -30,7 +31,8 @@ export function AdminNotificationProvider({ children }) {
|
|||||||
const fetchStickyAnnouncement = useCallback(async () => {
|
const fetchStickyAnnouncement = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get("/admin/notifications/sticky");
|
const res = await api.get("/admin/notifications/sticky");
|
||||||
setStickyAnnouncement(res.data?.data?.announcement ?? null);
|
setStickyAnnouncements(res.data?.data?.announcements ?? []);
|
||||||
|
setBannerImage(res.data?.data?.bannerImage ?? null);
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
@@ -57,7 +59,7 @@ export function AdminNotificationProvider({ children }) {
|
|||||||
prev.map(n => n.notification_id === id ? { ...n, seen: true } : n)
|
prev.map(n => n.notification_id === id ? { ...n, seen: true } : n)
|
||||||
);
|
);
|
||||||
setUnseenCount(prev => Math.max(0, prev - 1));
|
setUnseenCount(prev => Math.max(0, prev - 1));
|
||||||
setStickyAnnouncement(prev => (prev?.notification_id === id ? null : prev));
|
setStickyAnnouncements(prev => prev.filter(a => a.notification_id !== id));
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
@@ -68,7 +70,7 @@ export function AdminNotificationProvider({ children }) {
|
|||||||
await api.patch("/admin/notifications/seen-all");
|
await api.patch("/admin/notifications/seen-all");
|
||||||
setNotifications(prev => prev.map(n => ({ ...n, seen: true })));
|
setNotifications(prev => prev.map(n => ({ ...n, seen: true })));
|
||||||
setUnseenCount(0);
|
setUnseenCount(0);
|
||||||
setStickyAnnouncement(null);
|
setStickyAnnouncements([]);
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
@@ -89,7 +91,8 @@ export function AdminNotificationProvider({ children }) {
|
|||||||
<AdminNotificationContext.Provider value={{
|
<AdminNotificationContext.Provider value={{
|
||||||
notifications,
|
notifications,
|
||||||
unseenCount,
|
unseenCount,
|
||||||
stickyAnnouncement,
|
stickyAnnouncements,
|
||||||
|
bannerImage,
|
||||||
loading,
|
loading,
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
markSeen,
|
markSeen,
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { createContext, useCallback, useContext, useState } from "react";
|
|
||||||
import api from "@/utils/api.util";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
const AdminNotificationTemplateContext = createContext(null);
|
|
||||||
|
|
||||||
export function useAdminNotificationTemplates() {
|
|
||||||
const ctx = useContext(AdminNotificationTemplateContext);
|
|
||||||
if (!ctx) throw new Error("useAdminNotificationTemplates must be used inside AdminNotificationTemplateProvider");
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminNotificationTemplateProvider({ children }) {
|
|
||||||
const [templates, setTemplates] = useState([]);
|
|
||||||
const [template, setTemplate] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const request = useCallback(async (fn) => {
|
|
||||||
setLoading(true);
|
|
||||||
try { return await fn(); }
|
|
||||||
catch (err) {
|
|
||||||
toast(err?.response?.data?.message ?? "Something went wrong.");
|
|
||||||
return null;
|
|
||||||
} finally { setLoading(false); }
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchTemplates = useCallback(() =>
|
|
||||||
request(async () => {
|
|
||||||
const { data } = await api.get("/admin/announcement-templates");
|
|
||||||
setTemplates(data.data ?? []);
|
|
||||||
return data.data;
|
|
||||||
}), [request]);
|
|
||||||
|
|
||||||
const fetchTemplate = useCallback((id) =>
|
|
||||||
request(async () => {
|
|
||||||
const { data } = await api.get(`/admin/announcement-templates/${id}`);
|
|
||||||
setTemplate(data.data ?? null);
|
|
||||||
return data.data;
|
|
||||||
}), [request]);
|
|
||||||
|
|
||||||
const updateTemplate = useCallback((id, payload) =>
|
|
||||||
request(async () => {
|
|
||||||
const { data } = await api.put(`/admin/announcement-templates/${id}`, payload);
|
|
||||||
setTemplates((prev) =>
|
|
||||||
prev.map((t) => (String(t.notification_template_id) === String(id) ? data.data : t))
|
|
||||||
);
|
|
||||||
if (template && String(template.notification_template_id) === String(id)) setTemplate(data.data);
|
|
||||||
toast("Notification template updated.");
|
|
||||||
return data.data;
|
|
||||||
}), [request, template]);
|
|
||||||
|
|
||||||
const createTemplate = useCallback((payload) =>
|
|
||||||
request(async () => {
|
|
||||||
const { data } = await api.post("/admin/announcement-templates", payload);
|
|
||||||
setTemplates((prev) => [...prev, data.data]);
|
|
||||||
toast("Announcement template created.");
|
|
||||||
return data.data;
|
|
||||||
}), [request]);
|
|
||||||
|
|
||||||
const deleteTemplate = useCallback((id) =>
|
|
||||||
request(async () => {
|
|
||||||
await api.delete(`/admin/announcement-templates/${id}`);
|
|
||||||
setTemplates((prev) => prev.filter((t) => String(t.notification_template_id) !== String(id)));
|
|
||||||
toast("Announcement template deleted.");
|
|
||||||
return true;
|
|
||||||
}), [request]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminNotificationTemplateContext.Provider value={{
|
|
||||||
templates, template, loading,
|
|
||||||
fetchTemplates, fetchTemplate, updateTemplate, createTemplate, deleteTemplate,
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</AdminNotificationTemplateContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,7 @@ export function useClientAdvertisements() {
|
|||||||
|
|
||||||
export function ClientAdvertisementsProvider({ children }) {
|
export function ClientAdvertisementsProvider({ children }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { profile, getProfile, updateProfile } = useProfile();
|
const { profile, getProfile } = useProfile();
|
||||||
|
|
||||||
// Keyed by placement so multiple slots on the same page (e.g. dashboard.hero +
|
// Keyed by placement so multiple slots on the same page (e.g. dashboard.hero +
|
||||||
// dashboard.popup) can be fetched independently without clobbering each other.
|
// dashboard.popup) can be fetched independently without clobbering each other.
|
||||||
@@ -43,7 +43,6 @@ export function ClientAdvertisementsProvider({ children }) {
|
|||||||
const [listLoading, setListLoading] = useState({});
|
const [listLoading, setListLoading] = useState({});
|
||||||
const [clickCounts, setClickCounts] = useState(loadClickCounts);
|
const [clickCounts, setClickCounts] = useState(loadClickCounts);
|
||||||
const [pendingClick, setPendingClick] = useState(null); // { ad, cta } awaiting confirmation
|
const [pendingClick, setPendingClick] = useState(null); // { ad, cta } awaiting confirmation
|
||||||
const [dismissConfirmOpen, setDismissConfirmOpen] = useState(false);
|
|
||||||
|
|
||||||
// Ad fetches must know the real preference before deciding visibility — never
|
// Ad fetches must know the real preference before deciding visibility — never
|
||||||
// assume "show" as a default just because profile hasn't loaded yet. profileRef
|
// assume "show" as a default just because profile hasn't loaded yet. profileRef
|
||||||
@@ -63,14 +62,11 @@ export function ClientAdvertisementsProvider({ children }) {
|
|||||||
return fresh;
|
return fresh;
|
||||||
}, [getProfile]);
|
}, [getProfile]);
|
||||||
|
|
||||||
// Popups are gated separately from hero/banner/sidebar so "Don't show this
|
// Gated by the Settings → Advertisements "Other ads" toggle.
|
||||||
// ad again" only ever touches popups, per the Settings → Advertisements toggles.
|
|
||||||
const resolveVisibility = (profileData, ad) => {
|
const resolveVisibility = (profileData, ad) => {
|
||||||
if (!ad) return ad;
|
if (!ad) return ad;
|
||||||
const showPopupAds = profileData?.personal_info?.show_popup_ads ?? true;
|
|
||||||
const showOtherAds = profileData?.personal_info?.show_other_ads ?? true;
|
const showOtherAds = profileData?.personal_info?.show_other_ads ?? true;
|
||||||
const hidden = ad.type === "popup" ? !showPopupAds : !showOtherAds;
|
return showOtherAds ? ad : null;
|
||||||
return hidden ? null : ad;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── GET /api/client/advertisements/active?placement=dashboard.hero ───────
|
// ─── GET /api/client/advertisements/active?placement=dashboard.hero ───────
|
||||||
@@ -171,15 +167,18 @@ export function ClientAdvertisementsProvider({ children }) {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tracks the click then follows the CTA link (external → new tab, internal → router nav).
|
// Tracks the click then follows the link: the CTA's own link wins if present,
|
||||||
|
// else the ad's redirect_link, else its internal Page Builder landing page
|
||||||
|
// (/ads/:uuid) if one was authored. External links open in a new tab.
|
||||||
const goToCta = useCallback(
|
const goToCta = useCallback(
|
||||||
(ad, cta) => {
|
(ad, cta) => {
|
||||||
trackClick(ad?.advertisement_id);
|
trackClick(ad?.advertisement_id);
|
||||||
if (!cta?.link) return;
|
const link = cta?.link || ad?.redirect_link || (ad?.landing_page ? `/ads/${ad.uuid}` : null);
|
||||||
if (/^https?:\/\//.test(cta.link)) {
|
if (!link) return;
|
||||||
window.open(cta.link, "_blank", "noopener,noreferrer");
|
if (/^https?:\/\//.test(link)) {
|
||||||
|
window.open(link, "_blank", "noopener,noreferrer");
|
||||||
} else {
|
} else {
|
||||||
navigate(cta.link);
|
navigate(link);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[navigate, trackClick]
|
[navigate, trackClick]
|
||||||
@@ -219,20 +218,6 @@ export function ClientAdvertisementsProvider({ children }) {
|
|||||||
setPendingClick(null);
|
setPendingClick(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── "Don't show this ad again" (popups only) ──────────────────────────
|
|
||||||
// Persists the preference to the account (so it follows across devices),
|
|
||||||
// then shows a one-time confirmation pointing at where to turn it back on.
|
|
||||||
const dismissPopupForever = useCallback(async () => {
|
|
||||||
const result = await updateProfile({ show_popup_ads: false });
|
|
||||||
if (result?.data) profileRef.current = result.data;
|
|
||||||
setDismissConfirmOpen(true);
|
|
||||||
}, [updateProfile]);
|
|
||||||
|
|
||||||
const goToAdSettings = () => {
|
|
||||||
setDismissConfirmOpen(false);
|
|
||||||
navigate("/settings");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientAdvertisementsContext.Provider value={{
|
<ClientAdvertisementsContext.Provider value={{
|
||||||
advertisements,
|
advertisements,
|
||||||
@@ -244,7 +229,6 @@ export function ClientAdvertisementsProvider({ children }) {
|
|||||||
getActiveAdvertisementList,
|
getActiveAdvertisementList,
|
||||||
trackClick,
|
trackClick,
|
||||||
handleAdCtaClick,
|
handleAdCtaClick,
|
||||||
dismissPopupForever,
|
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
@@ -260,19 +244,6 @@ export function ClientAdvertisementsProvider({ children }) {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ResponsiveModal
|
|
||||||
open={dismissConfirmOpen}
|
|
||||||
onOpenChange={setDismissConfirmOpen}
|
|
||||||
title="Popup ads turned off"
|
|
||||||
description="You won't see popup ads anymore. You can turn them back on anytime in Settings → Advertisements."
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" onClick={() => setDismissConfirmOpen(false)}>Got it</Button>
|
|
||||||
<Button onClick={goToAdSettings}>Go to Settings</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ClientAdvertisementsContext.Provider>
|
</ClientAdvertisementsContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ const DEFAULT_PAGINATION = { page: 1, limit: 10, pages: 1, total: 0 };
|
|||||||
export function ClientNotificationProvider({ children }) {
|
export function ClientNotificationProvider({ children }) {
|
||||||
const [notifications, setNotifications] = useState([]);
|
const [notifications, setNotifications] = useState([]);
|
||||||
const [unseenCount, setUnseenCount] = useState(0);
|
const [unseenCount, setUnseenCount] = useState(0);
|
||||||
const [stickyAnnouncement, setStickyAnnouncement] = useState(null);
|
const [stickyAnnouncements, setStickyAnnouncements] = useState([]);
|
||||||
|
const [bannerImage, setBannerImage] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
||||||
const intervalRef = useRef(null);
|
const intervalRef = useRef(null);
|
||||||
@@ -35,7 +36,8 @@ export function ClientNotificationProvider({ children }) {
|
|||||||
const fetchStickyAnnouncement = useCallback(async () => {
|
const fetchStickyAnnouncement = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get("/client/notifications/sticky");
|
const res = await api.get("/client/notifications/sticky");
|
||||||
setStickyAnnouncement(res.data?.data?.announcement ?? null);
|
setStickyAnnouncements(res.data?.data?.announcements ?? []);
|
||||||
|
setBannerImage(res.data?.data?.bannerImage ?? null);
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
@@ -66,7 +68,7 @@ export function ClientNotificationProvider({ children }) {
|
|||||||
setNotifications([]);
|
setNotifications([]);
|
||||||
setUnseenCount(0);
|
setUnseenCount(0);
|
||||||
setPagination(DEFAULT_PAGINATION);
|
setPagination(DEFAULT_PAGINATION);
|
||||||
setStickyAnnouncement(null);
|
setStickyAnnouncements([]);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -80,9 +82,7 @@ export function ClientNotificationProvider({ children }) {
|
|||||||
prev.map(n => n.notification_id === id ? { ...n, seen: true } : n)
|
prev.map(n => n.notification_id === id ? { ...n, seen: true } : n)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (stickyAnnouncement?.notification_id === id) {
|
setStickyAnnouncements(prev => prev.filter(a => a.notification_id !== id));
|
||||||
setStickyAnnouncement(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-sync badge + sticky immediately (handles cases where the marked
|
// Re-sync badge + sticky immediately (handles cases where the marked
|
||||||
// row isn't present in the currently loaded notifications page).
|
// row isn't present in the currently loaded notifications page).
|
||||||
@@ -90,14 +90,14 @@ export function ClientNotificationProvider({ children }) {
|
|||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
}, [stickyAnnouncement?.notification_id, fetchUnseen, fetchStickyAnnouncement]);
|
}, [fetchUnseen, fetchStickyAnnouncement]);
|
||||||
|
|
||||||
const markAllSeen = useCallback(async () => {
|
const markAllSeen = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await api.patch("/client/notifications/seen-all");
|
await api.patch("/client/notifications/seen-all");
|
||||||
setNotifications(prev => prev.map(n => ({ ...n, seen: true })));
|
setNotifications(prev => prev.map(n => ({ ...n, seen: true })));
|
||||||
setUnseenCount(0);
|
setUnseenCount(0);
|
||||||
setStickyAnnouncement(null);
|
setStickyAnnouncements([]);
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,8 @@ export function ClientNotificationProvider({ children }) {
|
|||||||
<ClientNotificationContext.Provider value={{
|
<ClientNotificationContext.Provider value={{
|
||||||
notifications,
|
notifications,
|
||||||
unseenCount,
|
unseenCount,
|
||||||
stickyAnnouncement,
|
stickyAnnouncements,
|
||||||
|
bannerImage,
|
||||||
loading,
|
loading,
|
||||||
pagination,
|
pagination,
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Users, GitFork, FolderOpen, BookText, BookCheck, FileText, ListCheck, ShieldCheck, Megaphone, Bell, Trophy, Cog } from "lucide-react";
|
import { Users, GitFork, FolderOpen, BookText, BookCheck, FileText, ListCheck, ShieldCheck, Megaphone, Bell, Cog } from "lucide-react";
|
||||||
|
|
||||||
export const ADMIN_SECTIONS = [
|
export const ADMIN_SECTIONS = [
|
||||||
{
|
{
|
||||||
@@ -43,7 +43,6 @@ export const ADMIN_SECTIONS = [
|
|||||||
tiles: [
|
tiles: [
|
||||||
{ key: "advertisements", label: "Advertisements", icon: Megaphone, link: "/admin/advertisements" },
|
{ key: "advertisements", label: "Advertisements", icon: Megaphone, link: "/admin/advertisements" },
|
||||||
{ key: "notifications", label: "Announcements", icon: Bell, link: "/admin/announcements" },
|
{ key: "notifications", label: "Announcements", icon: Bell, link: "/admin/announcements" },
|
||||||
{ key: "achievements", label: "Achievements", icon: Trophy, link: "/admin/achievements" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,40 +1,36 @@
|
|||||||
// data/advertisements.data.js
|
// data/advertisements.data.js
|
||||||
import { Megaphone, Image, BellRing, PanelRight } from "lucide-react";
|
import { Megaphone, Image } from "lucide-react";
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
// Drives: filter dropdown options, type badge label/icon on each card,
|
// Drives: filter dropdown options, type badge label/icon on each card,
|
||||||
// and which fields the Add/Edit form shows (hero needs headline/description/ctas,
|
// and which fields the Add/Edit form shows (hero needs headline/description/ctas,
|
||||||
// banner/popup/sidebar are closer to image-only).
|
// banner is closer to image-only).
|
||||||
|
|
||||||
// TODO(ads-1): Once placements are re-categorized (Hero -> Dashboard, Banner ->
|
|
||||||
// Tier Plans) and popup/sidebar placements are removed, drop the "popup" and
|
|
||||||
// "sidebar" entries here too — see data/placement.data.js.
|
|
||||||
export const ADVERTISEMENT_TYPES = [
|
export const ADVERTISEMENT_TYPES = [
|
||||||
{ value: "hero", label: "Hero", icon: Megaphone, description: "Large featured banner with headline, description, and CTAs" },
|
{ value: "hero", label: "Hero", icon: Megaphone, description: "Large featured banner with headline, description, and CTAs" },
|
||||||
{ value: "banner", label: "Banner", icon: Image, description: "Simple image banner" },
|
{ value: "banner", label: "Banner", icon: Image, description: "Simple image banner" },
|
||||||
{ value: "popup", label: "Popup", icon: BellRing, description: "Modal-style popup shown on page load" },
|
|
||||||
{ value: "sidebar", label: "Sidebar", icon: PanelRight, description: "Compact image placed in a sidebar slot" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ADVERTISEMENT_TYPE_MAP = Object.fromEntries(
|
export const ADVERTISEMENT_TYPE_MAP = Object.fromEntries(
|
||||||
ADVERTISEMENT_TYPES.map((t) => [t.value, t])
|
ADVERTISEMENT_TYPES.map((t) => [t.value, t])
|
||||||
);
|
);
|
||||||
|
|
||||||
// Types that show the rich content fields (headline, description, CTAs) in the form
|
// ─── Content modes ──────────────────────────────────────────────────────────
|
||||||
export const RICH_CONTENT_TYPES = ["hero"];
|
// Whether an ad is image-only or carries badge/headline/description/CTAs
|
||||||
|
// alongside the image — an explicit admin choice, decoupled from placement/format.
|
||||||
|
export const CONTENT_MODES = [
|
||||||
|
{ value: "image", label: "Full image" },
|
||||||
|
{ value: "content", label: "Content + image" },
|
||||||
|
];
|
||||||
|
|
||||||
// ─── Statuses ───────────────────────────────────────────────────────────────
|
// ─── Statuses ───────────────────────────────────────────────────────────────
|
||||||
// Drives: filter dropdown options, status badge color/label on each card.
|
// Drives: filter dropdown options, status badge color/label on each card.
|
||||||
|
|
||||||
// TODO(ads-4): Remove "archived" from this list — it should no longer show up
|
|
||||||
// in the "All statuses" filter on AdvertisementList.jsx (archived ads live in
|
|
||||||
// their own separate Archived list/table, not mixed into the active filter).
|
|
||||||
export const ADVERTISEMENT_STATUSES = [
|
export const ADVERTISEMENT_STATUSES = [
|
||||||
{ value: "draft", label: "Draft", badgeClass: "bg-muted text-muted-foreground" },
|
{ value: "draft", label: "Draft", badgeClass: "bg-muted text-muted-foreground" },
|
||||||
{ value: "active", label: "Active", badgeClass: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400" },
|
{ value: "active", label: "Active", badgeClass: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400" },
|
||||||
{ value: "scheduled", label: "Scheduled", badgeClass: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400" },
|
{ value: "scheduled", label: "Scheduled", badgeClass: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400" },
|
||||||
{ value: "expired", label: "Expired", badgeClass: "bg-muted text-muted-foreground" },
|
{ value: "expired", label: "Expired", badgeClass: "bg-muted text-muted-foreground" },
|
||||||
{ value: "archived", label: "Archived", badgeClass: "bg-muted text-muted-foreground" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ADVERTISEMENT_STATUS_MAP = Object.fromEntries(
|
export const ADVERTISEMENT_STATUS_MAP = Object.fromEntries(
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
// Reference-only registry of {{placeholder}} tokens available per system
|
|
||||||
// notification type. Purely informational for the admin editor — the backend
|
|
||||||
// derives the real substitution data from wherever renderNotification({ type,
|
|
||||||
// data }) is called in code, this just tells the admin what's actually
|
|
||||||
// available to reference. Mirrors emailTemplatePlaceholders.data.js.
|
|
||||||
export const NOTIFICATION_TEMPLATE_PLACEHOLDERS = {
|
|
||||||
task_overdue: ["count", "task_word"],
|
|
||||||
user_registration: ["groupName", "groupCode", "userEmail"],
|
|
||||||
nogrp_user_registered: ["userEmail", "regType"],
|
|
||||||
task_requirements_updated: ["taskName", "taskListId", "groupId"],
|
|
||||||
user_task_overdue: ["count", "task_label", "task_list_ids"],
|
|
||||||
task_reminder: ["taskName", "deadline", "taskListId", "groupId"],
|
|
||||||
course_unlocked: ["courseTitle", "courseUuid"],
|
|
||||||
course_completed: ["courseTitle", "courseUuid"],
|
|
||||||
certificate_issued: ["courseTitle", "courseUuid"],
|
|
||||||
welcome: ["greeting", "group_suffix", "groupName", "groupCode", "accType"],
|
|
||||||
nogrp_welcome: [],
|
|
||||||
assessment_updated: ["assessmentTitle", "courseTitle", "courseUuid"],
|
|
||||||
tier_expired: ["planLabel", "tier", "label", "planId"],
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { ListChecks, GraduationCap, Megaphone, ClipboardCheck, CreditCard, UserPlus, Users } from "lucide-react";
|
|
||||||
|
|
||||||
// Groups notification templates by the `notify_type` written into the
|
|
||||||
// delivered notification row — every row here is is_system, so a category
|
|
||||||
// axis (like email templates' announcement/advertisement/system/other) would
|
|
||||||
// always resolve to a single value and add nothing. This is the axis that
|
|
||||||
// actually varies. Mirrors the shape of emailTemplateCategories.data.js.
|
|
||||||
export const NOTIFICATION_TEMPLATE_TYPES = [
|
|
||||||
{ value: "task_overdue", label: "Tasks (Admin)", icon: ListChecks },
|
|
||||||
{ value: "task", label: "Tasks (User)", icon: ListChecks },
|
|
||||||
{ value: "course", label: "Courses", icon: GraduationCap },
|
|
||||||
{ value: "announcement", label: "Announcements", icon: Megaphone },
|
|
||||||
{ value: "assessment", label: "Assessments", icon: ClipboardCheck },
|
|
||||||
{ value: "tier_expired", label: "Subscriptions", icon: CreditCard },
|
|
||||||
{ value: "user_registration", label: "New Registrations", icon: UserPlus },
|
|
||||||
{ value: "nogrp_user_registered", label: "Unaffiliated Users", icon: Users },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const getNotificationTemplateType = (value) =>
|
|
||||||
NOTIFICATION_TEMPLATE_TYPES.find((t) => t.value === value) ?? null;
|
|
||||||
@@ -6,17 +6,10 @@
|
|||||||
// backend registry when adding a new placement — same pattern as ADVERTISEMENT_TYPES already
|
// backend registry when adding a new placement — same pattern as ADVERTISEMENT_TYPES already
|
||||||
// mirroring the backend type ENUM.
|
// mirroring the backend type ENUM.
|
||||||
|
|
||||||
// TODO(ads-1): Re-categorize placements — Hero -> Dashboard, Banner -> Tier Plans.
|
|
||||||
// Remove the "popup" and "sidebar" formats entirely (dashboard.popup,
|
|
||||||
// course_details.sidebar). Keep in sync with the backend registry at
|
|
||||||
// new_starr/models/advertisements/advertisements.placements.js.
|
|
||||||
export const PLACEMENTS = [
|
export const PLACEMENTS = [
|
||||||
{ key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" },
|
{ key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" },
|
||||||
{ key: "dashboard.popup", format: "popup", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Popup (on load)" },
|
{ key: "tier_plans.banner", format: "banner", page: "tier_plans", pageLabel: "Tier Plans", slotLabel: "Banner (above plan cards)" },
|
||||||
{ key: "course_list.banner", format: "banner", page: "course_list", pageLabel: "Courses", slotLabel: "Banner (above course grid)" },
|
|
||||||
{ key: "course_details.banner", format: "banner", page: "course_details", pageLabel: "Course Details", slotLabel: "Banner (below hero)" },
|
{ key: "course_details.banner", format: "banner", page: "course_details", pageLabel: "Course Details", slotLabel: "Banner (below hero)" },
|
||||||
{ key: "course_details.sidebar", format: "sidebar", page: "course_details", pageLabel: "Course Details", slotLabel: "Sidebar (beside course content)" },
|
|
||||||
{ key: "plans.banner", format: "banner", page: "plans", pageLabel: "Plans", slotLabel: "Banner (above plan cards)" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PLACEMENT_MAP = Object.fromEntries(PLACEMENTS.map((p) => [p.key, p]));
|
export const PLACEMENT_MAP = Object.fromEntries(PLACEMENTS.map((p) => [p.key, p]));
|
||||||
|
|||||||
@@ -26,46 +26,15 @@ export const PLACEMENT_LAYOUTS = {
|
|||||||
{ kind: "grid", label: "Courses" },
|
{ kind: "grid", label: "Courses" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"dashboard.popup": {
|
|
||||||
blocks: [
|
|
||||||
{ kind: "nav" },
|
|
||||||
{ kind: "bar", size: "xl" },
|
|
||||||
{ kind: "list", label: "My Groups" },
|
|
||||||
{ kind: "grid", label: "Courses" },
|
|
||||||
],
|
|
||||||
overlay: { label: "Popup" },
|
|
||||||
},
|
|
||||||
"course_list.banner": {
|
|
||||||
blocks: [
|
|
||||||
{ kind: "nav" },
|
|
||||||
{ kind: "filters" },
|
|
||||||
{ kind: "bar", size: "md", highlight: true, label: "Banner" },
|
|
||||||
{ kind: "grid", label: "Course cards" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"course_details.banner": {
|
"course_details.banner": {
|
||||||
blocks: [
|
blocks: [
|
||||||
{ kind: "nav" },
|
{ kind: "nav" },
|
||||||
{ kind: "bar", size: "lg", label: "Course hero" },
|
{ kind: "bar", size: "lg", label: "Course hero" },
|
||||||
{ kind: "bar", size: "sm", highlight: true, label: "Banner" },
|
{ kind: "bar", size: "sm", highlight: true, label: "Banner" },
|
||||||
{ kind: "row", columns: [
|
{ kind: "list", label: "Course content" },
|
||||||
{ label: "Course content", width: "flex-1" },
|
|
||||||
{ label: "Sidebar", width: "w-1/4" },
|
|
||||||
] },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"course_details.sidebar": {
|
"tier_plans.banner": {
|
||||||
blocks: [
|
|
||||||
{ kind: "nav" },
|
|
||||||
{ kind: "bar", size: "lg", label: "Course hero" },
|
|
||||||
{ kind: "bar", size: "sm", label: "Banner" },
|
|
||||||
{ kind: "row", columns: [
|
|
||||||
{ label: "Course content", width: "flex-1" },
|
|
||||||
{ label: "Sidebar", width: "w-1/4", highlight: true },
|
|
||||||
] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"plans.banner": {
|
|
||||||
blocks: [
|
blocks: [
|
||||||
{ kind: "nav" },
|
{ kind: "nav" },
|
||||||
{ kind: "bar", size: "md", highlight: true, label: "Banner" },
|
{ kind: "bar", size: "md", highlight: true, label: "Banner" },
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ export const columnPinning = {
|
|||||||
|
|
||||||
const cellOverrides = {};
|
const cellOverrides = {};
|
||||||
|
|
||||||
// TODO(ads-9): Fix Sort and Columns on the Archived Advertisements table —
|
|
||||||
// sorting/column visibility currently misbehaves. Compare against a working
|
|
||||||
// DataTable usage elsewhere in admin/config to see what's diverging (likely
|
|
||||||
// an attributes/sort-key mismatch coming out of the paginate() response).
|
|
||||||
/**
|
/**
|
||||||
* Builds the full column array for the Archived Advertisements table.
|
* Builds the full column array for the Archived Advertisements table.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import * as LucideIcons from "lucide-react";
|
|
||||||
import { House, Plus, Pencil, Trash2, Trophy, Lock } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
|
||||||
import {
|
|
||||||
AdminAchievementsProvider,
|
|
||||||
useAdminAchievements,
|
|
||||||
} from "@/contexts/AdminAchievementsContext";
|
|
||||||
|
|
||||||
function AchievementCard({ item, onEdit, onDelete }) {
|
|
||||||
const Icon = LucideIcons[item.icon] ?? Trophy;
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-card p-5 flex items-start justify-between gap-4">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="w-12 h-12 rounded-lg border bg-muted flex items-center justify-center shrink-0">
|
|
||||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<p className="text-sm font-semibold">{item.label}</p>
|
|
||||||
<code className="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{item.key}</code>
|
|
||||||
<Badge variant="outline" className="text-[10px] capitalize">{item.type}</Badge>
|
|
||||||
{!item.is_active && <Badge variant="secondary">Inactive</Badge>}
|
|
||||||
{item.is_system && (
|
|
||||||
<Badge variant="secondary" className="gap-1">
|
|
||||||
<Lock className="h-2.5 w-2.5" /> System
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{item.description && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.description}</p>
|
|
||||||
)}
|
|
||||||
{item.trigger && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
Trigger: <span className="font-medium text-foreground capitalize">{item.trigger}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(item)}>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
{!item.is_system && (
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => onDelete(item)}>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AchievementsInner() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { achievements, loading, fetchAchievements, deleteAchievement } = useAdminAchievements();
|
|
||||||
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => { fetchAchievements(); }, []);
|
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
setDeleting(true);
|
|
||||||
await deleteAchievement(deleteTarget.achievement_definition_id);
|
|
||||||
setDeleting(false);
|
|
||||||
setDeleteTarget(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="bg-muted/60 min-h-full">
|
|
||||||
<PageMeta title="Achievements - STARR" />
|
|
||||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
|
||||||
<div className="w-full max-w-2xl mx-auto">
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
|
||||||
<AppBreadcrumb items={[
|
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
|
||||||
{ label: "Achievements" },
|
|
||||||
]} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold">Achievements</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
|
||||||
Badges and milestones learners can earn across the platform.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button size="sm" onClick={() => navigate("/admin/achievements/add")}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Add Achievement
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
|
|
||||||
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
<strong>System</strong> achievements are auto-granted by platform events (registration, course completion, etc.)
|
|
||||||
and cannot be deleted or have their key/type changed — everything else stays editable.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="mb-5" />
|
|
||||||
|
|
||||||
{loading && !achievements.length ? (
|
|
||||||
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
|
|
||||||
) : !achievements.length ? (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-12">No achievements found.</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{achievements.map((item) => (
|
|
||||||
<AchievementCard
|
|
||||||
key={item.achievement_definition_id}
|
|
||||||
item={item}
|
|
||||||
onEdit={(a) => navigate(`/admin/achievements/${a.achievement_definition_id}/edit`)}
|
|
||||||
onDelete={(a) => setDeleteTarget(a)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delete confirmation dialog */}
|
|
||||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
|
||||||
<DialogContent className="sm:max-w-sm">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete Achievement</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete{" "}
|
|
||||||
<span className="font-semibold text-foreground">{deleteTarget?.label}</span>?
|
|
||||||
This action cannot be undone. Any courses referencing this achievement must be unassigned first.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleting}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" onClick={confirmDelete} disabled={deleting}>
|
|
||||||
{deleting && <Spinner className="h-4 w-4 mr-2" />}
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Achievements() {
|
|
||||||
return (
|
|
||||||
<AdminAchievementsProvider>
|
|
||||||
<AchievementsInner />
|
|
||||||
</AdminAchievementsProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { ArrowLeft, House, X, Lock } from "lucide-react";
|
|
||||||
import { BADGE_ICON_OPTIONS } from "@/modules/admin/pages/tiers/EditTierCategory";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
|
||||||
import {
|
|
||||||
AdminAchievementsProvider,
|
|
||||||
useAdminAchievements,
|
|
||||||
} from "@/contexts/AdminAchievementsContext";
|
|
||||||
|
|
||||||
const TRIGGER_OPTIONS = [
|
|
||||||
{ value: "auth", label: "Auth (registration / login)" },
|
|
||||||
{ value: "tier", label: "Tier (subscription purchase)" },
|
|
||||||
{ value: "course", label: "Course (lessons / quizzes)" },
|
|
||||||
{ value: "profile", label: "Profile completion" },
|
|
||||||
{ value: "social", label: "Social (referrals / community)" },
|
|
||||||
{ value: "manual", label: "Manual (admin-granted only)" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function SectionCard({ title, children }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
|
||||||
{title && <p className="text-sm font-semibold border-b pb-3">{title}</p>}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldError({ message }) {
|
|
||||||
if (!message) return null;
|
|
||||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditAchievementInner({ isAdd }) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { id } = useParams();
|
|
||||||
const { achievement, loading, fetchAchievement, createAchievement, updateAchievement } = useAdminAchievements();
|
|
||||||
|
|
||||||
const [key, setKey] = useState("");
|
|
||||||
const [type, setType] = useState("badge");
|
|
||||||
const [label, setLabel] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [icon, setIcon] = useState(null);
|
|
||||||
const [trigger, setTrigger] = useState("manual");
|
|
||||||
const [isActive, setIsActive] = useState(true);
|
|
||||||
const [errors, setErrors] = useState({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAdd && id) fetchAchievement(id);
|
|
||||||
}, [id, isAdd]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (achievement && !isAdd) {
|
|
||||||
setKey(achievement.key ?? "");
|
|
||||||
setType(achievement.type ?? "badge");
|
|
||||||
setLabel(achievement.label ?? "");
|
|
||||||
setDescription(achievement.description ?? "");
|
|
||||||
setIcon(achievement.icon ?? null);
|
|
||||||
setTrigger(achievement.trigger ?? "manual");
|
|
||||||
setIsActive(achievement.is_active ?? true);
|
|
||||||
}
|
|
||||||
}, [achievement, isAdd]);
|
|
||||||
|
|
||||||
const validate = () => {
|
|
||||||
const e = {};
|
|
||||||
if (!label.trim()) e.label = "Label is required.";
|
|
||||||
if (isAdd && !key.trim()) e.key = "Key is required.";
|
|
||||||
if (isAdd && !/^[a-z0-9_]+$/.test(key)) e.key = "Key must be lowercase letters, numbers or underscores.";
|
|
||||||
setErrors(e);
|
|
||||||
return !Object.keys(e).length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!validate()) return;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
type,
|
|
||||||
label: label.trim(),
|
|
||||||
description: description.trim() || null,
|
|
||||||
icon: icon || null,
|
|
||||||
trigger: trigger || null,
|
|
||||||
is_active: isActive,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isAdd) {
|
|
||||||
payload.key = key.trim();
|
|
||||||
const result = await createAchievement(payload);
|
|
||||||
if (result) navigate("/admin/achievements");
|
|
||||||
} else {
|
|
||||||
const result = await updateAchievement(id, payload);
|
|
||||||
if (result) navigate("/admin/achievements");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSystem = !isAdd && achievement?.is_system;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="bg-muted/60 min-h-full">
|
|
||||||
<PageMeta title={isAdd ? "Add Achievement - STARR" : "Edit Achievement - STARR"} />
|
|
||||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
|
||||||
<div className="w-full max-w-2xl mx-auto">
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
|
||||||
<AppBreadcrumb items={[
|
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
|
||||||
{ label: "Achievements", to: "/admin/achievements" },
|
|
||||||
{ label: isAdd ? "Add Achievement" : (achievement?.label ?? "Edit") },
|
|
||||||
]} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold">{isAdd ? "Add Achievement" : "Edit Achievement"}</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{isAdd ? "Define a new badge or milestone learners can earn." : "Update this achievement's details."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isSystem && (
|
|
||||||
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
|
|
||||||
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
This is a <strong>system</strong> achievement — it's auto-granted by platform code that references
|
|
||||||
its key directly, so the key and type are locked. Label, description, icon, trigger and active state are still editable.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-5">
|
|
||||||
|
|
||||||
<SectionCard title="Achievement Details">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="key">Key <span className="text-destructive">*</span></Label>
|
|
||||||
<Input
|
|
||||||
id="key"
|
|
||||||
value={key}
|
|
||||||
onChange={(e) => setKey(e.target.value.toLowerCase())}
|
|
||||||
placeholder="e.g. course_marathon"
|
|
||||||
disabled={!isAdd}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">Lowercase, no spaces. Cannot be changed after creation.</p>
|
|
||||||
<FieldError message={errors.key} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
|
|
||||||
<Input id="label" value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Course Marathon" />
|
|
||||||
<FieldError message={errors.label} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="description">Description</Label>
|
|
||||||
<Textarea id="description" value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="What does a learner do to earn this?" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Type</Label>
|
|
||||||
<Select value={type} onValueChange={setType} disabled={isSystem}>
|
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="badge">Badge</SelectItem>
|
|
||||||
<SelectItem value="milestone">Milestone</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Trigger</Label>
|
|
||||||
<Select value={trigger} onValueChange={setTrigger}>
|
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{TRIGGER_OPTIONS.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-xs text-muted-foreground">Informational only — doesn't wire up new automatic grants by itself.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Switch id="is_active" checked={isActive} onCheckedChange={setIsActive} />
|
|
||||||
<Label htmlFor="is_active">Active</Label>
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<SectionCard title="Icon">
|
|
||||||
<p className="text-xs text-muted-foreground -mt-1">
|
|
||||||
Shown next to this achievement wherever it's displayed to learners.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIcon(null)}
|
|
||||||
className={`flex items-center justify-center w-9 h-9 rounded-lg border-2 text-xs text-muted-foreground transition-all ${!icon ? "border-foreground bg-muted scale-105" : "border-border hover:border-muted-foreground"}`}
|
|
||||||
title="No icon"
|
|
||||||
>
|
|
||||||
<X className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
{BADGE_ICON_OPTIONS.map(({ name, icon: Icon }) => {
|
|
||||||
const selected = icon === name;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={name}
|
|
||||||
type="button"
|
|
||||||
title={name}
|
|
||||||
onClick={() => setIcon(name)}
|
|
||||||
className={`flex items-center justify-center w-9 h-9 rounded-lg border-2 transition-all ${selected ? "bg-secondary text-secondary-foreground border-foreground scale-105" : "border-border hover:border-muted-foreground"}`}
|
|
||||||
>
|
|
||||||
<Icon className="size-4" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{icon && (
|
|
||||||
<p className="text-xs text-muted-foreground">Selected: <span className="font-medium">{icon}</span></p>
|
|
||||||
)}
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
|
|
||||||
<Button type="button" onClick={handleSave} disabled={loading}>
|
|
||||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
|
||||||
{isAdd ? "Create Achievement" : "Save Changes"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Wrapper({ isAdd }) {
|
|
||||||
return (
|
|
||||||
<AdminAchievementsProvider>
|
|
||||||
<EditAchievementInner isAdd={isAdd} />
|
|
||||||
</AdminAchievementsProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AddAchievement() { return <Wrapper isAdd={true} />; }
|
|
||||||
export function EditAchievement() { return <Wrapper isAdd={false} />; }
|
|
||||||
@@ -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,
|
||||||
Link2, CalendarClock, Check, ChevronLeft, ChevronRight,
|
LayoutTemplate, CalendarClock, Check, ChevronLeft, ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||||
@@ -25,16 +25,18 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
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 { RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
|
import { MAX_CTAS, CONTENT_MODES } from "@/data/advertisement.data";
|
||||||
import { AD_PAGES, PLACEMENT_MAP } from "@/data/placement.data";
|
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
|
||||||
|
|
||||||
// ─── Schema ─────────────────────────────────────────────────────────────────
|
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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"),
|
||||||
badge_label: z.string().optional(),
|
badge_label: z.string().optional(),
|
||||||
headline: z.string().optional(),
|
headline: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
@@ -45,6 +47,16 @@ const schema = z.object({
|
|||||||
link: z.string().min(1, "Link is required."),
|
link: z.string().min(1, "Link is required."),
|
||||||
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(),
|
||||||
|
landing_page: z.object({
|
||||||
|
title: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
body: z.string().optional(),
|
||||||
|
links: z.array(z.object({
|
||||||
|
label: z.string().optional(),
|
||||||
|
link: z.string().optional(),
|
||||||
|
})).default([]),
|
||||||
|
}).default({}),
|
||||||
start_date: z.string().optional(),
|
start_date: z.string().optional(),
|
||||||
end_date: z.string().optional(),
|
end_date: z.string().optional(),
|
||||||
order: z.coerce.number().min(0).default(0),
|
order: z.coerce.number().min(0).default(0),
|
||||||
@@ -64,30 +76,15 @@ const schema = z.object({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO(ads-6): Rework this wizard to match the target spec:
|
|
||||||
// Step 1 Placement — choose ONLY Dashboard, Tier Plans, or Course Details
|
|
||||||
// (depends on ads-1 registry re-categorization).
|
|
||||||
// Step 2 Content — pick "full image" vs "content + image":
|
|
||||||
// full image -> image only
|
|
||||||
// content+img -> badge label, headline, description,
|
|
||||||
// CTAs, redirect link
|
|
||||||
// Step 3 Page Builder — only shown when no redirect link was provided;
|
|
||||||
// builds an internal landing page (title, description,
|
|
||||||
// body, links, etc.) — new step, doesn't exist yet.
|
|
||||||
// Step 4 Scheduling & Display — start date, end date, order, and an
|
|
||||||
// active/draft switch labeled "Draft" when off
|
|
||||||
// (currently has start/end/order but check the
|
|
||||||
// on/off switch's Draft/Inactive labeling matches).
|
|
||||||
// Step 5 Review — display all details.
|
|
||||||
// ─── Steps ────────────────────────────────────────────────────────────────────
|
// ─── Steps ────────────────────────────────────────────────────────────────────
|
||||||
// richOnly steps are skipped entirely for placements whose format isn't a
|
// The Page Builder step only shows up when no redirect_link was given — it's
|
||||||
// RICH_CONTENT_TYPES format (banner/popup/sidebar never had CTAs).
|
// the alternative click-through destination (an internally-authored landing
|
||||||
|
// 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 the page and position this ad will appear in." },
|
{ 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: "Headline, description, and badge text for this placement." },
|
{ id: "content", label: "Content", icon: FileText, description: "Full image, or content with badge, headline, description, and CTAs." },
|
||||||
{ id: "image", label: "Image", icon: ImagePlus, description: "Choose an existing asset from Asset Management." },
|
{ id: "pageBuilder", label: "Page Builder", icon: LayoutTemplate, description: "No redirect link? Build an internal landing page for this ad instead.", skippable: true },
|
||||||
{ id: "ctas", label: "CTAs", icon: Link2, description: `Up to ${MAX_CTAS} buttons shown on the placement.`, richOnly: true },
|
{ id: "scheduling", label: "Scheduling & Display", icon: CalendarClock, description: "Start/end dates, display order, and draft/active status." },
|
||||||
{ id: "scheduling", label: "Scheduling & Display", icon: CalendarClock, description: "Optional start/end dates, manual ordering, and the on/off switch." },
|
|
||||||
{ id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this advertisement." },
|
{ id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this advertisement." },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -152,50 +149,28 @@ function Stepper({ steps, stepIndex }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Step: Placement ────────────────────────────────────────────────────────
|
// ─── Step 1: Placement ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StepPlacement({ selectedPage, setSelectedPage, placement, setValue, positionOptions, errors, format, isBanner, watch }) {
|
function StepPlacement({ placement, setValue, errors, format, isBanner, watch }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Page</Label>
|
<Label className="mb-1.5 block">Placement</Label>
|
||||||
<Select
|
|
||||||
value={selectedPage ?? undefined}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setSelectedPage(v);
|
|
||||||
setValue("placement", "", { shouldValidate: false });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a page" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{AD_PAGES.map((p) => (
|
|
||||||
<SelectItem key={p.page} value={p.page}>{p.pageLabel}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Position</Label>
|
|
||||||
<Select
|
<Select
|
||||||
value={placement || undefined}
|
value={placement || undefined}
|
||||||
onValueChange={(v) => setValue("placement", v, { shouldValidate: true })}
|
onValueChange={(v) => setValue("placement", v, { shouldValidate: true })}
|
||||||
disabled={!selectedPage}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={selectedPage ? "Select a position" : "Select a page first"} />
|
<SelectValue placeholder="Select a placement" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{positionOptions.map((p) => (
|
{PLACEMENTS.map((p) => (
|
||||||
<SelectItem key={p.key} value={p.key}>{p.slotLabel}</SelectItem>
|
<SelectItem key={p.key} value={p.key}>{p.pageLabel}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FieldError message={errors.placement?.message} />
|
<FieldError message={errors.placement?.message} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{placement && (
|
{placement && (
|
||||||
<div>
|
<div>
|
||||||
@@ -231,47 +206,9 @@ function StepPlacement({ selectedPage, setSelectedPage, placement, setValue, pos
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Step: Content ──────────────────────────────────────────────────────────
|
// ─── Step 2: Content ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StepContent({ register, errors, showRichContent, description, format }) {
|
function StepImagePicker({ selectedAsset, imageUrl, setPickerOpen }) {
|
||||||
if (!showRichContent) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Headline (optional)</Label>
|
|
||||||
<Input placeholder="Internal label for this ad" {...register("headline")} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Badge label</Label>
|
|
||||||
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Headline</Label>
|
|
||||||
<Input placeholder="e.g. Discover the World Top Designers" {...register("headline")} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Description</Label>
|
|
||||||
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
|
|
||||||
{format === "hero" && (
|
|
||||||
<div className="flex justify-between items-start mt-1">
|
|
||||||
<FieldError message={errors.description?.message} />
|
|
||||||
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
|
|
||||||
{description?.length ?? 0}/200
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Step: Image ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function StepImage({ selectedAsset, imageUrl, setPickerOpen }) {
|
|
||||||
return selectedAsset ? (
|
return selectedAsset ? (
|
||||||
<div
|
<div
|
||||||
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
|
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
|
||||||
@@ -298,10 +235,67 @@ function StepImage({ selectedAsset, imageUrl, setPickerOpen }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Step: CTAs ─────────────────────────────────────────────────────────────
|
function StepContent({
|
||||||
|
register, errors, setValue, watch, description, format,
|
||||||
|
selectedAsset, imageUrl, setPickerOpen,
|
||||||
|
ctaFields, appendCta, removeCta,
|
||||||
|
}) {
|
||||||
|
const contentMode = watch("content_mode");
|
||||||
|
|
||||||
function StepCtas({ ctaFields, register, errors, watch, setValue, appendCta, removeCta }) {
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Content type</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{CONTENT_MODES.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setValue("content_mode", m.value, { shouldValidate: true })}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border p-3 text-left transition-colors",
|
||||||
|
contentMode === m.value ? "border-primary bg-primary/5" : "hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">{m.label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{m.value === "image" ? "Just an image, no text overlay." : "Badge, headline, description, and CTAs."}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Image</Label>
|
||||||
|
<StepImagePicker selectedAsset={selectedAsset} imageUrl={imageUrl} setPickerOpen={setPickerOpen} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{contentMode === "content" && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Badge label</Label>
|
||||||
|
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Headline</Label>
|
||||||
|
<Input placeholder="e.g. Discover the World Top Designers" {...register("headline")} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Description</Label>
|
||||||
|
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
|
||||||
|
{format === "hero" && (
|
||||||
|
<div className="flex justify-between items-start mt-1">
|
||||||
|
<FieldError message={errors.description?.message} />
|
||||||
|
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||||
|
{description?.length ?? 0}/200
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Calls to action</Label>
|
||||||
<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">
|
||||||
@@ -347,22 +341,85 @@ function StepCtas({ ctaFields, register, errors, watch, setValue, appendCta, rem
|
|||||||
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Redirect link</Label>
|
||||||
|
<Input placeholder="e.g. /courses or https://example.com" {...register("redirect_link")} />
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
|
Where clicking the ad itself goes to. Leave blank to build an internal landing page in the next step instead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Step: Scheduling & Display ─────────────────────────────────────────────
|
// ─── Step 3: Page Builder ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StepPageBuilder({ register, linkFields, appendLink, removeLink }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Page title</Label>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step 4: Scheduling & Display ───────────────────────────────────────────
|
||||||
|
|
||||||
function StepScheduling({ register, watch, setValue }) {
|
function StepScheduling({ register, watch, setValue }) {
|
||||||
|
const isActive = watch("is_active");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Start date</Label>
|
<Label className="mb-1.5 block">Start date</Label>
|
||||||
<Input type="datetime-local" {...register("start_date")} />
|
<DateTimePicker
|
||||||
|
value={watch("start_date") || null}
|
||||||
|
onChange={(iso) => setValue("start_date", iso ?? "", { shouldValidate: true })}
|
||||||
|
placeholder="No start date"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">End date</Label>
|
<Label className="mb-1.5 block">End date</Label>
|
||||||
<Input type="datetime-local" {...register("end_date")} />
|
<DateTimePicker
|
||||||
|
value={watch("end_date") || null}
|
||||||
|
onChange={(iso) => setValue("end_date", iso ?? "", { shouldValidate: true })}
|
||||||
|
placeholder="No end date"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -372,9 +429,9 @@ function StepScheduling({ register, watch, setValue }) {
|
|||||||
<Input type="number" min={0} {...register("order")} />
|
<Input type="number" min={0} {...register("order")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between border rounded-md px-3 h-9">
|
<div className="flex items-center justify-between border rounded-md px-3 h-9">
|
||||||
<Label className="text-sm">Active</Label>
|
<Label className="text-sm">{isActive ? "Active" : "Draft"}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
checked={watch("is_active")}
|
checked={isActive}
|
||||||
onCheckedChange={(v) => setValue("is_active", v)}
|
onCheckedChange={(v) => setValue("is_active", v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -383,7 +440,7 @@ function StepScheduling({ register, watch, setValue }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Step: Review ───────────────────────────────────────────────────────────
|
// ─── Step 5: Review ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SummaryRow({ label, value }) {
|
function SummaryRow({ label, value }) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
@@ -398,6 +455,7 @@ function SummaryRow({ label, value }) {
|
|||||||
function StepReview({ data, selectedAsset, imageUrl }) {
|
function StepReview({ data, selectedAsset, imageUrl }) {
|
||||||
const placementMeta = PLACEMENT_MAP[data.placement];
|
const placementMeta = PLACEMENT_MAP[data.placement];
|
||||||
const ctas = (data.ctas ?? []).filter((c) => c.label || c.link);
|
const ctas = (data.ctas ?? []).filter((c) => c.label || c.link);
|
||||||
|
const hasLandingPage = !data.redirect_link && (data.landing_page?.title || data.landing_page?.body);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -408,15 +466,19 @@ function StepReview({ data, selectedAsset, imageUrl }) {
|
|||||||
{placementMeta && <Badge variant="secondary" className="ml-auto capitalize">{placementMeta.format}</Badge>}
|
{placementMeta && <Badge variant="secondary" className="ml-auto capitalize">{placementMeta.format}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<SummaryRow label="Page" value={placementMeta?.pageLabel} />
|
<SummaryRow label="Page" value={placementMeta?.pageLabel} />
|
||||||
<SummaryRow label="Position" value={placementMeta?.slotLabel} />
|
|
||||||
<SummaryRow label="Size" value={data.size} />
|
<SummaryRow label="Size" value={data.size} />
|
||||||
</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">Content</p>
|
<p className="text-sm font-medium mb-2">Content</p>
|
||||||
|
<SummaryRow label="Type" value={data.content_mode === "content" ? "Content + image" : "Full image"} />
|
||||||
|
{data.content_mode === "content" && (
|
||||||
|
<>
|
||||||
<SummaryRow label="Badge" value={data.badge_label} />
|
<SummaryRow label="Badge" value={data.badge_label} />
|
||||||
<SummaryRow label="Headline" value={data.headline} />
|
<SummaryRow label="Headline" value={data.headline} />
|
||||||
<SummaryRow label="Description" value={data.description} />
|
<SummaryRow label="Description" value={data.description} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border rounded-lg p-4 space-y-1">
|
<div className="border rounded-lg p-4 space-y-1">
|
||||||
@@ -441,12 +503,26 @@ function StepReview({ data, selectedAsset, imageUrl }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="border rounded-lg p-4 space-y-1">
|
||||||
|
<p className="text-sm font-medium mb-2">Click-through</p>
|
||||||
|
{data.redirect_link ? (
|
||||||
|
<SummaryRow label="Redirect 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} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No redirect link or landing page set — this ad won't link anywhere when clicked.</p>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
<SummaryRow label="Start date" value={data.start_date} />
|
<SummaryRow label="Start date" value={data.start_date} />
|
||||||
<SummaryRow label="End date" value={data.end_date} />
|
<SummaryRow label="End date" value={data.end_date} />
|
||||||
<SummaryRow label="Order" value={data.order} />
|
<SummaryRow label="Order" value={data.order} />
|
||||||
<SummaryRow label="Active" value={data.is_active ? "Yes" : "No"} />
|
<SummaryRow label="Status" value={data.is_active ? "Active" : "Draft"} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -462,7 +538,6 @@ export default function AddAdvertisement() {
|
|||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
const [selectedAsset, setSelectedAsset] = useState(null);
|
||||||
const [imagePreviewUrl, setImagePreviewUrl] = useState(null);
|
const [imagePreviewUrl, setImagePreviewUrl] = useState(null);
|
||||||
const [selectedPage, setSelectedPage] = useState(null);
|
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -478,11 +553,14 @@ export default function AddAdvertisement() {
|
|||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
placement: undefined,
|
placement: undefined,
|
||||||
|
content_mode: "image",
|
||||||
badge_label: "",
|
badge_label: "",
|
||||||
headline: "",
|
headline: "",
|
||||||
description: "",
|
description: "",
|
||||||
image_asset_id: null,
|
image_asset_id: null,
|
||||||
ctas: [],
|
ctas: [],
|
||||||
|
redirect_link: "",
|
||||||
|
landing_page: { title: "", description: "", body: "", links: [] },
|
||||||
start_date: "",
|
start_date: "",
|
||||||
end_date: "",
|
end_date: "",
|
||||||
order: 0,
|
order: 0,
|
||||||
@@ -492,23 +570,23 @@ export default function AddAdvertisement() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
|
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
|
||||||
|
const { fields: linkFields, append: appendLink, remove: removeLink } = useFieldArray({ control, name: "landing_page.links" });
|
||||||
|
|
||||||
// selectedPage/selectedAsset live outside the form and their setValue()
|
// selectedAsset lives outside the form and its setValue() call doesn't pass
|
||||||
// calls don't pass shouldDirty, so isDirty alone would miss them.
|
// shouldDirty, so isDirty alone would miss it.
|
||||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(
|
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(
|
||||||
isDirty || !!selectedAsset || !!selectedPage
|
isDirty || !!selectedAsset
|
||||||
);
|
);
|
||||||
|
|
||||||
const placement = watch("placement");
|
const placement = watch("placement");
|
||||||
const description = watch("description");
|
const description = watch("description");
|
||||||
|
const redirectLink = watch("redirect_link");
|
||||||
const format = PLACEMENT_MAP[placement]?.format;
|
const format = PLACEMENT_MAP[placement]?.format;
|
||||||
const showRichContent = RICH_CONTENT_TYPES.includes(format);
|
|
||||||
const isBanner = format === "banner";
|
const isBanner = format === "banner";
|
||||||
const positionOptions = AD_PAGES.find((p) => p.page === selectedPage)?.placements ?? [];
|
|
||||||
|
|
||||||
const steps = useMemo(
|
const steps = useMemo(
|
||||||
() => ALL_STEPS.filter((s) => !s.richOnly || showRichContent),
|
() => ALL_STEPS.filter((s) => !s.skippable || !redirectLink?.trim()),
|
||||||
[showRichContent]
|
[redirectLink]
|
||||||
);
|
);
|
||||||
const stepIndex = Math.min(step, steps.length - 1);
|
const stepIndex = Math.min(step, steps.length - 1);
|
||||||
const current = steps[stepIndex];
|
const current = steps[stepIndex];
|
||||||
@@ -523,8 +601,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 = showRichContent ? ["headline", "description", "badge_label"] : ["headline"];
|
else if (current.id === "content") fields = watch("content_mode") === "content" ? ["headline", "description", "badge_label", "ctas"] : [];
|
||||||
else if (current.id === "ctas") fields = ["ctas"];
|
|
||||||
|
|
||||||
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));
|
||||||
@@ -537,6 +614,8 @@ export default function AddAdvertisement() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
image_asset_id: values.image_asset_id || null,
|
image_asset_id: values.image_asset_id || null,
|
||||||
|
redirect_link: values.redirect_link || null,
|
||||||
|
landing_page: values.redirect_link ? null : values.landing_page,
|
||||||
start_date: values.start_date || null,
|
start_date: values.start_date || null,
|
||||||
end_date: values.end_date || null,
|
end_date: values.end_date || null,
|
||||||
size: PLACEMENT_MAP[values.placement]?.format === "banner" ? (values.size || "md") : null,
|
size: PLACEMENT_MAP[values.placement]?.format === "banner" ? (values.size || "md") : null,
|
||||||
@@ -557,7 +636,7 @@ export default function AddAdvertisement() {
|
|||||||
<div className="w-full max-w-2xl pb-10 space-y-6">
|
<div className="w-full max-w-2xl pb-10 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">New advertisement</h1>
|
<h1 className="text-2xl font-semibold tracking-tight mb-1">New advertisement</h1>
|
||||||
<p className="text-sm text-muted-foreground">Create a banner, popup, or hero placement.</p>
|
<p className="text-sm text-muted-foreground">Create a hero or banner placement.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Stepper steps={steps} stepIndex={stepIndex} />
|
<Stepper steps={steps} stepIndex={stepIndex} />
|
||||||
@@ -570,11 +649,8 @@ export default function AddAdvertisement() {
|
|||||||
|
|
||||||
{current.id === "placement" && (
|
{current.id === "placement" && (
|
||||||
<StepPlacement
|
<StepPlacement
|
||||||
selectedPage={selectedPage}
|
|
||||||
setSelectedPage={setSelectedPage}
|
|
||||||
placement={placement}
|
placement={placement}
|
||||||
setValue={setValue}
|
setValue={setValue}
|
||||||
positionOptions={positionOptions}
|
|
||||||
errors={errors}
|
errors={errors}
|
||||||
format={format}
|
format={format}
|
||||||
isBanner={isBanner}
|
isBanner={isBanner}
|
||||||
@@ -585,25 +661,26 @@ export default function AddAdvertisement() {
|
|||||||
<StepContent
|
<StepContent
|
||||||
register={register}
|
register={register}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
showRichContent={showRichContent}
|
setValue={setValue}
|
||||||
|
watch={watch}
|
||||||
description={description}
|
description={description}
|
||||||
format={format}
|
format={format}
|
||||||
/>
|
selectedAsset={selectedAsset}
|
||||||
)}
|
imageUrl={imagePreviewUrl}
|
||||||
{current.id === "image" && (
|
setPickerOpen={setPickerOpen}
|
||||||
<StepImage selectedAsset={selectedAsset} imageUrl={imagePreviewUrl} setPickerOpen={setPickerOpen} />
|
|
||||||
)}
|
|
||||||
{current.id === "ctas" && (
|
|
||||||
<StepCtas
|
|
||||||
ctaFields={ctaFields}
|
ctaFields={ctaFields}
|
||||||
register={register}
|
|
||||||
errors={errors}
|
|
||||||
watch={watch}
|
|
||||||
setValue={setValue}
|
|
||||||
appendCta={appendCta}
|
appendCta={appendCta}
|
||||||
removeCta={removeCta}
|
removeCta={removeCta}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{current.id === "pageBuilder" && (
|
||||||
|
<StepPageBuilder
|
||||||
|
register={register}
|
||||||
|
linkFields={linkFields}
|
||||||
|
appendLink={appendLink}
|
||||||
|
removeLink={removeLink}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{current.id === "scheduling" && (
|
{current.id === "scheduling" && (
|
||||||
<StepScheduling register={register} watch={watch} setValue={setValue} />
|
<StepScheduling register={register} watch={watch} setValue={setValue} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,37 +18,40 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
|
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
|
||||||
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
|
import { PLACEMENT_MAP } from "@/data/placement.data";
|
||||||
|
import { TablePagination } from "@/components/generic/Table/TablePagination";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
export default function AdvertisementList() {
|
export default function AdvertisementList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement } = useAdvertisements();
|
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement } = useAdvertisements();
|
||||||
|
|
||||||
const [typeFilter, setTypeFilter] = useState("all");
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
const [placementFilter, setPlacementFilter] = useState("all");
|
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
const [searchInput, setSearchInput] = useState("");
|
const [searchInput, setSearchInput] = useState("");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
// TODO(ads-3): Filters are not actually filtering — the "status" filter in
|
const buildFilters = () => {
|
||||||
// particular compares against the stored `status` column, but status is only
|
|
||||||
// recomputed on read (see deriveStatus() in
|
|
||||||
// controllers/admin/advertisements.controller.js) and never persisted back
|
|
||||||
// to the DB. An ad that lapsed to "expired" still has status="active" in
|
|
||||||
// the row, so filtering by status here misses/matches the wrong rows.
|
|
||||||
// Needs either persisting the derived status on write/read, or filtering
|
|
||||||
// server-side using the same derivation logic. Also verify type/placement
|
|
||||||
// filters actually round-trip once ads-1/ads-2 land.
|
|
||||||
useEffect(() => {
|
|
||||||
const filters = [];
|
const filters = [];
|
||||||
if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter });
|
if (typeFilter !== "all") filters.push({ id: "type", value: typeFilter });
|
||||||
if (placementFilter !== "all") filters.push({ field: "placement", value: placementFilter });
|
if (statusFilter !== "all") filters.push({ id: "status", value: statusFilter });
|
||||||
if (statusFilter !== "all") filters.push({ field: "status", value: statusFilter });
|
if (search.trim()) filters.push({ id: "headline", value: search.trim() });
|
||||||
if (search.trim()) filters.push({ field: "headline", op: "ilike", value: search.trim() });
|
return filters;
|
||||||
|
};
|
||||||
|
|
||||||
fetchAdvertisements({ page: 1, limit: 24, filters });
|
// Single source of truth for fetching — filter setters below always pair
|
||||||
|
// their state update with setPage(1) in the same handler so this only
|
||||||
|
// ever fires once per change (no separate "reset page" effect racing it).
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAdvertisements({ page, limit: PAGE_SIZE, filters: buildFilters() });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [typeFilter, placementFilter, statusFilter, search]);
|
}, [typeFilter, statusFilter, search, page]);
|
||||||
|
|
||||||
|
const handleTypeFilter = (v) => { setTypeFilter(v); setPage(1); };
|
||||||
|
const handleStatusFilter = (v) => { setStatusFilter(v); setPage(1); };
|
||||||
|
const runSearch = () => { setSearch(searchInput); setPage(1); };
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
@@ -77,7 +80,7 @@ export default function AdvertisementList() {
|
|||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Advertisements</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Advertisements</h1>
|
||||||
<p className="text-sm text-muted-foreground">Manage public-facing banners, popups, and promotional placements</p>
|
<p className="text-sm text-muted-foreground">Manage public-facing hero and banner placements</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" onClick={() => navigate("/admin/advertisements/archived")}>
|
<Button variant="outline" onClick={() => navigate("/admin/advertisements/archived")}>
|
||||||
@@ -101,7 +104,7 @@ export default function AdvertisementList() {
|
|||||||
|
|
||||||
{/* ── Filters ────────────────────────────────────────────────── */}
|
{/* ── Filters ────────────────────────────────────────────────── */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
<Select value={typeFilter} onValueChange={handleTypeFilter}>
|
||||||
<SelectTrigger className="w-[150px] bg-background">
|
<SelectTrigger className="w-[150px] bg-background">
|
||||||
<SelectValue placeholder="All types" />
|
<SelectValue placeholder="All types" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -113,20 +116,7 @@ export default function AdvertisementList() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* TODO(ads-2): Remove this "All placements" dropdown entirely. */}
|
<Select value={statusFilter} onValueChange={handleStatusFilter}>
|
||||||
<Select value={placementFilter} onValueChange={setPlacementFilter}>
|
|
||||||
<SelectTrigger className="w-[220px] bg-background">
|
|
||||||
<SelectValue placeholder="All placements" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All placements</SelectItem>
|
|
||||||
{PLACEMENTS.map((p) => (
|
|
||||||
<SelectItem key={p.key} value={p.key}>{p.pageLabel} — {p.slotLabel}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
||||||
<SelectTrigger className="w-[150px] bg-background">
|
<SelectTrigger className="w-[150px] bg-background">
|
||||||
<SelectValue placeholder="All statuses" />
|
<SelectValue placeholder="All statuses" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -138,31 +128,23 @@ export default function AdvertisementList() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* TODO(ads-5): Verify this already satisfies the spec — search only
|
|
||||||
fires on button click / Enter (`search` state, not `searchInput`,
|
|
||||||
drives the fetch effect above), typing alone does not refetch.
|
|
||||||
Looks done already; double-check then mark complete. */}
|
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-[160px]">
|
<div className="flex items-center gap-2 flex-1 min-w-[160px]">
|
||||||
<div className="relative flex-1">
|
<div className="relative w-64">
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search advertisements..."
|
placeholder="Search advertisements..."
|
||||||
className="pl-8 bg-background"
|
className="pl-8 bg-background"
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") setSearch(searchInput); }}
|
onKeyDown={(e) => { if (e.key === "Enter") runSearch(); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="icon" className="shrink-0 bg-background" onClick={() => setSearch(searchInput)} aria-label="Search">
|
<Button variant="outline" size="icon" className="shrink-0 bg-background" onClick={runSearch} aria-label="Search">
|
||||||
<Search className="size-4" />
|
<Search className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TODO(ads-8): Add pagination controls for this grid — currently
|
|
||||||
always fetches page 1 / limit 24 with no way to reach further
|
|
||||||
pages (see `pagination` from useAdvertisements, already returned
|
|
||||||
by the API but unused here). */}
|
|
||||||
{/* ── Grid ───────────────────────────────────────────────────── */}
|
{/* ── Grid ───────────────────────────────────────────────────── */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
@@ -171,6 +153,7 @@ export default function AdvertisementList() {
|
|||||||
) : advertisements.length === 0 ? (
|
) : advertisements.length === 0 ? (
|
||||||
<EmptyState onCreate={() => navigate("/admin/advertisements/add")} />
|
<EmptyState onCreate={() => navigate("/admin/advertisements/add")} />
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{advertisements.map((ad) => (
|
{advertisements.map((ad) => (
|
||||||
<AdvertisementCard
|
<AdvertisementCard
|
||||||
@@ -182,6 +165,16 @@ export default function AdvertisementList() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-background rounded-lg border">
|
||||||
|
<TablePagination
|
||||||
|
pagination={pagination}
|
||||||
|
onPageChange={setPage}
|
||||||
|
rowCount={advertisements.length}
|
||||||
|
recordLabel="advertisement"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,7 +291,7 @@ function EmptyState({ onCreate }) {
|
|||||||
<Megaphone className="size-8 text-muted-foreground" />
|
<Megaphone className="size-8 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">No advertisements yet</p>
|
<p className="font-medium">No advertisements yet</p>
|
||||||
<p className="text-sm text-muted-foreground">Create your first banner, popup, or hero placement.</p>
|
<p className="text-sm text-muted-foreground">Create your first hero or banner placement.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onCreate}>
|
<Button onClick={onCreate}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
|
|||||||
@@ -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 { 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";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -18,16 +19,19 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { DateTimePicker } from "@/components/ui/date-time-picker";
|
||||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||||
|
|
||||||
import { RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
|
import { MAX_CTAS, CONTENT_MODES } from "@/data/advertisement.data";
|
||||||
import { AD_PAGES, PLACEMENT_MAP } from "@/data/placement.data";
|
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
|
||||||
|
|
||||||
// ─── Schema ─────────────────────────────────────────────────────────────────
|
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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"),
|
||||||
badge_label: z.string().optional(),
|
badge_label: z.string().optional(),
|
||||||
headline: z.string().optional(),
|
headline: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
@@ -38,6 +42,16 @@ const schema = z.object({
|
|||||||
link: z.string().min(1, "Link is required."),
|
link: z.string().min(1, "Link is required."),
|
||||||
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(),
|
||||||
|
landing_page: z.object({
|
||||||
|
title: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
body: z.string().optional(),
|
||||||
|
links: z.array(z.object({
|
||||||
|
label: z.string().optional(),
|
||||||
|
link: z.string().optional(),
|
||||||
|
})).default([]),
|
||||||
|
}).default({}),
|
||||||
start_date: z.string().optional(),
|
start_date: z.string().optional(),
|
||||||
end_date: z.string().optional(),
|
end_date: z.string().optional(),
|
||||||
order: z.coerce.number().min(0).default(0),
|
order: z.coerce.number().min(0).default(0),
|
||||||
@@ -78,14 +92,6 @@ function SectionCard({ title, description, children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert ISO datetime to value usable by <input type="datetime-local">
|
|
||||||
function toLocalInputValue(iso) {
|
|
||||||
if (!iso) return "";
|
|
||||||
const d = new Date(iso);
|
|
||||||
const pad = (n) => String(n).padStart(2, "0");
|
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BANNER_SIZES = [
|
const BANNER_SIZES = [
|
||||||
{ value: "sm", label: "Small" },
|
{ value: "sm", label: "Small" },
|
||||||
{ value: "md", label: "Medium" },
|
{ value: "md", label: "Medium" },
|
||||||
@@ -109,13 +115,9 @@ export default function EditAdvertisement() {
|
|||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
const [selectedAsset, setSelectedAsset] = useState(null);
|
||||||
const [imagePreviewUrl, setImagePreviewUrl] = useState(null);
|
const [imagePreviewUrl, setImagePreviewUrl] = useState(null);
|
||||||
const [selectedPage, setSelectedPage] = useState(null);
|
|
||||||
// Gates the form's first paint until the fetched advertisement has been
|
// Gates the form's first paint until the fetched advertisement has been
|
||||||
// applied via reset() + setSelectedPage(). Without this, the Page/Position
|
// applied via reset(). Without this, fields briefly mount with their empty
|
||||||
// selects briefly mount with their empty defaultValues (no page selected,
|
// defaultValues before the fetch resolves.
|
||||||
// no position options yet) before the fetch resolves — that first paint is
|
|
||||||
// enough for the position <Select> to lose track of the eventual value,
|
|
||||||
// leaving it visually unselected even after reset() runs.
|
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -130,11 +132,14 @@ export default function EditAdvertisement() {
|
|||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
placement: undefined,
|
placement: undefined,
|
||||||
|
content_mode: "image",
|
||||||
badge_label: "",
|
badge_label: "",
|
||||||
headline: "",
|
headline: "",
|
||||||
description: "",
|
description: "",
|
||||||
image_asset_id: null,
|
image_asset_id: null,
|
||||||
ctas: [],
|
ctas: [],
|
||||||
|
redirect_link: "",
|
||||||
|
landing_page: { title: "", description: "", body: "", links: [] },
|
||||||
start_date: "",
|
start_date: "",
|
||||||
end_date: "",
|
end_date: "",
|
||||||
order: 0,
|
order: 0,
|
||||||
@@ -144,15 +149,16 @@ export default function EditAdvertisement() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
|
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
|
||||||
|
const { fields: linkFields, append: appendLink, remove: removeLink } = useFieldArray({ control, name: "landing_page.links" });
|
||||||
|
|
||||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
||||||
|
|
||||||
const placement = watch("placement");
|
const placement = watch("placement");
|
||||||
const description = watch("description");
|
const description = watch("description");
|
||||||
|
const contentMode = watch("content_mode");
|
||||||
|
const redirectLink = watch("redirect_link");
|
||||||
const format = PLACEMENT_MAP[placement]?.format;
|
const format = PLACEMENT_MAP[placement]?.format;
|
||||||
const showRichContent = RICH_CONTENT_TYPES.includes(format);
|
|
||||||
const isBanner = format === "banner";
|
const isBanner = format === "banner";
|
||||||
const positionOptions = AD_PAGES.find((p) => p.page === selectedPage)?.placements ?? [];
|
|
||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
@@ -174,10 +180,10 @@ export default function EditAdvertisement() {
|
|||||||
// — no separate token round-trip needed.
|
// — no separate token round-trip needed.
|
||||||
setImagePreviewUrl(resolveAssetSrc(ad.image));
|
setImagePreviewUrl(resolveAssetSrc(ad.image));
|
||||||
}
|
}
|
||||||
setSelectedPage(PLACEMENT_MAP[ad.placement]?.page ?? null);
|
|
||||||
|
|
||||||
reset({
|
reset({
|
||||||
placement: ad.placement ?? undefined,
|
placement: ad.placement ?? undefined,
|
||||||
|
content_mode: ad.content_mode ?? "image",
|
||||||
badge_label: ad.badge_label ?? "",
|
badge_label: ad.badge_label ?? "",
|
||||||
headline: ad.headline ?? "",
|
headline: ad.headline ?? "",
|
||||||
description: ad.description ?? "",
|
description: ad.description ?? "",
|
||||||
@@ -187,8 +193,15 @@ export default function EditAdvertisement() {
|
|||||||
link: c.link ?? "",
|
link: c.link ?? "",
|
||||||
variant: c.variant ?? (i === 0 ? "default" : "outline"),
|
variant: c.variant ?? (i === 0 ? "default" : "outline"),
|
||||||
})),
|
})),
|
||||||
start_date: toLocalInputValue(ad.start_date),
|
redirect_link: ad.redirect_link ?? "",
|
||||||
end_date: toLocalInputValue(ad.end_date),
|
landing_page: {
|
||||||
|
title: ad.landing_page?.title ?? "",
|
||||||
|
description: ad.landing_page?.description ?? "",
|
||||||
|
body: ad.landing_page?.body ?? "",
|
||||||
|
links: (ad.landing_page?.links ?? []).map((l) => ({ label: l.label ?? "", link: l.link ?? "" })),
|
||||||
|
},
|
||||||
|
start_date: ad.start_date ?? "",
|
||||||
|
end_date: ad.end_date ?? "",
|
||||||
order: ad.order ?? 0,
|
order: ad.order ?? 0,
|
||||||
is_active: ad.is_active ?? true,
|
is_active: ad.is_active ?? true,
|
||||||
size: ad.size ?? null,
|
size: ad.size ?? null,
|
||||||
@@ -205,6 +218,8 @@ export default function EditAdvertisement() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
image_asset_id: values.image_asset_id || null,
|
image_asset_id: values.image_asset_id || null,
|
||||||
|
redirect_link: values.redirect_link || null,
|
||||||
|
landing_page: values.redirect_link ? null : values.landing_page,
|
||||||
start_date: values.start_date || null,
|
start_date: values.start_date || null,
|
||||||
end_date: values.end_date || null,
|
end_date: values.end_date || null,
|
||||||
size: PLACEMENT_MAP[values.placement]?.format === "banner" ? (values.size || "md") : null,
|
size: PLACEMENT_MAP[values.placement]?.format === "banner" ? (values.size || "md") : null,
|
||||||
@@ -234,50 +249,28 @@ export default function EditAdvertisement() {
|
|||||||
|
|
||||||
<div className="w-full max-w-2xl pb-10">
|
<div className="w-full max-w-2xl pb-10">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">Edit advertisement</h1>
|
<h1 className="text-2xl font-semibold tracking-tight mb-1">Edit advertisement</h1>
|
||||||
<p className="text-sm text-muted-foreground mb-6">Update this banner, popup, or hero placement.</p>
|
<p className="text-sm text-muted-foreground mb-6">Update this hero or banner placement.</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||||
|
|
||||||
<SectionCard title="Placement" description="Where this advertisement will appear.">
|
<SectionCard title="Placement" description="Where this advertisement will appear.">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Page</Label>
|
<Label className="mb-1.5 block">Placement</Label>
|
||||||
<Select
|
|
||||||
value={selectedPage ?? undefined}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setSelectedPage(v);
|
|
||||||
setValue("placement", "", { shouldValidate: false, shouldDirty: true });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a page" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{AD_PAGES.map((p) => (
|
|
||||||
<SelectItem key={p.page} value={p.page}>{p.pageLabel}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Position</Label>
|
|
||||||
<Select
|
<Select
|
||||||
value={placement || undefined}
|
value={placement || undefined}
|
||||||
onValueChange={(v) => setValue("placement", v, { shouldValidate: true, shouldDirty: true })}
|
onValueChange={(v) => setValue("placement", v, { shouldValidate: true, shouldDirty: true })}
|
||||||
disabled={!selectedPage}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={selectedPage ? "Select a position" : "Select a page first"} />
|
<SelectValue placeholder="Select a placement" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{positionOptions.map((p) => (
|
{PLACEMENTS.map((p) => (
|
||||||
<SelectItem key={p.key} value={p.key}>{p.slotLabel}</SelectItem>
|
<SelectItem key={p.key} value={p.key}>{p.pageLabel}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FieldError message={errors.placement?.message} />
|
<FieldError message={errors.placement?.message} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{format && (
|
{format && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -305,8 +298,28 @@ export default function EditAdvertisement() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{showRichContent && (
|
<SectionCard title="Content" description="Full image, or content with badge, headline, description, and CTAs.">
|
||||||
<SectionCard title="Content" description="Headline, description, and badge text shown on the hero placement.">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{CONTENT_MODES.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setValue("content_mode", m.value, { shouldValidate: true, shouldDirty: true })}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border p-3 text-left transition-colors",
|
||||||
|
contentMode === m.value ? "border-primary bg-primary/5" : "hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">{m.label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{m.value === "image" ? "Just an image, no text overlay." : "Badge, headline, description, and CTAs."}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{contentMode === "content" && (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Badge label</Label>
|
<Label className="mb-1.5 block">Badge label</Label>
|
||||||
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
|
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
|
||||||
@@ -327,17 +340,9 @@ export default function EditAdvertisement() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!showRichContent && (
|
|
||||||
<SectionCard title="Content" description="Optional headline for this placement.">
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Headline (optional)</Label>
|
|
||||||
<Input placeholder="Internal label for this ad" {...register("headline")} />
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
|
||||||
|
|
||||||
<SectionCard title="Image" description="Choose an existing asset from Asset Management.">
|
<SectionCard title="Image" description="Choose an existing asset from Asset Management.">
|
||||||
{selectedAsset ? (
|
{selectedAsset ? (
|
||||||
@@ -366,7 +371,7 @@ export default function EditAdvertisement() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{showRichContent && (
|
{contentMode === "content" && (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Calls to action"
|
title="Calls to action"
|
||||||
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. Each button can be styled as Primary or Outline.`}
|
||||||
@@ -417,27 +422,81 @@ export default function EditAdvertisement() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SectionCard title="Click-through" description="Where clicking the ad itself goes to.">
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Redirect link</Label>
|
||||||
|
<Input placeholder="e.g. /courses or https://example.com" {...register("redirect_link")} />
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
|
Leave blank to use the internal landing page below instead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!redirectLink?.trim() && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Page title</Label>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Start date</Label>
|
<Label className="mb-1.5 block">Start date</Label>
|
||||||
<Input type="datetime-local" {...register("start_date")} />
|
<DateTimePicker
|
||||||
|
value={watch("start_date") || null}
|
||||||
|
onChange={(iso) => setValue("start_date", iso ?? "", { shouldDirty: true })}
|
||||||
|
placeholder="No start date"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">End date</Label>
|
<Label className="mb-1.5 block">End date</Label>
|
||||||
<Input type="datetime-local" {...register("end_date")} />
|
<DateTimePicker
|
||||||
|
value={watch("end_date") || null}
|
||||||
|
onChange={(iso) => setValue("end_date", iso ?? "", { shouldDirty: true })}
|
||||||
|
placeholder="No end date"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="Display" description="Manual ordering and on/off switch.">
|
<SectionCard title="Display" description="Manual ordering and draft/active switch.">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Order</Label>
|
<Label className="mb-1.5 block">Order</Label>
|
||||||
<Input type="number" min={0} {...register("order")} />
|
<Input type="number" min={0} {...register("order")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between border rounded-md px-3 h-9">
|
<div className="flex items-center justify-between border rounded-md px-3 h-9">
|
||||||
<Label className="text-sm">Active</Label>
|
<Label className="text-sm">{watch("is_active") ? "Active" : "Draft"}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
checked={watch("is_active")}
|
checked={watch("is_active")}
|
||||||
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
|
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, RefreshCw } from "lucide-react";
|
import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
|
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -55,7 +56,6 @@ function formatBytes(bytes) {
|
|||||||
// ─── Thumbnail Drop Zone ──────────────────────────────────────────────────────
|
// ─── Thumbnail Drop Zone ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ThumbnailDropZone({ currentUrl, newFile, onFile, onClear, error }) {
|
function ThumbnailDropZone({ currentUrl, newFile, onFile, onClear, error }) {
|
||||||
console.log(currentUrl)
|
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
const preview = newFile
|
const preview = newFile
|
||||||
@@ -178,6 +178,11 @@ export default function EditAsset() {
|
|||||||
const isVideo = asset?.file_type === "video";
|
const isVideo = asset?.file_type === "video";
|
||||||
const hasThumbnailChange = !!thumbnailRef.current;
|
const hasThumbnailChange = !!thumbnailRef.current;
|
||||||
|
|
||||||
|
// asset.file_url is redacted to null for S3-stored assets (see
|
||||||
|
// redactS3Url in assets.controller.js) — resolve the real preview src
|
||||||
|
// the same way ViewImageAsset.jsx does instead of reading it raw.
|
||||||
|
const { src: previewSrc } = useAssetPreviewSrc(asset, { scope: "admin" });
|
||||||
|
|
||||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || hasThumbnailChange);
|
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || hasThumbnailChange);
|
||||||
|
|
||||||
const onSubmit = async (data) => {
|
const onSubmit = async (data) => {
|
||||||
@@ -265,7 +270,7 @@ export default function EditAsset() {
|
|||||||
</Label>
|
</Label>
|
||||||
<ThumbnailDropZone
|
<ThumbnailDropZone
|
||||||
key={thumbKey}
|
key={thumbKey}
|
||||||
currentUrl={asset.file_type === "video" ? asset.thumbnail_url : asset.file_url}
|
currentUrl={asset.file_type === "video" ? asset.thumbnail_url : previewSrc}
|
||||||
newFile={thumbnailRef.current}
|
newFile={thumbnailRef.current}
|
||||||
onFile={(f) => {
|
onFile={(f) => {
|
||||||
thumbnailRef.current = f;
|
thumbnailRef.current = f;
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// modules/admin/pages/notifications/AddNotificationBroadcast.jsx
|
// modules/admin/pages/notifications/AddNotificationBroadcast.jsx
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useForm } from "react-hook-form";
|
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 { House } from "lucide-react";
|
import { format } from "date-fns";
|
||||||
|
import { House, Check, ArrowLeft, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
import api from "@/utils/api.util";
|
|
||||||
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";
|
||||||
@@ -18,10 +18,13 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { DateTimePicker } from "@/components/ui/date-time-picker";
|
||||||
|
|
||||||
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
|
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
|
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
|
||||||
|
|
||||||
// ─── Schema ─────────────────────────────────────────────────────────────────
|
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -34,6 +37,10 @@ const schema = z.object({
|
|||||||
show_in_notifications: z.boolean().optional(),
|
show_in_notifications: z.boolean().optional(),
|
||||||
link_mode: z.enum(["info", "link"]).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(),
|
||||||
|
start_date: z.string().optional(),
|
||||||
|
end_date: z.string().optional(),
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
|
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
@@ -58,7 +65,24 @@ const schema = z.object({
|
|||||||
path: ["link_url"],
|
path: ["link_url"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.start_date && data.end_date && new Date(data.start_date) > new Date(data.end_date)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "End date must be after the start date.",
|
||||||
|
path: ["end_date"],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Steps config ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ label: "Content", description: "Title & message" },
|
||||||
|
{ label: "Target", description: "Who receives it" },
|
||||||
|
{ label: "Display", description: "Where it shows & schedule" },
|
||||||
|
{ label: "Review", description: "Confirm & save" },
|
||||||
|
];
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -81,18 +105,72 @@ function SectionCard({ title, description, children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StepIndicator({ steps, current, onStepClick }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start w-full mb-8">
|
||||||
|
{steps.flatMap((step, i) => {
|
||||||
|
const items = [
|
||||||
|
<button
|
||||||
|
key={`step-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStepClick(i)}
|
||||||
|
className="flex flex-col items-center gap-1.5 shrink-0 group"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"w-8 h-8 rounded-full border-2 flex items-center justify-center text-sm font-semibold transition-all group-hover:opacity-80",
|
||||||
|
i < current
|
||||||
|
? "bg-primary border-primary text-primary-foreground"
|
||||||
|
: i === current
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-border text-muted-foreground",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{i < current ? <Check className="h-4 w-4" /> : i + 1}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={[
|
||||||
|
"text-[11px] font-medium text-center leading-tight whitespace-nowrap",
|
||||||
|
i === current ? "text-foreground" : "text-muted-foreground",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</p>
|
||||||
|
</button>,
|
||||||
|
];
|
||||||
|
if (i < steps.length - 1) {
|
||||||
|
items.push(
|
||||||
|
<div
|
||||||
|
key={`line-${i}`}
|
||||||
|
className={[
|
||||||
|
"flex-1 h-px mt-4 mx-2 shrink",
|
||||||
|
i < current ? "bg-primary" : "bg-border",
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Page ───────────────────────────────────────────────────────────────────
|
// ─── Page ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AddNotificationBroadcast() {
|
export default function AddNotificationBroadcast() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { createBroadcast, loading } = useNotificationBroadcasts();
|
const { createBroadcast, sendBroadcast, loading } = useNotificationBroadcasts();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [targetLabel, setTargetLabel] = useState(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
|
trigger,
|
||||||
formState: { errors, isDirty },
|
formState: { errors, isDirty },
|
||||||
} = useForm({
|
} = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -105,31 +183,24 @@ export default function AddNotificationBroadcast() {
|
|||||||
show_in_notifications: true,
|
show_in_notifications: true,
|
||||||
link_mode: "info",
|
link_mode: "info",
|
||||||
link_url: "",
|
link_url: "",
|
||||||
|
link_label: "",
|
||||||
|
color: "indigo",
|
||||||
|
start_date: "",
|
||||||
|
end_date: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
||||||
|
|
||||||
const [templates, setTemplates] = useState([]);
|
|
||||||
useEffect(() => {
|
|
||||||
api.get("/admin/announcement-templates")
|
|
||||||
.then(({ data }) => setTemplates((data.data ?? []).filter((t) => !t.is_system)))
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const applyTemplate = (id) => {
|
|
||||||
const tpl = templates.find((t) => String(t.notification_template_id) === id);
|
|
||||||
if (!tpl) return;
|
|
||||||
setValue("title", tpl.title ?? "", { shouldValidate: true, shouldDirty: true });
|
|
||||||
setValue("message", tpl.message ?? "", { shouldValidate: true, shouldDirty: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const targetType = watch("target_type");
|
const targetType = watch("target_type");
|
||||||
const targetId = watch("target_id");
|
const targetId = watch("target_id");
|
||||||
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 linkMode = watch("link_mode");
|
||||||
|
const color = watch("color");
|
||||||
|
const startDate = watch("start_date");
|
||||||
|
const endDate = watch("end_date");
|
||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
@@ -137,7 +208,25 @@ export default function AddNotificationBroadcast() {
|
|||||||
{ label: "New" },
|
{ label: "New" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const onSubmit = async (values) => {
|
// Guards the step indicator: jumping ahead must not bypass required
|
||||||
|
// fields from earlier steps.
|
||||||
|
const goToStep = async (target) => {
|
||||||
|
if (target > 0) {
|
||||||
|
const valid = await trigger(["title", "message"]);
|
||||||
|
if (!valid) { setCurrentStep(0); return; }
|
||||||
|
}
|
||||||
|
if (target > 1) {
|
||||||
|
const valid = await trigger(["target_type", "target_id"]);
|
||||||
|
if (!valid) { setCurrentStep(1); return; }
|
||||||
|
}
|
||||||
|
if (target > 2) {
|
||||||
|
const valid = await trigger(["show_in_sticky", "show_in_notifications", "link_url", "end_date"]);
|
||||||
|
if (!valid) { setCurrentStep(2); return; }
|
||||||
|
}
|
||||||
|
setCurrentStep(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveBroadcast = async (values, { publish = false } = {}) => {
|
||||||
const { link_mode, ...rest } = values;
|
const { link_mode, ...rest } = values;
|
||||||
const payload = {
|
const payload = {
|
||||||
...rest,
|
...rest,
|
||||||
@@ -145,11 +234,24 @@ export default function AddNotificationBroadcast() {
|
|||||||
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") ? values.link_url.trim() : null,
|
link_url: (values.show_in_sticky && link_mode === "link") ? 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",
|
||||||
|
start_date: values.start_date || null,
|
||||||
|
end_date: values.end_date || null,
|
||||||
createdBy: user?.user_id ?? null,
|
createdBy: user?.user_id ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await createBroadcast(payload);
|
const res = await createBroadcast(payload);
|
||||||
if (res) { bypassOnce(); navigate("/admin/announcements"); }
|
const created = res?.data?.data ?? null;
|
||||||
|
if (!created) return;
|
||||||
|
|
||||||
|
if (publish) {
|
||||||
|
const sent = await sendBroadcast(created.broadcast_id);
|
||||||
|
if (!sent) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bypassOnce();
|
||||||
|
navigate("/admin/announcements");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -163,25 +265,13 @@ export default function AddNotificationBroadcast() {
|
|||||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">New announcement</h1>
|
<h1 className="text-2xl font-semibold tracking-tight mb-1">New announcement</h1>
|
||||||
<p className="text-sm text-muted-foreground mb-6">Compose an announcement. It's saved as a draft until you send it.</p>
|
<p className="text-sm text-muted-foreground mb-6">Compose an announcement. It's saved as a draft until you send it.</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
<StepIndicator steps={STEPS} current={currentStep} onStepClick={goToStep} />
|
||||||
|
|
||||||
|
<form onSubmit={(e) => e.preventDefault()} className="space-y-5">
|
||||||
|
|
||||||
|
{/* ── Step 0: Content ── */}
|
||||||
|
{currentStep === 0 && (
|
||||||
<SectionCard title="Content" description="What admins and/or users will see.">
|
<SectionCard title="Content" description="What admins and/or users will see.">
|
||||||
{templates.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Load from template</Label>
|
|
||||||
<Select onValueChange={applyTemplate}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Optional — start from a saved preset" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{templates.map((t) => (
|
|
||||||
<SelectItem key={t.notification_template_id} value={String(t.notification_template_id)}>{t.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">Fills in the title and message below — you can still edit them after.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Title</Label>
|
<Label className="mb-1.5 block">Title</Label>
|
||||||
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
|
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
|
||||||
@@ -193,7 +283,10 @@ export default function AddNotificationBroadcast() {
|
|||||||
<FieldError message={errors.message?.message} />
|
<FieldError message={errors.message?.message} />
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 1: Target ── */}
|
||||||
|
{currentStep === 1 && (
|
||||||
<SectionCard title="Target" description="Who receives this announcement when it's sent.">
|
<SectionCard title="Target" description="Who receives this announcement when it's sent.">
|
||||||
<div>
|
<div>
|
||||||
<Select
|
<Select
|
||||||
@@ -201,6 +294,7 @@ export default function AddNotificationBroadcast() {
|
|||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
|
setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
|
||||||
setValue("target_id", null, { shouldDirty: true });
|
setValue("target_id", null, { shouldDirty: true });
|
||||||
|
setTargetLabel(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -226,19 +320,31 @@ export default function AddNotificationBroadcast() {
|
|||||||
targetType={targetType}
|
targetType={targetType}
|
||||||
value={targetId}
|
value={targetId}
|
||||||
onChange={(id) => setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
|
onChange={(id) => setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
|
||||||
|
onLabelResolved={setTargetLabel}
|
||||||
/>
|
/>
|
||||||
<FieldError message={errors.target_id?.message} />
|
<FieldError message={errors.target_id?.message} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 2: Display ── */}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<>
|
||||||
<SectionCard title="Display" description="Where clients/admins can see this announcement.">
|
<SectionCard title="Display" description="Where clients/admins can see this announcement.">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="show_in_sticky"
|
id="show_in_sticky"
|
||||||
checked={showInSticky === true}
|
checked={showInSticky === true}
|
||||||
onCheckedChange={(v) => setValue("show_in_sticky", v === true, { shouldValidate: true, shouldDirty: true })}
|
onCheckedChange={(v) => {
|
||||||
|
const checked = v === true;
|
||||||
|
setValue("show_in_sticky", checked, { shouldValidate: true, shouldDirty: true });
|
||||||
|
// Sticky-only announcements vanish forever once dismissed (seen=true drops
|
||||||
|
// them from the sticky query, show_in_notifications=false hides them from
|
||||||
|
// the list too) — force the list entry so it stays reachable afterward.
|
||||||
|
if (checked) setValue("show_in_notifications", true, { shouldValidate: true, shouldDirty: true });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
||||||
Show in Sticky Announcements
|
Show in Sticky Announcements
|
||||||
@@ -249,13 +355,75 @@ 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) => setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
|
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="show_in_notifications" className="cursor-pointer">
|
<Label htmlFor="show_in_notifications" className={showInSticky ? "text-muted-foreground" : "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>
|
||||||
|
<Label className="mb-1.5 block">Start date</Label>
|
||||||
|
<DateTimePicker
|
||||||
|
value={startDate || null}
|
||||||
|
onChange={(iso) => setValue("start_date", iso ?? "", { shouldValidate: true, shouldDirty: true })}
|
||||||
|
placeholder="Show immediately"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">End date</Label>
|
||||||
|
<DateTimePicker
|
||||||
|
value={endDate || null}
|
||||||
|
onChange={(iso) => setValue("end_date", iso ?? "", { shouldValidate: true, shouldDirty: true })}
|
||||||
|
placeholder="No end date"
|
||||||
|
/>
|
||||||
|
<FieldError message={errors.end_date?.message} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showInSticky && (
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<Label>Sticky banner color</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{TIER_COLOR_OPTIONS.map((opt) => {
|
||||||
|
const selected = (color || "indigo") === opt.key;
|
||||||
|
const bg = selected ? shadeColor(opt.swatch, -20) : opt.swatch;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.key}
|
||||||
|
type="button"
|
||||||
|
title={opt.label}
|
||||||
|
onClick={() => setValue("color", opt.key, { shouldDirty: true })}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${selected ? "scale-105 shadow-md" : "opacity-80 hover:opacity-100"}`}
|
||||||
|
style={{ backgroundColor: bg, color: getContrastText(bg, opt.key) }}
|
||||||
|
>
|
||||||
|
{selected && <Check className="size-3" />}
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-full flex items-center justify-center gap-3 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" && (
|
||||||
|
<Button type="button" variant="outline" size="sm" className="shrink-0 text-foreground">
|
||||||
|
{watch("link_label") || "Open Link"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{showInSticky && (
|
{showInSticky && (
|
||||||
@@ -280,14 +448,23 @@ export default function AddNotificationBroadcast() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{linkMode === "link" ? (
|
{linkMode === "link" ? (
|
||||||
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Link URL</Label>
|
<Label className="mb-1.5 block">Link URL</Label>
|
||||||
<Input placeholder="https://example.com or /course/123" {...register("link_url")} />
|
<Input placeholder="https://example.com or /course/123" {...register("link_url")} />
|
||||||
<FieldError message={errors.link_url?.message} />
|
<FieldError message={errors.link_url?.message} />
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
Shows an "Open Link" button in the full-content view. Internal paths (starting with /) navigate in-app; anything else opens in a new tab.
|
Internal paths (starting with /) navigate in-app; anything else opens in a new tab.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<p className="text-xs text-muted-foreground">
|
||||||
The full-content view will show just the title and message, with no action button.
|
The full-content view will show just the title and message, with no action button.
|
||||||
@@ -295,15 +472,121 @@ export default function AddNotificationBroadcast() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
{/* ── Step 3: Review ── */}
|
||||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
{currentStep === 3 && (
|
||||||
Cancel
|
<>
|
||||||
|
<SectionCard title="Content" description="Confirm everything looks right before saving the draft.">
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Title</p>
|
||||||
|
<p className="font-medium">{watch("title") || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Message</p>
|
||||||
|
<p className="font-medium whitespace-pre-wrap">{watch("message") || "—"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="Target">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Badge variant="outline">{TARGET_TYPE_MAP[targetType]?.label ?? "—"}</Badge>
|
||||||
|
{needsTarget && (
|
||||||
|
<span className={targetLabel ? "font-medium" : "text-muted-foreground"}>
|
||||||
|
{targetLabel ?? (targetId ? "Selected item not found — reselect it" : "No target selected")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="Display">
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Sticky Announcements</p>
|
||||||
|
<p className="font-medium">{showInSticky ? "Shown" : "Hidden"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Notifications</p>
|
||||||
|
<p className="font-medium">{showInNotifications ? "Shown" : "Hidden"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Start date</p>
|
||||||
|
<p className="font-medium">{startDate ? format(new Date(startDate), "MMM d, yyyy HH:mm") : "Immediately"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">End date</p>
|
||||||
|
<p className="font-medium">{endDate ? format(new Date(endDate), "MMM d, yyyy HH:mm") : "No end date"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showInSticky && (
|
||||||
|
<div
|
||||||
|
className="w-full flex items-center justify-center gap-3 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" && (
|
||||||
|
<Button type="button" variant="outline" size="sm" className="shrink-0 text-foreground">
|
||||||
|
{watch("link_label") || "Open Link"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step navigation ── */}
|
||||||
|
<div className="flex items-center justify-between pt-2 pb-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
{currentStep === 0 ? "Cancel" : "Back"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{currentStep < STEPS.length - 1 ? (
|
||||||
|
<Button type="button" onClick={() => goToStep(currentStep + 1)}>
|
||||||
|
Next
|
||||||
|
<ArrowRight className="h-4 w-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={handleSubmit((values) => saveBroadcast(values, { publish: false }))}
|
||||||
|
>
|
||||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
Save draft
|
Save as draft
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={handleSubmit((values) => saveBroadcast(values, { publish: true }))}
|
||||||
|
>
|
||||||
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
|
Publish now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { ArrowLeft, House } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
|
||||||
import {
|
|
||||||
AdminNotificationTemplateProvider,
|
|
||||||
useAdminNotificationTemplates,
|
|
||||||
} from "@/contexts/AdminNotificationTemplateContext";
|
|
||||||
|
|
||||||
function SectionCard({ title, children }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
|
||||||
{title && <p className="text-sm font-semibold border-b pb-3">{title}</p>}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldError({ message }) {
|
|
||||||
if (!message) return null;
|
|
||||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddNotificationTemplateInner() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { loading, createTemplate } = useAdminNotificationTemplates();
|
|
||||||
|
|
||||||
const [label, setLabel] = useState("");
|
|
||||||
const [title, setTitle] = useState("");
|
|
||||||
const [message, setMessage] = useState("");
|
|
||||||
const [errors, setErrors] = useState({});
|
|
||||||
|
|
||||||
const validate = () => {
|
|
||||||
const e = {};
|
|
||||||
if (!label.trim()) e.label = "Label is required.";
|
|
||||||
if (!title.trim()) e.title = "Title is required.";
|
|
||||||
if (!message.trim()) e.message = "Message is required.";
|
|
||||||
setErrors(e);
|
|
||||||
return !Object.keys(e).length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
if (!validate()) return;
|
|
||||||
|
|
||||||
const result = await createTemplate({
|
|
||||||
label: label.trim(),
|
|
||||||
title: title.trim(),
|
|
||||||
message: message.trim(),
|
|
||||||
});
|
|
||||||
if (result) navigate("/admin/announcement-templates");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="bg-muted/60 min-h-full">
|
|
||||||
<PageMeta title="Add Announcement Template - STARR" />
|
|
||||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
|
||||||
<div className="w-full max-w-2xl mx-auto">
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
|
||||||
<AppBreadcrumb items={[
|
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
|
||||||
{ label: "Announcements", to: "/admin/announcements" },
|
|
||||||
{ label: "Templates", to: "/admin/announcement-templates" },
|
|
||||||
{ label: "Add" },
|
|
||||||
]} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h1 className="text-xl font-semibold">Add Announcement Template</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Save a reusable title/message preset to load into a new announcement later.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-5">
|
|
||||||
|
|
||||||
<SectionCard title="Template Details">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
|
|
||||||
<Input id="label" value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Scheduled Maintenance" />
|
|
||||||
<FieldError message={errors.label} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
|
||||||
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Scheduled maintenance tonight" />
|
|
||||||
<FieldError message={errors.title} />
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<SectionCard title="Message">
|
|
||||||
<p className="text-xs text-muted-foreground -mt-1">
|
|
||||||
This is copied straight into the announcement — no placeholders here, this text goes out as-is.
|
|
||||||
</p>
|
|
||||||
<Textarea
|
|
||||||
id="message"
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
rows={6}
|
|
||||||
placeholder="Full announcement text"
|
|
||||||
/>
|
|
||||||
<FieldError message={errors.message} />
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
|
|
||||||
<Button type="button" onClick={handleCreate} disabled={loading}>
|
|
||||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
|
||||||
Create Template
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AddNotificationTemplate() {
|
|
||||||
return (
|
|
||||||
<AdminNotificationTemplateProvider>
|
|
||||||
<AddNotificationTemplateInner />
|
|
||||||
</AdminNotificationTemplateProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,9 +5,9 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||||||
import { useForm } from "react-hook-form";
|
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 { House } from "lucide-react";
|
import { format } from "date-fns";
|
||||||
|
import { House, Check, ArrowLeft, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
import api from "@/utils/api.util";
|
|
||||||
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";
|
||||||
@@ -18,10 +18,13 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { DateTimePicker } from "@/components/ui/date-time-picker";
|
||||||
|
|
||||||
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
|
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
|
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
|
||||||
|
|
||||||
// ─── Schema ─────────────────────────────────────────────────────────────────
|
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -34,6 +37,10 @@ const schema = z.object({
|
|||||||
show_in_notifications: z.boolean().optional(),
|
show_in_notifications: z.boolean().optional(),
|
||||||
link_mode: z.enum(["info", "link"]).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(),
|
||||||
|
start_date: z.string().optional(),
|
||||||
|
end_date: z.string().optional(),
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
|
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
@@ -58,7 +65,24 @@ const schema = z.object({
|
|||||||
path: ["link_url"],
|
path: ["link_url"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.start_date && data.end_date && new Date(data.start_date) > new Date(data.end_date)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "End date must be after the start date.",
|
||||||
|
path: ["end_date"],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Steps config ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ label: "Content", description: "Title & message" },
|
||||||
|
{ label: "Target", description: "Who receives it" },
|
||||||
|
{ label: "Display", description: "Where it shows & schedule" },
|
||||||
|
{ label: "Review", description: "Confirm & save" },
|
||||||
|
];
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -81,20 +105,75 @@ function SectionCard({ title, description, children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StepIndicator({ steps, current, onStepClick }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start w-full mb-8">
|
||||||
|
{steps.flatMap((step, i) => {
|
||||||
|
const items = [
|
||||||
|
<button
|
||||||
|
key={`step-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStepClick(i)}
|
||||||
|
className="flex flex-col items-center gap-1.5 shrink-0 group"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"w-8 h-8 rounded-full border-2 flex items-center justify-center text-sm font-semibold transition-all group-hover:opacity-80",
|
||||||
|
i < current
|
||||||
|
? "bg-primary border-primary text-primary-foreground"
|
||||||
|
: i === current
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-border text-muted-foreground",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{i < current ? <Check className="h-4 w-4" /> : i + 1}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={[
|
||||||
|
"text-[11px] font-medium text-center leading-tight whitespace-nowrap",
|
||||||
|
i === current ? "text-foreground" : "text-muted-foreground",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</p>
|
||||||
|
</button>,
|
||||||
|
];
|
||||||
|
if (i < steps.length - 1) {
|
||||||
|
items.push(
|
||||||
|
<div
|
||||||
|
key={`line-${i}`}
|
||||||
|
className={[
|
||||||
|
"flex-1 h-px mt-4 mx-2 shrink",
|
||||||
|
i < current ? "bg-primary" : "bg-border",
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Page ───────────────────────────────────────────────────────────────────
|
// ─── Page ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function EditNotificationBroadcast() {
|
export default function EditNotificationBroadcast() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { broadcastId } = useParams();
|
const { broadcastId } = useParams();
|
||||||
const { fetchBroadcast, updateBroadcast, loading } = useNotificationBroadcasts();
|
const { fetchBroadcast, updateBroadcast, sendBroadcast, loading } = useNotificationBroadcasts();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [targetLabel, setTargetLabel] = useState(null);
|
||||||
|
const [broadcastStatus, setBroadcastStatus] = useState("draft");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
|
trigger,
|
||||||
formState: { errors, isDirty },
|
formState: { errors, isDirty },
|
||||||
} = useForm({
|
} = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -107,31 +186,24 @@ export default function EditNotificationBroadcast() {
|
|||||||
show_in_notifications: true,
|
show_in_notifications: true,
|
||||||
link_mode: "info",
|
link_mode: "info",
|
||||||
link_url: "",
|
link_url: "",
|
||||||
|
link_label: "",
|
||||||
|
color: "indigo",
|
||||||
|
start_date: "",
|
||||||
|
end_date: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
||||||
|
|
||||||
const [templates, setTemplates] = useState([]);
|
|
||||||
useEffect(() => {
|
|
||||||
api.get("/admin/announcement-templates")
|
|
||||||
.then(({ data }) => setTemplates((data.data ?? []).filter((t) => !t.is_system)))
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const applyTemplate = (id) => {
|
|
||||||
const tpl = templates.find((t) => String(t.notification_template_id) === id);
|
|
||||||
if (!tpl) return;
|
|
||||||
setValue("title", tpl.title ?? "", { shouldValidate: true, shouldDirty: true });
|
|
||||||
setValue("message", tpl.message ?? "", { shouldValidate: true, shouldDirty: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const targetType = watch("target_type");
|
const targetType = watch("target_type");
|
||||||
const targetId = watch("target_id");
|
const targetId = watch("target_id");
|
||||||
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 linkMode = watch("link_mode");
|
||||||
|
const color = watch("color");
|
||||||
|
const startDate = watch("start_date");
|
||||||
|
const endDate = watch("end_date");
|
||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
@@ -146,21 +218,48 @@ 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: b.show_in_sticky ?? false,
|
show_in_sticky: stickyOn,
|
||||||
show_in_notifications: b.show_in_notifications ?? true,
|
show_in_notifications: stickyOn ? true : (b.show_in_notifications ?? true),
|
||||||
link_mode: b.link_url ? "link" : "info",
|
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",
|
||||||
|
start_date: b.start_date ?? "",
|
||||||
|
end_date: b.end_date ?? "",
|
||||||
});
|
});
|
||||||
|
setBroadcastStatus(b.status ?? "draft");
|
||||||
|
setCurrentStep(0);
|
||||||
})();
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [broadcastId]);
|
}, [broadcastId]);
|
||||||
|
|
||||||
const onSubmit = async (values) => {
|
// Guards the step indicator: jumping ahead must not bypass required
|
||||||
|
// fields from earlier steps.
|
||||||
|
const goToStep = async (target) => {
|
||||||
|
if (target > 0) {
|
||||||
|
const valid = await trigger(["title", "message"]);
|
||||||
|
if (!valid) { setCurrentStep(0); return; }
|
||||||
|
}
|
||||||
|
if (target > 1) {
|
||||||
|
const valid = await trigger(["target_type", "target_id"]);
|
||||||
|
if (!valid) { setCurrentStep(1); return; }
|
||||||
|
}
|
||||||
|
if (target > 2) {
|
||||||
|
const valid = await trigger(["show_in_sticky", "show_in_notifications", "link_url", "end_date"]);
|
||||||
|
if (!valid) { setCurrentStep(2); return; }
|
||||||
|
}
|
||||||
|
setCurrentStep(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveBroadcast = async (values, { publish = false } = {}) => {
|
||||||
const { link_mode, ...rest } = values;
|
const { link_mode, ...rest } = values;
|
||||||
const payload = {
|
const payload = {
|
||||||
...rest,
|
...rest,
|
||||||
@@ -168,11 +267,23 @@ export default function EditNotificationBroadcast() {
|
|||||||
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") ? values.link_url.trim() : null,
|
link_url: (values.show_in_sticky && link_mode === "link") ? 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",
|
||||||
|
start_date: values.start_date || null,
|
||||||
|
end_date: values.end_date || null,
|
||||||
updatedBy: user?.user_id ?? null,
|
updatedBy: user?.user_id ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await updateBroadcast(broadcastId, payload);
|
const res = await updateBroadcast(broadcastId, payload);
|
||||||
if (res) { bypassOnce(); navigate("/admin/announcements"); }
|
if (!res) return;
|
||||||
|
|
||||||
|
if (publish) {
|
||||||
|
const sent = await sendBroadcast(broadcastId);
|
||||||
|
if (!sent) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bypassOnce();
|
||||||
|
navigate("/admin/announcements");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -184,27 +295,19 @@ export default function EditNotificationBroadcast() {
|
|||||||
|
|
||||||
<div className="w-full max-w-2xl pb-10">
|
<div className="w-full max-w-2xl pb-10">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">Edit announcement</h1>
|
<h1 className="text-2xl font-semibold tracking-tight mb-1">Edit announcement</h1>
|
||||||
<p className="text-sm text-muted-foreground mb-6">Only draft announcements can be edited.</p>
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
{broadcastStatus === "sent"
|
||||||
|
? "This announcement has already been sent — changes apply immediately to anyone currently seeing it."
|
||||||
|
: "It's saved as a draft until you send it."}
|
||||||
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
<StepIndicator steps={STEPS} current={currentStep} onStepClick={goToStep} />
|
||||||
|
|
||||||
|
<form onSubmit={(e) => e.preventDefault()} className="space-y-5">
|
||||||
|
|
||||||
|
{/* ── Step 0: Content ── */}
|
||||||
|
{currentStep === 0 && (
|
||||||
<SectionCard title="Content" description="What admins and/or users will see.">
|
<SectionCard title="Content" description="What admins and/or users will see.">
|
||||||
{templates.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<Label className="mb-1.5 block">Load from template</Label>
|
|
||||||
<Select onValueChange={applyTemplate}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Optional — start from a saved preset" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{templates.map((t) => (
|
|
||||||
<SelectItem key={t.notification_template_id} value={String(t.notification_template_id)}>{t.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">Fills in the title and message below — you can still edit them after.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Title</Label>
|
<Label className="mb-1.5 block">Title</Label>
|
||||||
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
|
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
|
||||||
@@ -216,7 +319,10 @@ export default function EditNotificationBroadcast() {
|
|||||||
<FieldError message={errors.message?.message} />
|
<FieldError message={errors.message?.message} />
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 1: Target ── */}
|
||||||
|
{currentStep === 1 && (
|
||||||
<SectionCard title="Target" description="Who receives this announcement when it's sent.">
|
<SectionCard title="Target" description="Who receives this announcement when it's sent.">
|
||||||
<div>
|
<div>
|
||||||
<Select
|
<Select
|
||||||
@@ -224,6 +330,7 @@ export default function EditNotificationBroadcast() {
|
|||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
|
setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
|
||||||
setValue("target_id", null, { shouldDirty: true });
|
setValue("target_id", null, { shouldDirty: true });
|
||||||
|
setTargetLabel(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -249,19 +356,31 @@ export default function EditNotificationBroadcast() {
|
|||||||
targetType={targetType}
|
targetType={targetType}
|
||||||
value={targetId}
|
value={targetId}
|
||||||
onChange={(id) => setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
|
onChange={(id) => setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
|
||||||
|
onLabelResolved={setTargetLabel}
|
||||||
/>
|
/>
|
||||||
<FieldError message={errors.target_id?.message} />
|
<FieldError message={errors.target_id?.message} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 2: Display ── */}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<>
|
||||||
<SectionCard title="Display" description="Where clients/admins can see this announcement.">
|
<SectionCard title="Display" description="Where clients/admins can see this announcement.">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="show_in_sticky"
|
id="show_in_sticky"
|
||||||
checked={showInSticky === true}
|
checked={showInSticky === true}
|
||||||
onCheckedChange={(v) => setValue("show_in_sticky", v === true, { shouldValidate: true, shouldDirty: true })}
|
onCheckedChange={(v) => {
|
||||||
|
const checked = v === true;
|
||||||
|
setValue("show_in_sticky", checked, { shouldValidate: true, shouldDirty: true });
|
||||||
|
// Sticky-only announcements vanish forever once dismissed (seen=true drops
|
||||||
|
// them from the sticky query, show_in_notifications=false hides them from
|
||||||
|
// the list too) — force the list entry so it stays reachable afterward.
|
||||||
|
if (checked) setValue("show_in_notifications", true, { shouldValidate: true, shouldDirty: true });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
||||||
Show in Sticky Announcements
|
Show in Sticky Announcements
|
||||||
@@ -272,13 +391,75 @@ 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) => setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
|
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="show_in_notifications" className="cursor-pointer">
|
<Label htmlFor="show_in_notifications" className={showInSticky ? "text-muted-foreground" : "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>
|
||||||
|
<Label className="mb-1.5 block">Start date</Label>
|
||||||
|
<DateTimePicker
|
||||||
|
value={startDate || null}
|
||||||
|
onChange={(iso) => setValue("start_date", iso ?? "", { shouldValidate: true, shouldDirty: true })}
|
||||||
|
placeholder="Show immediately"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">End date</Label>
|
||||||
|
<DateTimePicker
|
||||||
|
value={endDate || null}
|
||||||
|
onChange={(iso) => setValue("end_date", iso ?? "", { shouldValidate: true, shouldDirty: true })}
|
||||||
|
placeholder="No end date"
|
||||||
|
/>
|
||||||
|
<FieldError message={errors.end_date?.message} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showInSticky && (
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<Label>Sticky banner color</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{TIER_COLOR_OPTIONS.map((opt) => {
|
||||||
|
const selected = (color || "indigo") === opt.key;
|
||||||
|
const bg = selected ? shadeColor(opt.swatch, -20) : opt.swatch;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.key}
|
||||||
|
type="button"
|
||||||
|
title={opt.label}
|
||||||
|
onClick={() => setValue("color", opt.key, { shouldDirty: true })}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${selected ? "scale-105 shadow-md" : "opacity-80 hover:opacity-100"}`}
|
||||||
|
style={{ backgroundColor: bg, color: getContrastText(bg, opt.key) }}
|
||||||
|
>
|
||||||
|
{selected && <Check className="size-3" />}
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-full flex items-center justify-center gap-3 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" && (
|
||||||
|
<Button type="button" variant="outline" size="sm" className="shrink-0 text-foreground">
|
||||||
|
{watch("link_label") || "Open Link"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{showInSticky && (
|
{showInSticky && (
|
||||||
@@ -303,14 +484,23 @@ export default function EditNotificationBroadcast() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{linkMode === "link" ? (
|
{linkMode === "link" ? (
|
||||||
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Link URL</Label>
|
<Label className="mb-1.5 block">Link URL</Label>
|
||||||
<Input placeholder="https://example.com or /course/123" {...register("link_url")} />
|
<Input placeholder="https://example.com or /course/123" {...register("link_url")} />
|
||||||
<FieldError message={errors.link_url?.message} />
|
<FieldError message={errors.link_url?.message} />
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
Shows an "Open Link" button in the full-content view. Internal paths (starting with /) navigate in-app; anything else opens in a new tab.
|
Internal paths (starting with /) navigate in-app; anything else opens in a new tab.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<p className="text-xs text-muted-foreground">
|
||||||
The full-content view will show just the title and message, with no action button.
|
The full-content view will show just the title and message, with no action button.
|
||||||
@@ -318,15 +508,130 @@ export default function EditNotificationBroadcast() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
{/* ── Step 3: Review ── */}
|
||||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
{currentStep === 3 && (
|
||||||
Cancel
|
<>
|
||||||
|
<SectionCard title="Content" description="Confirm everything looks right before saving.">
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Title</p>
|
||||||
|
<p className="font-medium">{watch("title") || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Message</p>
|
||||||
|
<p className="font-medium whitespace-pre-wrap">{watch("message") || "—"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="Target">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Badge variant="outline">{TARGET_TYPE_MAP[targetType]?.label ?? "—"}</Badge>
|
||||||
|
{needsTarget && (
|
||||||
|
<span className={targetLabel ? "font-medium" : "text-muted-foreground"}>
|
||||||
|
{targetLabel ?? (targetId ? "Selected item not found — reselect it" : "No target selected")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard title="Display">
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Sticky Announcements</p>
|
||||||
|
<p className="font-medium">{showInSticky ? "Shown" : "Hidden"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Notifications</p>
|
||||||
|
<p className="font-medium">{showInNotifications ? "Shown" : "Hidden"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Start date</p>
|
||||||
|
<p className="font-medium">{startDate ? format(new Date(startDate), "MMM d, yyyy HH:mm") : "Immediately"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">End date</p>
|
||||||
|
<p className="font-medium">{endDate ? format(new Date(endDate), "MMM d, yyyy HH:mm") : "No end date"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showInSticky && (
|
||||||
|
<div
|
||||||
|
className="w-full flex items-center justify-center gap-3 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" && (
|
||||||
|
<Button type="button" variant="outline" size="sm" className="shrink-0 text-foreground">
|
||||||
|
{watch("link_label") || "Open Link"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step navigation ── */}
|
||||||
|
<div className="flex items-center justify-between pt-2 pb-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
{currentStep === 0 ? "Cancel" : "Back"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{currentStep < STEPS.length - 1 ? (
|
||||||
|
<Button type="button" onClick={() => goToStep(currentStep + 1)}>
|
||||||
|
Next
|
||||||
|
<ArrowRight className="h-4 w-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
) : broadcastStatus === "sent" ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={handleSubmit((values) => saveBroadcast(values, { publish: false }))}
|
||||||
|
>
|
||||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
Save changes
|
Save changes
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={handleSubmit((values) => saveBroadcast(values, { publish: false }))}
|
||||||
|
>
|
||||||
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
|
Save as draft
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={handleSubmit((values) => saveBroadcast(values, { publish: true }))}
|
||||||
|
>
|
||||||
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
|
Publish now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { ArrowLeft, House, Lock, Send, Clock3, Trash2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
|
||||||
AlertDialog, AlertDialogAction, AlertDialogCancel,
|
|
||||||
AlertDialogContent, AlertDialogDescription,
|
|
||||||
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
|
||||||
import {
|
|
||||||
AdminNotificationTemplateProvider,
|
|
||||||
useAdminNotificationTemplates,
|
|
||||||
} from "@/contexts/AdminNotificationTemplateContext";
|
|
||||||
import { NOTIFICATION_TEMPLATE_PLACEHOLDERS } from "@/data/notificationTemplatePlaceholders.data";
|
|
||||||
import { STATUS_META, hasPendingChanges } from "@/data/notificationTemplateStatus.data";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
function SectionCard({ title, children }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
|
||||||
{title && <p className="text-sm font-semibold border-b pb-3">{title}</p>}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldError({ message }) {
|
|
||||||
if (!message) return null;
|
|
||||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditNotificationTemplateInner() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { id } = useParams();
|
|
||||||
const { template, loading, fetchTemplate, updateTemplate, deleteTemplate } = useAdminNotificationTemplates();
|
|
||||||
|
|
||||||
const [label, setLabel] = useState("");
|
|
||||||
const [title, setTitle] = useState("");
|
|
||||||
const [message, setMessage] = useState("");
|
|
||||||
const [errors, setErrors] = useState({});
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
||||||
|
|
||||||
const isCustom = template && !template.is_system;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (id) fetchTemplate(id);
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (template) {
|
|
||||||
setLabel(template.label ?? "");
|
|
||||||
// Prefer whatever's pending (unpublished) over the live version, so
|
|
||||||
// reopening a template with pending changes resumes editing them.
|
|
||||||
setTitle(template.draft_title ?? template.title ?? "");
|
|
||||||
setMessage(template.draft_message ?? template.message ?? "");
|
|
||||||
}
|
|
||||||
}, [template]);
|
|
||||||
|
|
||||||
const status = STATUS_META[template?.status] ?? STATUS_META.draft;
|
|
||||||
const pending = hasPendingChanges(template);
|
|
||||||
const knownPlaceholders = NOTIFICATION_TEMPLATE_PLACEHOLDERS[template?.type] ?? null;
|
|
||||||
|
|
||||||
const validate = () => {
|
|
||||||
const e = {};
|
|
||||||
if (!label.trim()) e.label = "Label is required.";
|
|
||||||
if (!title.trim()) e.title = "Title is required.";
|
|
||||||
if (!message.trim()) e.message = "Message is required.";
|
|
||||||
setErrors(e);
|
|
||||||
return !Object.keys(e).length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async (publish) => {
|
|
||||||
if (!validate()) return;
|
|
||||||
|
|
||||||
const result = await updateTemplate(id, {
|
|
||||||
label: label.trim(),
|
|
||||||
title: title.trim(),
|
|
||||||
message: message.trim(),
|
|
||||||
publish,
|
|
||||||
});
|
|
||||||
if (result) navigate("/admin/announcement-templates");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
const result = await deleteTemplate(id);
|
|
||||||
setConfirmDelete(false);
|
|
||||||
if (result) navigate("/admin/announcement-templates");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="bg-muted/60 min-h-full">
|
|
||||||
<PageMeta title="Edit Announcement Template - STARR" />
|
|
||||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
|
||||||
<div className="w-full max-w-2xl mx-auto">
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
|
||||||
<AppBreadcrumb items={[
|
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
|
||||||
{ label: "Announcements", to: "/admin/announcements" },
|
|
||||||
{ label: "Templates", to: "/admin/announcement-templates" },
|
|
||||||
{ label: template?.label ?? "Edit" },
|
|
||||||
]} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<h1 className="text-xl font-semibold">Edit Announcement Template</h1>
|
|
||||||
{template && !isCustom && (
|
|
||||||
<Badge variant="outline" className={cn("gap-1", status.badgeClass)}>
|
|
||||||
<Send className="h-3 w-3" /> {status.label}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">Update this template's title and message.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pending && !isCustom && (
|
|
||||||
<div className="rounded-lg border border-amber-400 bg-amber-50 dark:bg-amber-950/40 dark:border-amber-700 p-4 flex items-start gap-3 mb-5">
|
|
||||||
<Clock3 className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
|
|
||||||
<p className="text-xs text-amber-800 dark:text-amber-300">
|
|
||||||
This template has <strong>pending changes</strong> that haven't gone out yet — the version
|
|
||||||
currently used is the last one you published. Press <strong>Publish</strong> below to
|
|
||||||
apply these edits, or <strong>Save as Draft</strong> to keep working without publishing.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isCustom && (
|
|
||||||
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
|
|
||||||
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
This is a <strong>system</strong> notification — code fires it by referencing this exact type,
|
|
||||||
so the type is locked. Label, title and message are still fully editable.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-5">
|
|
||||||
|
|
||||||
<SectionCard title="Template Details">
|
|
||||||
{!isCustom && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Type</Label>
|
|
||||||
<Input value={template?.type ?? ""} disabled />
|
|
||||||
<p className="text-xs text-muted-foreground">Cannot be changed — this is what code looks up.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
|
|
||||||
<Input id="label" value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Tasks Overdue (Admin)" />
|
|
||||||
<FieldError message={errors.label} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
|
||||||
<Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Tasks Overdue" />
|
|
||||||
<FieldError message={errors.title} />
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<SectionCard>
|
|
||||||
<div className="flex items-center justify-between border-b pb-3">
|
|
||||||
<p className="text-sm font-semibold">Message</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground -mt-1">
|
|
||||||
{isCustom
|
|
||||||
? "Plain text only — this is copied straight into the announcement as-is."
|
|
||||||
: (<>Plain text only — no HTML, no conditional logic, just straight{" "}
|
|
||||||
<code className="bg-muted px-1 rounded">{"{{placeholder}}"}</code> tokens.</>)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{!isCustom && (knownPlaceholders !== null) && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">Available placeholders</p>
|
|
||||||
{knownPlaceholders.length ? (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{knownPlaceholders.map((ph) => (
|
|
||||||
<Badge key={ph} variant="outline" className="font-mono text-[10px]">
|
|
||||||
{`{{${ph}}}`}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-muted-foreground">This template has no dynamic placeholders.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Textarea
|
|
||||||
id="message"
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
rows={6}
|
|
||||||
className="font-mono text-xs"
|
|
||||||
placeholder="{{count}} {{task_word}} automatically marked as overdue."
|
|
||||||
/>
|
|
||||||
<FieldError message={errors.message} />
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
{isCustom ? (
|
|
||||||
<Button type="button" variant="ghost" className="text-destructive hover:text-destructive" onClick={() => setConfirmDelete(true)} disabled={loading}>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" /> Delete
|
|
||||||
</Button>
|
|
||||||
) : <span />}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
|
|
||||||
{isCustom ? (
|
|
||||||
<Button type="button" onClick={() => handleSave(false)} disabled={loading}>
|
|
||||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button type="button" variant="outline" onClick={() => handleSave(false)} disabled={loading}>
|
|
||||||
Save as Draft
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={() => handleSave(true)} disabled={loading}>
|
|
||||||
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Send className="h-4 w-4 mr-2" />}
|
|
||||||
Publish
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete this template?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
"{template?.label}" will be permanently removed. It won't affect any announcements already sent.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={handleDelete} disabled={loading} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
||||||
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Trash2 className="h-4 w-4 mr-2" />}
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EditNotificationTemplate() {
|
|
||||||
return (
|
|
||||||
<AdminNotificationTemplateProvider>
|
|
||||||
<EditNotificationTemplateInner />
|
|
||||||
</AdminNotificationTemplateProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { House, Plus, Search, Megaphone, Send, Edit, Trash2, Users, FileText, Archive } from "lucide-react";
|
import { House, Plus, Search, Megaphone, Send, Edit, Trash2, Users, Archive, ImagePlus, X } from "lucide-react";
|
||||||
|
|
||||||
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
|
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { TablePagination } from "@/components/generic/Table/TablePagination";
|
import { TablePagination } from "@/components/generic/Table/TablePagination";
|
||||||
|
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -18,15 +20,29 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
import { TARGET_TYPE_MAP, BROADCAST_STATUSES, BROADCAST_STATUS_MAP } from "@/data/notificationBroadcast.data";
|
import { TARGET_TYPE_MAP, BROADCAST_STATUSES, BROADCAST_STATUS_MAP } from "@/data/notificationBroadcast.data";
|
||||||
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
|
|
||||||
export default function NotificationBroadcastList() {
|
export default function NotificationBroadcastList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { broadcasts, pagination, loading, fetchBroadcasts, sendBroadcast, archiveBroadcast } = useNotificationBroadcasts();
|
const { user } = useAuth();
|
||||||
|
const {
|
||||||
|
broadcasts, pagination, loading, fetchBroadcasts, sendBroadcast, archiveBroadcast,
|
||||||
|
stickyBannerSetting, fetchStickyBannerSetting, updateStickyBannerSetting,
|
||||||
|
} = useNotificationBroadcasts();
|
||||||
|
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
const [searchInput, setSearchInput] = useState("");
|
const [searchInput, setSearchInput] = useState("");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [limit, setLimit] = useState(12);
|
const [limit, setLimit] = useState(12);
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
const bannerAsset = stickyBannerSetting?.image ?? null;
|
||||||
|
const bannerImageUrl = bannerAsset ? resolveAssetSrc(bannerAsset) : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStickyBannerSetting();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
function buildFilters() {
|
function buildFilters() {
|
||||||
const filters = [];
|
const filters = [];
|
||||||
@@ -73,10 +89,6 @@ export default function NotificationBroadcastList() {
|
|||||||
<p className="text-sm text-muted-foreground">Compose and send announcements to admins and users</p>
|
<p className="text-sm text-muted-foreground">Compose and send announcements to admins and users</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" onClick={() => navigate("/admin/announcement-templates")}>
|
|
||||||
<FileText className="size-4" />
|
|
||||||
Templates
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={() => navigate("/admin/announcements/archived")}>
|
<Button variant="outline" onClick={() => navigate("/admin/announcements/archived")}>
|
||||||
<Archive className="size-4" />
|
<Archive className="size-4" />
|
||||||
Archived
|
Archived
|
||||||
@@ -95,6 +107,24 @@ export default function NotificationBroadcastList() {
|
|||||||
<StatCard label="Sent" value={sentCount} tone="success" />
|
<StatCard label="Sent" value={sentCount} tone="success" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Sticky banner image (shared across all active announcements) ── */}
|
||||||
|
<div className="bg-background rounded-lg border p-4 flex items-center gap-4">
|
||||||
|
<div className="w-40 shrink-0">
|
||||||
|
<StickyBannerPicker
|
||||||
|
selectedAsset={bannerAsset}
|
||||||
|
imageUrl={bannerImageUrl}
|
||||||
|
onPick={() => setPickerOpen(true)}
|
||||||
|
onRemove={() => updateStickyBannerSetting({ image_asset_id: null, updatedBy: user?.user_id ?? null })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Sticky banner image</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Shown in the details dialog for every currently-active sticky announcement (up to 3 share this one image).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Filters ────────────────────────────────────────────────── */}
|
{/* ── Filters ────────────────────────────────────────────────── */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
@@ -110,11 +140,11 @@ export default function NotificationBroadcastList() {
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-[160px]">
|
<div className="flex items-center gap-2 flex-1 min-w-[160px]">
|
||||||
<div className="relative flex-1">
|
<div className="relative w-64">
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search announcements..."
|
placeholder="Search announcements..."
|
||||||
className="pl-8 bg-background"
|
className="pl-8 bg-background text-sm"
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") setSearch(searchInput); }}
|
onKeyDown={(e) => { if (e.key === "Enter") setSearch(searchInput); }}
|
||||||
@@ -163,12 +193,65 @@ export default function NotificationBroadcastList() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AssetPickerSheet
|
||||||
|
open={pickerOpen}
|
||||||
|
onOpenChange={setPickerOpen}
|
||||||
|
fileType="image"
|
||||||
|
onSelect={(asset) => {
|
||||||
|
updateStickyBannerSetting({ image_asset_id: asset.asset_id, updatedBy: user?.user_id ?? null });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Stat card ──────────────────────────────────────────────────────────────
|
// ─── Stat card ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ─── Sticky banner image picker ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StickyBannerPicker({ selectedAsset, imageUrl, onPick, onRemove }) {
|
||||||
|
if (!selectedAsset) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPick}
|
||||||
|
className="w-full 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative rounded-lg overflow-hidden border aspect-video group">
|
||||||
|
<img
|
||||||
|
src={imageUrl || resolveAssetSrc(selectedAsset)}
|
||||||
|
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 StatCard({ label, value, tone = "default" }) {
|
function StatCard({ label, value, tone = "default" }) {
|
||||||
const toneClass = {
|
const toneClass = {
|
||||||
default: "text-foreground",
|
default: "text-foreground",
|
||||||
@@ -238,11 +321,9 @@ function BroadcastCard({ broadcast, onView, onEdit, onSend, onArchive }) {
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)}
|
)}
|
||||||
{isDraft && (
|
|
||||||
<Button variant="ghost" size="icon" className="size-7" onClick={onEdit} aria-label="Edit">
|
<Button variant="ghost" size="icon" className="size-7" onClick={onEdit} aria-label="Edit">
|
||||||
<Edit className="size-3.5" />
|
<Edit className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="size-7" aria-label="Delete">
|
<Button variant="ghost" size="icon" className="size-7" aria-label="Delete">
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { House, Pencil, Bell, Lock, Send, Clock3, Plus, Trash2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import {
|
|
||||||
AlertDialog, AlertDialogAction, AlertDialogCancel,
|
|
||||||
AlertDialogContent, AlertDialogDescription,
|
|
||||||
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
|
||||||
import {
|
|
||||||
AdminNotificationTemplateProvider,
|
|
||||||
useAdminNotificationTemplates,
|
|
||||||
} from "@/contexts/AdminNotificationTemplateContext";
|
|
||||||
import { NOTIFICATION_TEMPLATE_TYPES, getNotificationTemplateType } from "@/data/notificationTemplateTypes.data";
|
|
||||||
import { STATUS_META, hasPendingChanges } from "@/data/notificationTemplateStatus.data";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
function TemplateCard({ item, onEdit, onDelete }) {
|
|
||||||
const typeMeta = getNotificationTemplateType(item.notify_type);
|
|
||||||
const TypeIcon = typeMeta?.icon ?? Bell;
|
|
||||||
const status = STATUS_META[item.status] ?? STATUS_META.draft;
|
|
||||||
const pending = hasPendingChanges(item);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border bg-card p-5 flex flex-col gap-4 h-full">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center shrink-0">
|
|
||||||
<TypeIcon className="h-4.5 w-4.5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
{!item.is_system && (
|
|
||||||
<Button type="button" variant="ghost" size="icon" className="text-destructive hover:text-destructive" onClick={() => onDelete(item)}>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(item)}>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
|
||||||
<p className="text-sm font-semibold truncate">{item.label}</p>
|
|
||||||
{item.is_system && (
|
|
||||||
<Badge variant="secondary" className="gap-1 shrink-0">
|
|
||||||
<Lock className="h-2.5 w-2.5" /> System
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<code className="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{item.type}</code>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">
|
|
||||||
<span className="text-foreground">{item.title || item.draft_title || "No title yet"}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
|
||||||
{typeMeta && (
|
|
||||||
<Badge variant="outline" className="gap-1 text-[11px]">
|
|
||||||
<typeMeta.icon className="h-3 w-3" /> {typeMeta.label}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<Badge variant="outline" className={cn("gap-1 text-[11px]", status.badgeClass)}>
|
|
||||||
<Send className="h-3 w-3" /> {status.label}
|
|
||||||
</Badge>
|
|
||||||
{pending && (
|
|
||||||
<Badge variant="outline" className="gap-1 text-[11px] bg-amber-100 text-amber-700 border-amber-400 dark:bg-amber-900/40 dark:text-amber-400 dark:border-amber-700">
|
|
||||||
<Clock3 className="h-3 w-3" /> Pending changes
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotificationTemplatesInner() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { templates, loading, fetchTemplates, deleteTemplate } = useAdminNotificationTemplates();
|
|
||||||
const [activeType, setActiveType] = useState("all");
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => { fetchTemplates(); }, []);
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
await deleteTemplate(deleteTarget.notification_template_id);
|
|
||||||
setDeleteTarget(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filtered = useMemo(
|
|
||||||
() => activeType === "all" ? templates : templates.filter((t) => t.notify_type === activeType),
|
|
||||||
[templates, activeType]
|
|
||||||
);
|
|
||||||
|
|
||||||
const typesInUse = useMemo(
|
|
||||||
() => NOTIFICATION_TEMPLATE_TYPES.filter((t) => templates.some((tpl) => tpl.notify_type === t.value)),
|
|
||||||
[templates]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="bg-muted/60 min-h-full">
|
|
||||||
<PageMeta title="Announcement Templates - STARR" />
|
|
||||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
|
||||||
<div className="w-full max-w-6xl mx-auto">
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
|
||||||
<AppBreadcrumb items={[
|
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
|
||||||
{ label: "Announcements", to: "/admin/announcements" },
|
|
||||||
{ label: "Templates" },
|
|
||||||
]} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold">Announcement Templates</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
|
||||||
Title and message wording for every automated notification STARR sends.
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Send className="h-3 w-3 text-emerald-600" />
|
|
||||||
{templates.filter((t) => t.status === "sent").length} sent
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
{templates.filter((t) => t.status === "draft").length} draft
|
|
||||||
</span>
|
|
||||||
{templates.some(hasPendingChanges) && (
|
|
||||||
<span className="flex items-center gap-1 text-amber-600">
|
|
||||||
<Clock3 className="h-3 w-3" />
|
|
||||||
{templates.filter(hasPendingChanges).length} with pending changes
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="button" onClick={() => navigate("/admin/announcement-templates/add")} className="gap-1.5">
|
|
||||||
<Plus className="h-4 w-4" /> Add Template
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
|
|
||||||
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
|
||||||
<div className="text-xs text-muted-foreground space-y-1">
|
|
||||||
<p>
|
|
||||||
<strong>System</strong> templates are locked — code fires them by referencing their exact
|
|
||||||
type, so only the title and message wording is editable, never the type itself, and they
|
|
||||||
can't be deleted.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Custom</strong> templates (no lock icon) are reusable title/message presets you
|
|
||||||
create — pick one from the "Load from template" dropdown when composing a new announcement
|
|
||||||
to skip retyping recurring wording. You can freely create, edit, and delete these.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Draft vs. Sent</strong> (system templates only): a <strong>Sent</strong> template
|
|
||||||
is the version actually used for real notifications right now. Editing a Sent template
|
|
||||||
doesn't change what goes out immediately — it's held as a pending change until you press{" "}
|
|
||||||
<strong>Publish</strong> again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-5">
|
|
||||||
<Button
|
|
||||||
type="button" size="sm" variant={activeType === "all" ? "secondary" : "outline"}
|
|
||||||
onClick={() => setActiveType("all")}
|
|
||||||
>
|
|
||||||
All ({templates.length})
|
|
||||||
</Button>
|
|
||||||
{typesInUse.map((t) => {
|
|
||||||
const Icon = t.icon;
|
|
||||||
const count = templates.filter((tpl) => tpl.notify_type === t.value).length;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={t.value}
|
|
||||||
type="button" size="sm"
|
|
||||||
variant={activeType === t.value ? "secondary" : "outline"}
|
|
||||||
onClick={() => setActiveType(t.value)}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<Icon className="h-3.5 w-3.5" /> {t.label} ({count})
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="mb-5" />
|
|
||||||
|
|
||||||
{loading && !templates.length ? (
|
|
||||||
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
|
|
||||||
) : !filtered.length ? (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-12">No notification templates found.</p>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{filtered.map((item) => (
|
|
||||||
<TemplateCard
|
|
||||||
key={item.notification_template_id}
|
|
||||||
item={item}
|
|
||||||
onEdit={(t) => navigate(`/admin/announcement-templates/${t.notification_template_id}/edit`)}
|
|
||||||
onDelete={setDeleteTarget}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete this template?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
"{deleteTarget?.label}" will be permanently removed. It won't affect any announcements already sent.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={handleDelete} disabled={loading} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
||||||
{loading ? <Spinner className="h-4 w-4 mr-2" /> : <Trash2 className="h-4 w-4 mr-2" />}
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NotificationTemplates() {
|
|
||||||
return (
|
|
||||||
<AdminNotificationTemplateProvider>
|
|
||||||
<NotificationTemplatesInner />
|
|
||||||
</AdminNotificationTemplateProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -169,8 +169,8 @@ export default function ViewNotificationBroadcast() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isDraft && (
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{isDraft && (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
@@ -191,12 +191,12 @@ export default function ViewNotificationBroadcast() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
)}
|
||||||
<Button size="sm" onClick={() => navigate(`/admin/announcements/${broadcastId}/edit`)}>
|
<Button size="sm" onClick={() => navigate(`/admin/announcements/${broadcastId}/edit`)}>
|
||||||
<Edit className="size-4" />
|
<Edit className="size-4" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Underline tabs */}
|
{/* Underline tabs */}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Milestone, Navigation, Sunrise,
|
Milestone, Navigation, Sunrise,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as LucideIcons from "lucide-react";
|
import * as LucideIcons from "lucide-react";
|
||||||
import { TIER_COLOR_OPTIONS, getTierColor } from "@/utils/tierColors";
|
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
export const BADGE_ICON_OPTIONS = [
|
export const BADGE_ICON_OPTIONS = [
|
||||||
@@ -279,14 +279,15 @@ function EditTierCategoryInner({ isAdd }) {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{TIER_COLOR_OPTIONS.map((opt) => {
|
{TIER_COLOR_OPTIONS.map((opt) => {
|
||||||
const selected = color === opt.key;
|
const selected = color === opt.key;
|
||||||
|
const bg = selected ? shadeColor(opt.swatch, -20) : opt.swatch;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={opt.key}
|
key={opt.key}
|
||||||
type="button"
|
type="button"
|
||||||
title={opt.label}
|
title={opt.label}
|
||||||
onClick={() => setColor(opt.key)}
|
onClick={() => setColor(opt.key)}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border-2 transition-all ${selected ? "border-foreground scale-105" : "border-transparent opacity-70 hover:opacity-100"}`}
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${selected ? "scale-105 shadow-md" : "opacity-80 hover:opacity-100"}`}
|
||||||
style={{ backgroundColor: opt.swatch, color: "#fff" }}
|
style={{ backgroundColor: bg, color: getContrastText(bg, opt.key) }}
|
||||||
>
|
>
|
||||||
{selected && <Check className="size-3" />}
|
{selected && <Check className="size-3" />}
|
||||||
{opt.label}
|
{opt.label}
|
||||||
|
|||||||
@@ -115,9 +115,6 @@ import EditAdvertisement from '../pages/advertisements/EditAdvertisement'
|
|||||||
import ViewAdvertisement from '../pages/advertisements/ViewAdvertisement'
|
import ViewAdvertisement from '../pages/advertisements/ViewAdvertisement'
|
||||||
import ArchivedAdvertisementList from '../pages/advertisements/ArchivedAdvertisementList'
|
import ArchivedAdvertisementList from '../pages/advertisements/ArchivedAdvertisementList'
|
||||||
|
|
||||||
// Achievements
|
|
||||||
import Achievements from '../pages/achievements/Achievements'
|
|
||||||
import { AddAchievement, EditAchievement } from '../pages/achievements/EditAchievement'
|
|
||||||
|
|
||||||
// Notification Broadcasts
|
// Notification Broadcasts
|
||||||
import NotificationBroadcastList from '../pages/notifications/NotificationBroadcastList'
|
import NotificationBroadcastList from '../pages/notifications/NotificationBroadcastList'
|
||||||
@@ -125,9 +122,6 @@ import AddNotificationBroadcast from '../pages/notifications/AddNotificationBroa
|
|||||||
import EditNotificationBroadcast from '../pages/notifications/EditNotificationBroadcast'
|
import EditNotificationBroadcast from '../pages/notifications/EditNotificationBroadcast'
|
||||||
import ViewNotificationBroadcast from '../pages/notifications/ViewNotificationBroadcast'
|
import ViewNotificationBroadcast from '../pages/notifications/ViewNotificationBroadcast'
|
||||||
import Jobs from '../pages/jobs/Jobs'
|
import Jobs from '../pages/jobs/Jobs'
|
||||||
import NotificationTemplates from '../pages/notifications/NotificationTemplates'
|
|
||||||
import AddNotificationTemplate from '../pages/notifications/AddNotificationTemplate'
|
|
||||||
import EditNotificationTemplate from '../pages/notifications/EditNotificationTemplate'
|
|
||||||
import ArchivedNotificationBroadcastList from '../pages/notifications/ArchivedNotificationBroadcastList'
|
import ArchivedNotificationBroadcastList from '../pages/notifications/ArchivedNotificationBroadcastList'
|
||||||
|
|
||||||
// Activity
|
// Activity
|
||||||
@@ -358,17 +352,6 @@ export const AdminRoutes = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
// Achievements
|
|
||||||
|
|
||||||
{
|
|
||||||
path: 'achievements',
|
|
||||||
element: <Outlet />,
|
|
||||||
children: [
|
|
||||||
{ index: true, element: <Achievements /> },
|
|
||||||
{ path: 'add', element: <AddAchievement /> },
|
|
||||||
{ path: ':id/edit', element: <EditAchievement /> },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
// Jobs (cron scheduling for announcement/notification jobs)
|
// Jobs (cron scheduling for announcement/notification jobs)
|
||||||
{ path: 'jobs', element: <Jobs /> },
|
{ path: 'jobs', element: <Jobs /> },
|
||||||
@@ -386,16 +369,6 @@ export const AdminRoutes = {
|
|||||||
{ path: ':broadcastId/edit', element: <EditNotificationBroadcast /> },
|
{ path: ':broadcastId/edit', element: <EditNotificationBroadcast /> },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'announcement-templates',
|
|
||||||
element: <Outlet />,
|
|
||||||
children: [
|
|
||||||
{ index: true, element: <NotificationTemplates /> },
|
|
||||||
{ path: 'add', element: <AddNotificationTemplate /> },
|
|
||||||
{ path: ':id/edit', element: <EditNotificationTemplate /> },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
// Backwards-compatible aliases (keep old URLs working)
|
// Backwards-compatible aliases (keep old URLs working)
|
||||||
{
|
{
|
||||||
path: 'notifications',
|
path: 'notifications',
|
||||||
@@ -409,17 +382,6 @@ export const AdminRoutes = {
|
|||||||
{ path: ':broadcastId/edit', element: <EditNotificationBroadcast /> },
|
{ path: ':broadcastId/edit', element: <EditNotificationBroadcast /> },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'notification-templates',
|
|
||||||
element: <Outlet />,
|
|
||||||
children: [
|
|
||||||
{ index: true, element: <NotificationTemplates /> },
|
|
||||||
{ path: 'add', element: <AddNotificationTemplate /> },
|
|
||||||
{ path: ':id/edit', element: <EditNotificationTemplate /> },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
// Activity Feed
|
// Activity Feed
|
||||||
{ path: 'activity', element: <ActivityFeed /> },
|
{ path: 'activity', element: <ActivityFeed /> },
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
// no lesson_count/quiz_id of its own — shows unit_count instead (how many Units
|
// no lesson_count/quiz_id of its own — shows unit_count instead (how many Units
|
||||||
// it's attached to).
|
// it's attached to).
|
||||||
|
|
||||||
import { Timer, LockIcon, Layers } from "lucide-react";
|
import { Timer, LockIcon, Layers, Tag } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { resolveTierBadge, cheapestTierSlug } from "@/utils/tierBadge.util";
|
||||||
|
|
||||||
function formatDuration(seconds = 0) {
|
function formatDuration(seconds = 0) {
|
||||||
if (!seconds) return null;
|
if (!seconds) return null;
|
||||||
@@ -17,11 +18,13 @@ function formatDuration(seconds = 0) {
|
|||||||
return `${m}m`;
|
return `${m}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LessonCard = ({ lesson, onViewDetails }) => {
|
export const LessonCard = ({ lesson, tierMap = {}, onViewDetails }) => {
|
||||||
const locked = lesson.is_locked;
|
const locked = lesson.is_locked;
|
||||||
const duration = formatDuration(lesson.duration_seconds);
|
const duration = formatDuration(lesson.duration_seconds);
|
||||||
const unitCount = Number(lesson.unit_count ?? 0);
|
const unitCount = Number(lesson.unit_count ?? 0);
|
||||||
const courses = lesson.courses ?? [];
|
const courses = lesson.courses ?? [];
|
||||||
|
const slug = cheapestTierSlug(courses.map((c) => c.subscription), tierMap);
|
||||||
|
const { label, cls } = resolveTierBadge(slug, tierMap);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -35,23 +38,12 @@ export const LessonCard = ({ lesson, onViewDetails }) => {
|
|||||||
onClick={() => onViewDetails(lesson)}
|
onClick={() => onViewDetails(lesson)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<Badge className={cls}>
|
||||||
|
{locked ? <LockIcon className="size-3" /> : <Tag className="size-3" />}
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
{locked && (
|
{locked && (
|
||||||
<Badge variant="secondary">
|
<Badge variant="outline" className="text-muted-foreground">Locked</Badge>
|
||||||
<LockIcon className="size-3" /> Locked
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{unitCount > 0 ? (
|
|
||||||
<Badge variant="outline">
|
|
||||||
<Layers className="size-3" /> In {unitCount} unit{unitCount === 1 ? "" : "s"}
|
|
||||||
{courses[0] && (
|
|
||||||
<>
|
|
||||||
{" "}· <span className="truncate max-w-[120px] inline-block align-bottom">{courses[0].title}</span>
|
|
||||||
{courses.length > 1 ? ` +${courses.length - 1}` : ""}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
!locked && <Badge variant="outline" className="text-muted-foreground">Standalone</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// UnitCard — grid card for a standalone Unit. Shared by UnitsList.jsx and
|
// UnitCard — grid card for a standalone Unit. Shared by UnitsList.jsx and
|
||||||
// Dashboard.jsx's "Featured Units" section.
|
// Dashboard.jsx's "Featured Units" section.
|
||||||
|
|
||||||
import { Timer, LockIcon, Layers, BookOpen, ClipboardList } from "lucide-react";
|
import { Timer, LockIcon, Layers, Tag } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { resolveTierBadge, cheapestTierSlug } from "@/utils/tierBadge.util";
|
||||||
|
|
||||||
function formatDuration(seconds = 0) {
|
function formatDuration(seconds = 0) {
|
||||||
if (!seconds) return null;
|
if (!seconds) return null;
|
||||||
@@ -15,12 +16,13 @@ function formatDuration(seconds = 0) {
|
|||||||
return `${m}m`;
|
return `${m}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UnitCard = ({ unit, onViewDetails }) => {
|
export const UnitCard = ({ unit, tierMap = {}, onViewDetails }) => {
|
||||||
const locked = unit.is_locked;
|
const locked = unit.is_locked;
|
||||||
const duration = formatDuration(unit.duration_seconds);
|
const duration = formatDuration(unit.duration_seconds);
|
||||||
const lessonCount = Number(unit.lesson_count ?? 0);
|
const lessonCount = Number(unit.lesson_count ?? 0);
|
||||||
const courseCount = Number(unit.course_count ?? 0);
|
|
||||||
const courses = unit.courses ?? [];
|
const courses = unit.courses ?? [];
|
||||||
|
const slug = cheapestTierSlug([unit.subscription, ...courses.map((c) => c.subscription)], tierMap);
|
||||||
|
const { label, cls } = resolveTierBadge(slug, tierMap);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -34,23 +36,10 @@ export const UnitCard = ({ unit, onViewDetails }) => {
|
|||||||
onClick={() => onViewDetails(unit)}
|
onClick={() => onViewDetails(unit)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{locked && (
|
<Badge className={cls}>
|
||||||
<Badge variant="secondary">
|
{locked ? <LockIcon className="size-3" /> : <Tag className="size-3" />}
|
||||||
<LockIcon className="size-3" /> Locked
|
{label}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
|
||||||
{courseCount > 0 ? (
|
|
||||||
<Badge variant="outline">
|
|
||||||
<BookOpen className="size-3" />
|
|
||||||
<span className="truncate max-w-[140px] inline-block align-bottom">{courses[0]?.title ?? "Course"}</span>
|
|
||||||
{courseCount > 1 ? ` +${courseCount - 1}` : ""}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
!locked && <Badge variant="outline" className="text-muted-foreground">Standalone</Badge>
|
|
||||||
)}
|
|
||||||
{unit.quiz_id && (
|
|
||||||
<Badge variant="outline"><ClipboardList className="size-3" /> Quiz</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// modules/client/pages/AdvertisementLandingPage.jsx
|
||||||
|
//
|
||||||
|
// Destination for an advertisement's own click-through when no redirect_link
|
||||||
|
// was set — the internal page authored in the "Page Builder" wizard step
|
||||||
|
// (Step 3 of Add Advertisement). Resolved by uuid via
|
||||||
|
// GET /api/client/advertisements/uuid/:uuid.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { Megaphone, ArrowLeft } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
|
import api from "@/utils/api.util";
|
||||||
|
|
||||||
|
export default function AdvertisementLandingPage() {
|
||||||
|
const { uuid } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [ad, setAd] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [notFound, setNotFound] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setNotFound(false);
|
||||||
|
api.get(`/client/advertisements/uuid/${uuid}`)
|
||||||
|
.then(({ data }) => setAd(data?.data?.data ?? null))
|
||||||
|
.catch(() => setNotFound(true))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [uuid]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="my-20 lg:container lg:mx-auto max-w-3xl px-4 space-y-4">
|
||||||
|
<Skeleton className="h-8 w-2/3" />
|
||||||
|
<Skeleton className="h-56 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-5/6" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notFound || !ad) {
|
||||||
|
return (
|
||||||
|
<div className="my-20 lg:container lg:mx-auto max-w-3xl px-4 flex flex-col items-center text-center gap-3 py-16">
|
||||||
|
<Megaphone className="size-8 text-muted-foreground" />
|
||||||
|
<p className="font-medium">This advertisement is no longer available.</p>
|
||||||
|
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||||
|
<ArrowLeft className="size-4" /> Go back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = ad.landing_page ?? {};
|
||||||
|
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
|
||||||
|
const links = Array.isArray(page.links) ? page.links : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-20 lg:container lg:mx-auto max-w-3xl px-4 space-y-6 pb-16">
|
||||||
|
<PageMeta title={page.title ? `${page.title} - STARR` : undefined} description={page.description} />
|
||||||
|
|
||||||
|
<Button variant="ghost" size="sm" className="w-fit -ml-2" onClick={() => navigate(-1)}>
|
||||||
|
<ArrowLeft className="size-4" /> Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{imageSrc && (
|
||||||
|
<div className="rounded-lg overflow-hidden border h-56 sm:h-72">
|
||||||
|
<img src={imageSrc} alt={page.title || ad.headline || "Advertisement"} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">{page.title || ad.headline || "Advertisement"}</h1>
|
||||||
|
{page.description && <p className="text-muted-foreground text-lg">{page.description}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{page.body && (
|
||||||
|
<div className="prose prose-sm sm:prose max-w-none dark:prose-invert whitespace-pre-wrap">
|
||||||
|
{page.body}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{links.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
{links.map((l, i) => (
|
||||||
|
<Button
|
||||||
|
key={i}
|
||||||
|
variant={i === 0 ? "default" : "outline"}
|
||||||
|
onClick={() => {
|
||||||
|
if (!l.link) return;
|
||||||
|
if (/^https?:\/\//.test(l.link)) window.open(l.link, "_blank", "noopener,noreferrer");
|
||||||
|
else navigate(l.link);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{l.label || l.link}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,7 +29,6 @@ import { toast } from "sonner";
|
|||||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
|
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
|
||||||
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
|
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
|
||||||
import { Sidebar, SidebarSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Sidebar";
|
|
||||||
import { Tags } from "lucide-react";
|
import { Tags } from "lucide-react";
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -542,13 +541,12 @@ const CourseDetails = () => {
|
|||||||
getMyTier();
|
getMyTier();
|
||||||
getCourse(courseId);
|
getCourse(courseId);
|
||||||
fetchCourseProgress(courseId);
|
fetchCourseProgress(courseId);
|
||||||
getActiveAdvertisements(["course_details.banner", "course_details.sidebar"]);
|
getActiveAdvertisements(["course_details.banner"]);
|
||||||
return () => { resetCourse(); resetProgress(); setBadgeImageUrl(null); };
|
return () => { resetCourse(); resetProgress(); setBadgeImageUrl(null); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [courseId]);
|
}, [courseId]);
|
||||||
|
|
||||||
const bannerAd = advertisements["course_details.banner"] ?? null;
|
const bannerAd = advertisements["course_details.banner"] ?? null;
|
||||||
const sidebarAd = advertisements["course_details.sidebar"] ?? null;
|
|
||||||
|
|
||||||
// Resolve badge image once course loads — issue a client stream token for
|
// Resolve badge image once course loads — issue a client stream token for
|
||||||
// private S3 assets so the badge preview works on this page.
|
// private S3 assets so the badge preview works on this page.
|
||||||
@@ -714,7 +712,6 @@ const CourseDetails = () => {
|
|||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-3 xs:-mt-5 lg:-mt-0">
|
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-3 xs:-mt-5 lg:-mt-0">
|
||||||
<div className="flex flex-col lg:flex-row gap-8">
|
|
||||||
<div className="flex flex-col xs:gap-12 lg:gap-12 flex-1 min-w-0">
|
<div className="flex flex-col xs:gap-12 lg:gap-12 flex-1 min-w-0">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="font-bold text-2xl">About this course</div>
|
<div className="font-bold text-2xl">About this course</div>
|
||||||
@@ -764,16 +761,6 @@ const CourseDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Advertisement Sidebar */}
|
|
||||||
<aside className="hidden lg:block w-72 shrink-0 sticky top-36 h-fit">
|
|
||||||
{adLoading["course_details.sidebar"] ? (
|
|
||||||
<SidebarSkeleton />
|
|
||||||
) : (
|
|
||||||
<Sidebar ad={sidebarAd} onCtaClick={handleAdCtaClick} />
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import { useDateFormat } from "@/hooks/useDateFormat";
|
|||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
import { Building2 } from "lucide-react";
|
import { Building2 } from "lucide-react";
|
||||||
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
|
|
||||||
import { Banner, BannerSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Banner";
|
|
||||||
import { GitBranch } from "lucide-react";
|
import { GitBranch } from "lucide-react";
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -166,7 +164,6 @@ const CoursesList = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { courses, coursesLoading, getCourses } = useClientCourses();
|
const { courses, coursesLoading, getCourses } = useClientCourses();
|
||||||
const { fmtCurrency } = useDateFormat();
|
const { fmtCurrency } = useDateFormat();
|
||||||
const { advertisements, loading: adLoading, getActiveAdvertisement, handleAdCtaClick } = useClientAdvertisements();
|
|
||||||
|
|
||||||
const [tierCategories, setTierCategories] = useState([]);
|
const [tierCategories, setTierCategories] = useState([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@@ -187,12 +184,9 @@ const CoursesList = () => {
|
|||||||
api.get("/client/courses/categories")
|
api.get("/client/courses/categories")
|
||||||
.then(({ data }) => setAllCategories(data.data ?? []))
|
.then(({ data }) => setAllCategories(data.data ?? []))
|
||||||
.catch(() => { });
|
.catch(() => { });
|
||||||
getActiveAdvertisement("course_list.banner");
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const bannerAd = advertisements["course_list.banner"] ?? null;
|
|
||||||
|
|
||||||
// slug → category info map
|
// slug → category info map
|
||||||
const tierMap = useMemo(() => {
|
const tierMap = useMemo(() => {
|
||||||
const m = {};
|
const m = {};
|
||||||
@@ -306,13 +300,6 @@ const CoursesList = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Advertisement Banner */}
|
|
||||||
{adLoading["course_list.banner"] ? (
|
|
||||||
<BannerSkeleton />
|
|
||||||
) : (
|
|
||||||
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{categoryFilter !== "All" && (
|
{categoryFilter !== "All" && (
|
||||||
<div className="flex items-center flex-wrap gap-2">
|
<div className="flex items-center flex-wrap gap-2">
|
||||||
<span className="text-sm text-muted-foreground">Tags:</span>
|
<span className="text-sm text-muted-foreground">Tags:</span>
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import { cn } from "@/lib/utils";
|
|||||||
import { useGroup } from "@/contexts/ClientGroupContext";
|
import { useGroup } from "@/contexts/ClientGroupContext";
|
||||||
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
|
import { useClientAdvertisements } from "@/contexts/ClientAdvertisementContext";
|
||||||
import { Hero, HeroSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Hero";
|
import { Hero, HeroSkeleton } from "@/components/generic/Blocks/Client/Advertisements/Hero";
|
||||||
import { Popup } from "@/components/generic/Blocks/Client/Advertisements/Popup";
|
|
||||||
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -214,9 +213,8 @@ const Client = () => {
|
|||||||
const { myTier, getMyTier, tierMap } = useClientTiers();
|
const { myTier, getMyTier, tierMap } = useClientTiers();
|
||||||
const { groups, fetchGroups, loading: groupLoading } = useGroup();
|
const { groups, fetchGroups, loading: groupLoading } = useGroup();
|
||||||
const {
|
const {
|
||||||
advertisements, getActiveAdvertisements,
|
|
||||||
adLists, listLoading, getActiveAdvertisementList,
|
adLists, listLoading, getActiveAdvertisementList,
|
||||||
handleAdCtaClick, dismissPopupForever,
|
handleAdCtaClick,
|
||||||
} = useClientAdvertisements();
|
} = useClientAdvertisements();
|
||||||
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
@@ -228,12 +226,9 @@ const Client = () => {
|
|||||||
const [lessonModalOpen, setLessonModalOpen] = useState(false);
|
const [lessonModalOpen, setLessonModalOpen] = useState(false);
|
||||||
const [selectedLesson, setSelectedLesson] = useState(null);
|
const [selectedLesson, setSelectedLesson] = useState(null);
|
||||||
|
|
||||||
const [popupOpen, setPopupOpen] = useState(false);
|
|
||||||
|
|
||||||
const userTier = myTier?.tier ?? "free";
|
const userTier = myTier?.tier ?? "free";
|
||||||
|
|
||||||
const heroAds = adLists["dashboard.hero"] ?? [];
|
const heroAds = adLists["dashboard.hero"] ?? [];
|
||||||
const popupAd = advertisements["dashboard.popup"] ?? null;
|
|
||||||
|
|
||||||
// Show welcome toast on first registration
|
// Show welcome toast on first registration
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -256,11 +251,8 @@ const Client = () => {
|
|||||||
fetchGroups();
|
fetchGroups();
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// ── Resolve active popup ad + hero ad carousel once on mount ─────────────
|
// ── Resolve hero ad carousel once on mount ────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getActiveAdvertisements(["dashboard.popup"]).then((result) => {
|
|
||||||
if (result["dashboard.popup"]) setPopupOpen(true);
|
|
||||||
});
|
|
||||||
getActiveAdvertisementList("dashboard.hero");
|
getActiveAdvertisementList("dashboard.hero");
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@@ -387,6 +379,7 @@ const Client = () => {
|
|||||||
<UnitCard
|
<UnitCard
|
||||||
key={unit.unit_id}
|
key={unit.unit_id}
|
||||||
unit={unit}
|
unit={unit}
|
||||||
|
tierMap={tierMap}
|
||||||
onViewDetails={handleViewUnitDetails}
|
onViewDetails={handleViewUnitDetails}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -415,6 +408,7 @@ const Client = () => {
|
|||||||
<LessonCard
|
<LessonCard
|
||||||
key={lesson.lesson_id}
|
key={lesson.lesson_id}
|
||||||
lesson={lesson}
|
lesson={lesson}
|
||||||
|
tierMap={tierMap}
|
||||||
onViewDetails={handleViewLessonDetails}
|
onViewDetails={handleViewLessonDetails}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -425,15 +419,6 @@ const Client = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Popup Advertisement ── */}
|
|
||||||
<Popup
|
|
||||||
ad={popupAd}
|
|
||||||
open={popupOpen}
|
|
||||||
onOpenChange={setPopupOpen}
|
|
||||||
onCtaClick={handleAdCtaClick}
|
|
||||||
onDismissForever={dismissPopupForever}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── Upsell Modal — only for locked courses ── */}
|
{/* ── Upsell Modal — only for locked courses ── */}
|
||||||
<ResponsiveModal
|
<ResponsiveModal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ const LessonsList = () => {
|
|||||||
<LessonCard
|
<LessonCard
|
||||||
key={lesson.lesson_id}
|
key={lesson.lesson_id}
|
||||||
lesson={lesson}
|
lesson={lesson}
|
||||||
|
tierMap={tierMap}
|
||||||
onViewDetails={handleViewDetails}
|
onViewDetails={handleViewDetails}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -356,11 +356,11 @@ export default function PlanList() {
|
|||||||
getPlans();
|
getPlans();
|
||||||
getMyTier();
|
getMyTier();
|
||||||
getTierCategories();
|
getTierCategories();
|
||||||
getActiveAdvertisement("plans.banner");
|
getActiveAdvertisement("tier_plans.banner");
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [getPlans, getMyTier]);
|
}, [getPlans, getMyTier]);
|
||||||
|
|
||||||
const bannerAd = advertisements["plans.banner"] ?? null;
|
const bannerAd = advertisements["tier_plans.banner"] ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearInterval(refundTimerRef.current);
|
clearInterval(refundTimerRef.current);
|
||||||
@@ -406,7 +406,7 @@ export default function PlanList() {
|
|||||||
<div className="lg:container lg:mx-auto space-y-8 p-6">
|
<div className="lg:container lg:mx-auto space-y-8 p-6">
|
||||||
|
|
||||||
{/* Advertisement Banner */}
|
{/* Advertisement Banner */}
|
||||||
{adLoading["plans.banner"] ? (
|
{adLoading["tier_plans.banner"] ? (
|
||||||
<BannerSkeleton />
|
<BannerSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
|
<Banner ad={bannerAd} onCtaClick={handleAdCtaClick} />
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ const UnitsList = () => {
|
|||||||
<UnitCard
|
<UnitCard
|
||||||
key={unit.unit_id}
|
key={unit.unit_id}
|
||||||
unit={unit}
|
unit={unit}
|
||||||
|
tierMap={tierMap}
|
||||||
onViewDetails={handleViewDetails}
|
onViewDetails={handleViewDetails}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import MyCertificates from '../pages/MyCertificates'
|
|||||||
import MyAchievements from '../pages/MyAchievements'
|
import MyAchievements from '../pages/MyAchievements'
|
||||||
import AccountSettings from '../pages/AccountSettings'
|
import AccountSettings from '../pages/AccountSettings'
|
||||||
import Notifications from '../pages/Notifications'
|
import Notifications from '../pages/Notifications'
|
||||||
|
import AdvertisementLandingPage from '../pages/AdvertisementLandingPage'
|
||||||
import IntroPage from '@/modules/auth/pages/Intro'
|
import IntroPage from '@/modules/auth/pages/Intro'
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ export const ClientRoutes = {
|
|||||||
{ path: 'achievements', element: <MyAchievements /> },
|
{ path: 'achievements', element: <MyAchievements /> },
|
||||||
{ path: 'settings', element: <AccountSettings /> },
|
{ path: 'settings', element: <AccountSettings /> },
|
||||||
{ path: 'notifications', element: <Notifications /> },
|
{ path: 'notifications', element: <Notifications /> },
|
||||||
|
{ path: 'ads/:uuid', element: <AdvertisementLandingPage /> },
|
||||||
{
|
{
|
||||||
path: 'plans', element: <Outlet />,
|
path: 'plans', element: <Outlet />,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -22,3 +22,18 @@ export function tierBadgeClass(colorKey = 'green') {
|
|||||||
export function tierPanelColors(colorKey = 'green') {
|
export function tierPanelColors(colorKey = 'green') {
|
||||||
return getTierColor(colorKey).panel;
|
return getTierColor(colorKey).panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the lowest-rank (cheapest) tier slug among several access paths —
|
||||||
|
* e.g. a Unit's own subscription plus every course that also unlocks it, or
|
||||||
|
* a Lesson's attached courses. Falls back to 'free' when none are gated.
|
||||||
|
*/
|
||||||
|
export function cheapestTierSlug(slugs = [], tierMap = {}) {
|
||||||
|
const gated = slugs.filter(Boolean);
|
||||||
|
if (!gated.length) return 'free';
|
||||||
|
return gated.reduce((cheapest, slug) => {
|
||||||
|
const rank = tierMap[slug]?.rank ?? 0;
|
||||||
|
const cheapestRank = tierMap[cheapest]?.rank ?? 0;
|
||||||
|
return rank < cheapestRank ? slug : cheapest;
|
||||||
|
}, gated[0]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -198,6 +198,32 @@ export const TIER_COLOR_OPTIONS = Object.entries(TIER_COLOR_MAP).map(([key, val]
|
|||||||
swatch: val.swatch,
|
swatch: val.swatch,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/** Darkens (negative percent) or lightens (positive percent) a hex color. */
|
||||||
|
export function shadeColor(hex, percent) {
|
||||||
|
const num = parseInt(hex.replace("#", ""), 16);
|
||||||
|
const amt = Math.round(2.55 * percent);
|
||||||
|
const clamp = (v) => Math.max(0, Math.min(255, v));
|
||||||
|
const r = clamp((num >> 16) + amt);
|
||||||
|
const g = clamp(((num >> 8) & 0x00ff) + amt);
|
||||||
|
const b = clamp((num & 0x0000ff) + amt);
|
||||||
|
return "#" + (0x1000000 + r * 0x10000 + g * 0x100 + b).toString(16).slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The perceived-brightness formula below underweights blue, so these read as
|
||||||
|
// "dark enough for white text" even though they're visually too light for it.
|
||||||
|
const FORCE_BLACK_TEXT = new Set(["sky", "blue", "cyan", "teal"]);
|
||||||
|
|
||||||
|
/** Returns "#000000" or "#ffffff", whichever reads better on top of the given hex color. */
|
||||||
|
export function getContrastText(hex, key) {
|
||||||
|
if (key && FORCE_BLACK_TEXT.has(key)) return "#000000";
|
||||||
|
const num = parseInt(hex.replace("#", ""), 16);
|
||||||
|
const r = (num >> 16) & 0xff;
|
||||||
|
const g = (num >> 8) & 0xff;
|
||||||
|
const b = num & 0xff;
|
||||||
|
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||||
|
return luminance > 0.6 ? "#000000" : "#ffffff";
|
||||||
|
}
|
||||||
|
|
||||||
/** Fallback when a stored color key is not in the map. */
|
/** Fallback when a stored color key is not in the map. */
|
||||||
const FALLBACK = TIER_COLOR_MAP.purple;
|
const FALLBACK = TIER_COLOR_MAP.purple;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user