Files
starr-philproperties/src/modules/admin/pages/notifications/AddNotificationBroadcast.jsx
T
2026-08-03 22:48:16 +08:00

570 lines
30 KiB
React

// 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 <p className="text-xs text-destructive mt-1">{message}</p>;
}
function SectionCard({ title, description, children }) {
return (
<div className="rounded-lg border bg-card p-6 space-y-5">
{(title || description) && (
<div className="space-y-0.5 pb-1 border-b">
{title && <h2 className="text-sm font-semibold">{title}</h2>}
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
)}
{children}
</div>
);
}
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 ───────────────────────────────────────────────────────────────────
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: <House className="size-4" />, 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 (
<section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
<div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={breadcrumbItems} />
</div>
<div className="w-full max-w-2xl pb-10">
<h1 className="text-2xl font-semibold tracking-tight mb-1">New Alert</h1>
<p className="text-sm text-muted-foreground mb-6">Compose an alert. It's saved as a draft until you send it.</p>
<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.">
<div>
<Label className="mb-1.5 block">Title</Label>
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
<FieldError message={errors.title?.message} />
</div>
{showInNotifications && (
<div>
<Label className="mb-1.5 block">Message</Label>
<Textarea rows={4} placeholder="Full alert text" {...register("message")} />
<FieldError message={errors.message?.message} />
</div>
)}
</SectionCard>
<SectionCard title="Display" description="Where clients/admins can see this alert.">
<div className="space-y-3">
<div className="flex items-center gap-3">
<Checkbox
id="show_in_sticky"
checked={showInSticky === true}
onCheckedChange={(v) => {
const checked = v === true;
setValue("show_in_sticky", checked, { shouldValidate: true, shouldDirty: true });
if (checked) setValue("show_in_notifications", false, { shouldValidate: true, shouldDirty: true });
}}
/>
<Label htmlFor="show_in_sticky" className="cursor-pointer">
Show in Sticky Alerts
</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox
id="show_in_notifications"
checked={showInNotifications === true}
onCheckedChange={(v) => {
const checked = v === true;
setValue("show_in_notifications", checked, { shouldValidate: true, shouldDirty: true });
if (checked) setValue("show_in_sticky", false, { shouldValidate: true, shouldDirty: true });
}}
/>
<Label htmlFor="show_in_notifications" className="cursor-pointer">
Show in Notifications
</Label>
</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-1.5">
{TIER_COLOR_OPTIONS.map((opt) => {
const selected = (color || "indigo") === opt.key;
return (
<button
key={opt.key}
type="button"
title={opt.label}
onClick={() => setValue("color", opt.key, { shouldDirty: true })}
className={[
"w-5 h-5 rounded-full border-2 transition-all",
selected
? "border-foreground scale-110 shadow-sm"
: "border-transparent hover:border-muted-foreground/50",
].join(" ")}
style={{ backgroundColor: opt.swatch }}
/>
);
})}
</div>
</div>
)}
</SectionCard>
{showInSticky && (
<SectionCard title="Link" description="Where the sticky banner goes when someone clicks it. Optional.">
<div>
<Label className="mb-1.5 block">Link URL</Label>
<Input placeholder="https://example.com or /course/123" {...register("link_url")} />
<FieldError message={errors.link_url?.message} />
<p className="text-xs text-muted-foreground mt-1.5">
Internal paths (starting with /) navigate in-app; anything else opens in a new tab. Leave blank for an informational banner with no click action.
</p>
</div>
</SectionCard>
)}
{showInSticky && (
<SectionCard title="Preview" description="What the sticky banner looks like. Clicking it redirects to the link above, if set.">
<div
className="w-full flex items-center justify-center rounded-md px-3 py-2 text-sm font-semibold"
style={{
backgroundColor: getTierColor(color || "indigo").swatch,
color: getContrastText(getTierColor(color || "indigo").swatch, color || "indigo"),
}}
>
<span className="truncate">{watch("title") || "Sticky banner preview"}</span>
</div>
</SectionCard>
)}
</>
)}
{/* ── Step 1: Target ── */}
{currentStep === 1 && (
<SectionCard title="Target" description="Who receives this alert when it's sent.">
<div>
<Select
value={targetType}
onValueChange={(v) => {
setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
setValue("target_id", null, { shouldDirty: true });
setTargetLabel(null);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a target" />
</SelectTrigger>
<SelectContent>
{TARGET_TYPE_OPTIONS.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.target_type?.message} />
{targetType && (
<p className="text-xs text-muted-foreground mt-1.5">
{TARGET_TYPE_MAP[targetType]?.description}
</p>
)}
</div>
{needsTarget && (
<div>
<BroadcastTargetPicker
targetType={targetType}
value={targetId}
onChange={(id) => setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
onLabelResolved={setTargetLabel}
/>
<FieldError message={errors.target_id?.message} />
</div>
)}
</SectionCard>
)}
{/* ── Step 2: Review ── */}
{currentStep === 2 && (
<>
<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>
{showInNotifications && (
<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 Alerts</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 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>
</div>
<p className="text-xs text-muted-foreground">
{watch("link_url")
? <>Clicking redirects to <span className="font-medium text-foreground">{watch("link_url")}</span>.</>
: "No link — informational only, not clickable."}
</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("/admin/announcements")}
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" />}
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>
</form>
</div>
</div>
{unsavedChangesDialog}
</section>
);
}