add: more commits

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-03 16:21:27 +08:00
parent 17326b2c2e
commit 7e964f2432
112 changed files with 9160 additions and 3461 deletions
@@ -0,0 +1,182 @@
// modules/admin/pages/notifications/AddNotificationBroadcast.jsx
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 { 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
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";
// ─── 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_id: z.string().nullable().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"],
});
}
});
// ─── 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>
);
}
// ─── Page ───────────────────────────────────────────────────────────────────
export default function AddNotificationBroadcast() {
const navigate = useNavigate();
const { createBroadcast, loading } = useNotificationBroadcasts();
const { user } = useAuth();
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
title: "",
message: "",
target_type: undefined,
target_id: null,
},
});
const targetType = watch("target_type");
const targetId = watch("target_id");
const needsTarget = TARGET_TYPE_MAP[targetType]?.needsTarget;
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Notifications", to: "/admin/notifications" },
{ label: "New" },
];
const onSubmit = async (values) => {
const payload = {
...values,
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
createdBy: user?.user_id ?? null,
};
const res = await createBroadcast(payload);
if (res) navigate("/admin/notifications");
};
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 notification</h1>
<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">
<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>
<div>
<Label className="mb-1.5 block">Message</Label>
<Textarea rows={4} placeholder="Full announcement text" {...register("message")} />
<FieldError message={errors.message?.message} />
</div>
</SectionCard>
<SectionCard title="Target" description="Who receives this notification when it's sent.">
<div>
<Select
value={targetType}
onValueChange={(v) => {
setValue("target_type", v, { shouldValidate: true });
setValue("target_id", 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 })}
/>
<FieldError message={errors.target_id?.message} />
</div>
)}
</SectionCard>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save draft
</Button>
</div>
</form>
</div>
</div>
</section>
);
}