// modules/admin/pages/notifications/AddNotificationBroadcast.jsx import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { format } from "date-fns"; import { House, Check, ArrowLeft, ArrowRight } from "lucide-react"; import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext"; import { useAuth } from "@/contexts/AuthContext"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; 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 { 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 { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; import { TIER_COLOR_OPTIONS, getTierColor, getContrastText } from "@/utils/tierColors"; import { normalizeExternalUrl } from "@/components/generic/notificationDisplay"; import { isValidLink, LINK_ERROR } from "@/utils/link.util"; // Internal paths ("/course/123") pass through untouched — everything else // gets a scheme so the saved URL always matches what goToLink() will open, // instead of relying on client-side normalization to paper over a bare // "example.com" the admin typed. const normalizeLinkUrl = (raw) => (!raw ? null : raw.startsWith("/") ? raw : normalizeExternalUrl(raw)); // ─── Schema ───────────────────────────────────────────────────────────────── const schema = z.object({ title: z.string().min(1, "Title is required."), message: z.string().optional(), target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { message: "Please select a target." }), target_id: z.string().nullable().optional(), show_in_sticky: z.boolean().optional(), show_in_notifications: z.boolean().optional(), link_url: z.string().trim().optional(), color: z.string().optional(), start_date: z.string().optional(), end_date: z.string().optional(), }).superRefine((data, ctx) => { if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Please select a specific target.", path: ["target_id"], }); } if (!data.show_in_sticky && !data.show_in_notifications) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Select where to show this notification (Sticky or Notifications).", path: ["show_in_sticky"], }); } if (data.show_in_sticky && data.show_in_notifications) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Choose only one: Sticky or Notifications.", path: ["show_in_notifications"], }); } if (data.show_in_notifications && !data.message?.trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Message is required for Notifications alerts.", path: ["message"], }); } if (data.link_url && !isValidLink(data.link_url)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: LINK_ERROR, path: ["link_url"], }); } 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: "What it says & how it's shown" }, { label: "Target", description: "Who receives it" }, { label: "Review", description: "Confirm & save" }, ]; // ─── Helpers ──────────────────────────────────────────────────────────────── function FieldError({ message }) { if (!message) return null; return

{message}

; } function SectionCard({ title, description, children }) { return (
{(title || description) && (
{title &&

{title}

} {description &&

{description}

}
)} {children}
); } function StepIndicator({ steps, current, onStepClick }) { return (
{steps.flatMap((step, i) => { const items = [ , ]; if (i < steps.length - 1) { items.push(
); } return items; })}
); } // ─── Page ─────────────────────────────────────────────────────────────────── export default function AddNotificationBroadcast() { const navigate = useNavigate(); const { createBroadcast, sendBroadcast, loading } = useNotificationBroadcasts(); const { user } = useAuth(); const [currentStep, setCurrentStep] = useState(0); const [targetLabel, setTargetLabel] = useState(null); const { register, handleSubmit, watch, setValue, trigger, formState: { errors, isDirty }, } = useForm({ resolver: zodResolver(schema), defaultValues: { title: "", message: "", target_type: undefined, target_id: null, show_in_sticky: false, show_in_notifications: true, link_url: "", color: "indigo", start_date: "", end_date: "", }, }); const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty); const targetType = watch("target_type"); const targetId = watch("target_id"); const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget; const showInSticky = watch("show_in_sticky"); const showInNotifications = watch("show_in_notifications"); const color = watch("color"); const startDate = watch("start_date"); const endDate = watch("end_date"); const breadcrumbItems = [ { label: "Home", icon: , to: "/admin" }, { label: "Alerts", to: "/admin/announcements" }, { label: "New" }, ]; // 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", "show_in_sticky", "show_in_notifications", "link_url", "end_date"]); if (!valid) { setCurrentStep(0); return; } } if (target > 1) { const valid = await trigger(["target_type", "target_id"]); if (!valid) { setCurrentStep(1); return; } } setCurrentStep(target); }; const saveBroadcast = async (values, { publish = false } = {}) => { const payload = { ...values, message: values.message?.trim() || null, target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null, show_in_sticky: values.show_in_sticky ?? false, show_in_notifications: values.show_in_notifications ?? true, link_url: values.link_url?.trim() ? normalizeLinkUrl(values.link_url.trim()) : 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, }; const res = await createBroadcast(payload); 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 (

New Alert

Compose an alert. It's saved as a draft until you send it.

e.preventDefault()} className="space-y-5"> {/* ── Step 0: Content ── */} {currentStep === 0 && ( <>
{showInNotifications && (