mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user