Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-11 12:12:40 +08:00
parent 01a2c63b06
commit 71f758fe0b
66 changed files with 3055 additions and 939 deletions
@@ -1,11 +1,13 @@
// modules/admin/pages/notifications/AddNotificationBroadcast.jsx
import { useEffect, 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 { House } from "lucide-react";
import api from "@/utils/api.util";
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
import { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -19,16 +21,19 @@ import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
// ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({
title: z.string().min(1, "Title is required."),
message: z.string().min(1, "Message is required."),
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { required_error: "Target is required." }),
target_type: z.enum(["admin", "user", "both", "task_list", "course", "tier_plan"], { message: "Please select a target." }),
target_id: z.string().nullable().optional(),
show_in_sticky: z.boolean().optional(),
show_in_notifications: z.boolean().optional(),
link_mode: z.enum(["info", "link"]).optional(),
link_url: z.string().trim().optional(),
}).superRefine((data, ctx) => {
if (TARGET_TYPE_MAP[data.target_type]?.needsTarget && !data.target_id) {
ctx.addIssue({
@@ -45,6 +50,14 @@ const schema = z.object({
path: ["show_in_sticky"],
});
}
if (data.show_in_sticky && data.link_mode === "link" && !data.link_url) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Enter a link URL, or switch to text info only.",
path: ["link_url"],
});
}
});
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -80,7 +93,7 @@ export default function AddNotificationBroadcast() {
handleSubmit,
watch,
setValue,
formState: { errors },
formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
@@ -90,14 +103,33 @@ export default function AddNotificationBroadcast() {
target_id: null,
show_in_sticky: false,
show_in_notifications: true,
link_mode: "info",
link_url: "",
},
});
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 targetId = watch("target_id");
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
const showInSticky = watch("show_in_sticky");
const showInNotifications = watch("show_in_notifications");
const linkMode = watch("link_mode");
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -106,16 +138,18 @@ export default function AddNotificationBroadcast() {
];
const onSubmit = async (values) => {
const { link_mode, ...rest } = values;
const payload = {
...values,
...rest,
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
show_in_sticky: values.show_in_sticky ?? false,
show_in_notifications: values.show_in_notifications ?? true,
link_url: (values.show_in_sticky && link_mode === "link") ? values.link_url.trim() : null,
createdBy: user?.user_id ?? null,
};
const res = await createBroadcast(payload);
if (res) navigate("/admin/announcements");
if (res) { bypassOnce(); navigate("/admin/announcements"); }
};
return (
@@ -132,6 +166,22 @@ export default function AddNotificationBroadcast() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<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>
<Label className="mb-1.5 block">Title</Label>
<Input placeholder="e.g. Scheduled maintenance tonight" {...register("title")} />
@@ -149,8 +199,8 @@ export default function AddNotificationBroadcast() {
<Select
value={targetType}
onValueChange={(v) => {
setValue("target_type", v, { shouldValidate: true });
setValue("target_id", null);
setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
setValue("target_id", null, { shouldDirty: true });
}}
>
<SelectTrigger>
@@ -175,7 +225,7 @@ export default function AddNotificationBroadcast() {
<BroadcastTargetPicker
targetType={targetType}
value={targetId}
onChange={(id) => setValue("target_id", id, { shouldValidate: true })}
onChange={(id) => setValue("target_id", id, { shouldValidate: true, shouldDirty: true })}
/>
<FieldError message={errors.target_id?.message} />
</div>
@@ -188,7 +238,7 @@ export default function AddNotificationBroadcast() {
<Checkbox
id="show_in_sticky"
checked={showInSticky === true}
onCheckedChange={(v) => setValue("show_in_sticky", v === true, { shouldValidate: true })}
onCheckedChange={(v) => setValue("show_in_sticky", v === true, { shouldValidate: true, shouldDirty: true })}
/>
<Label htmlFor="show_in_sticky" className="cursor-pointer">
Show in Sticky Announcements
@@ -199,7 +249,7 @@ export default function AddNotificationBroadcast() {
<Checkbox
id="show_in_notifications"
checked={showInNotifications === true}
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true })}
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
/>
<Label htmlFor="show_in_notifications" className="cursor-pointer">
Show in Notifications
@@ -208,6 +258,44 @@ export default function AddNotificationBroadcast() {
</div>
</SectionCard>
{showInSticky && (
<SectionCard title="On Open" description="What happens when someone opens this announcement from the sticky banner.">
<div className="flex gap-2">
<Button
type="button"
variant={linkMode !== "link" ? "secondary" : "outline"}
className="flex-1"
onClick={() => setValue("link_mode", "info", { shouldDirty: true })}
>
Text info only
</Button>
<Button
type="button"
variant={linkMode === "link" ? "secondary" : "outline"}
className="flex-1"
onClick={() => setValue("link_mode", "link", { shouldDirty: true })}
>
Include a link
</Button>
</div>
{linkMode === "link" ? (
<div>
<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">
Shows an "Open Link" button in the full-content view. Internal paths (starting with /) navigate in-app; anything else opens in a new tab.
</p>
</div>
) : (
<p className="text-xs text-muted-foreground">
The full-content view will show just the title and message, with no action button.
</p>
)}
</SectionCard>
)}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
@@ -220,6 +308,8 @@ export default function AddNotificationBroadcast() {
</form>
</div>
</div>
{unsavedChangesDialog}
</section>
);
}