mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -1,13 +1,13 @@
|
||||
// modules/admin/pages/notifications/AddNotificationBroadcast.jsx
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
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 { House } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { House, Check, ArrowLeft, ArrowRight } 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";
|
||||
@@ -18,10 +18,13 @@ 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, shadeColor, getContrastText } from "@/utils/tierColors";
|
||||
|
||||
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,6 +37,10 @@ const schema = z.object({
|
||||
show_in_notifications: z.boolean().optional(),
|
||||
link_mode: z.enum(["info", "link"]).optional(),
|
||||
link_url: z.string().trim().optional(),
|
||||
link_label: 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({
|
||||
@@ -58,8 +65,25 @@ const schema = z.object({
|
||||
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: "Title & message" },
|
||||
{ label: "Target", description: "Who receives it" },
|
||||
{ label: "Display", description: "Where it shows & schedule" },
|
||||
{ label: "Review", description: "Confirm & save" },
|
||||
];
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function FieldError({ message }) {
|
||||
@@ -81,18 +105,72 @@ function SectionCard({ title, description, children }) {
|
||||
);
|
||||
}
|
||||
|
||||
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, loading } = useNotificationBroadcasts();
|
||||
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),
|
||||
@@ -105,31 +183,24 @@ export default function AddNotificationBroadcast() {
|
||||
show_in_notifications: true,
|
||||
link_mode: "info",
|
||||
link_url: "",
|
||||
link_label: "",
|
||||
color: "indigo",
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
},
|
||||
});
|
||||
|
||||
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 color = watch("color");
|
||||
const startDate = watch("start_date");
|
||||
const endDate = watch("end_date");
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
@@ -137,7 +208,25 @@ export default function AddNotificationBroadcast() {
|
||||
{ label: "New" },
|
||||
];
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
// 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"]);
|
||||
if (!valid) { setCurrentStep(0); return; }
|
||||
}
|
||||
if (target > 1) {
|
||||
const valid = await trigger(["target_type", "target_id"]);
|
||||
if (!valid) { setCurrentStep(1); return; }
|
||||
}
|
||||
if (target > 2) {
|
||||
const valid = await trigger(["show_in_sticky", "show_in_notifications", "link_url", "end_date"]);
|
||||
if (!valid) { setCurrentStep(2); return; }
|
||||
}
|
||||
setCurrentStep(target);
|
||||
};
|
||||
|
||||
const saveBroadcast = async (values, { publish = false } = {}) => {
|
||||
const { link_mode, ...rest } = values;
|
||||
const payload = {
|
||||
...rest,
|
||||
@@ -145,11 +234,24 @@ export default function AddNotificationBroadcast() {
|
||||
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,
|
||||
link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : 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);
|
||||
if (res) { bypassOnce(); navigate("/admin/announcements"); }
|
||||
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 (
|
||||
@@ -163,147 +265,328 @@ export default function AddNotificationBroadcast() {
|
||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">New announcement</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">
|
||||
<StepIndicator steps={STEPS} current={currentStep} onStepClick={goToStep} />
|
||||
|
||||
<SectionCard title="Content" description="What admins and/or users will see.">
|
||||
{templates.length > 0 && (
|
||||
<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">Load from template</Label>
|
||||
<Select onValueChange={applyTemplate}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ── Step 1: Target ── */}
|
||||
{currentStep === 1 && (
|
||||
<SectionCard title="Target" description="Who receives this announcement 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="Optional — start from a saved preset" />
|
||||
<SelectValue placeholder="Select a target" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((t) => (
|
||||
<SelectItem key={t.notification_template_id} value={String(t.notification_template_id)}>{t.label}</SelectItem>
|
||||
{TARGET_TYPE_OPTIONS.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{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")} />
|
||||
<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 announcement when it's sent.">
|
||||
<div>
|
||||
<Select
|
||||
value={targetType}
|
||||
onValueChange={(v) => {
|
||||
setValue("target_type", v, { shouldValidate: true, shouldDirty: true });
|
||||
setValue("target_id", null, { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<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 })}
|
||||
/>
|
||||
<FieldError message={errors.target_id?.message} />
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Display" description="Where clients/admins can see this announcement.">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="show_in_sticky"
|
||||
checked={showInSticky === 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
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="show_in_notifications"
|
||||
checked={showInNotifications === true}
|
||||
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
|
||||
/>
|
||||
<Label htmlFor="show_in_notifications" className="cursor-pointer">
|
||||
Show in Notifications
|
||||
</Label>
|
||||
</div>
|
||||
</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} />
|
||||
<FieldError message={errors.target_type?.message} />
|
||||
{targetType && (
|
||||
<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.
|
||||
{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>
|
||||
) : (
|
||||
<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
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Save draft
|
||||
{/* ── Step 2: Display ── */}
|
||||
{currentStep === 2 && (
|
||||
<>
|
||||
<SectionCard title="Display" description="Where clients/admins can see this announcement.">
|
||||
<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 });
|
||||
// Sticky-only announcements vanish forever once dismissed (seen=true drops
|
||||
// them from the sticky query, show_in_notifications=false hides them from
|
||||
// the list too) — force the list entry so it stays reachable afterward.
|
||||
if (checked) setValue("show_in_notifications", true, { shouldValidate: true, shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
||||
Show in Sticky Announcements
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="show_in_notifications"
|
||||
checked={showInNotifications === true}
|
||||
disabled={showInSticky === true}
|
||||
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: true })}
|
||||
/>
|
||||
<Label htmlFor="show_in_notifications" className={showInSticky ? "text-muted-foreground" : "cursor-pointer"}>
|
||||
Show in Notifications
|
||||
</Label>
|
||||
</div>
|
||||
{showInSticky && (
|
||||
<p className="text-xs text-muted-foreground pl-7">
|
||||
Required while sticky is on, so it stays visible after being dismissed.
|
||||
</p>
|
||||
)}
|
||||
</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-2">
|
||||
{TIER_COLOR_OPTIONS.map((opt) => {
|
||||
const selected = (color || "indigo") === opt.key;
|
||||
const bg = selected ? shadeColor(opt.swatch, -20) : opt.swatch;
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
title={opt.label}
|
||||
onClick={() => setValue("color", opt.key, { shouldDirty: true })}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all ${selected ? "scale-105 shadow-md" : "opacity-80 hover:opacity-100"}`}
|
||||
style={{ backgroundColor: bg, color: getContrastText(bg, opt.key) }}
|
||||
>
|
||||
{selected && <Check className="size-3" />}
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className="w-full flex items-center justify-center gap-3 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>
|
||||
{linkMode === "link" && (
|
||||
<Button type="button" variant="outline" size="sm" className="shrink-0 text-foreground">
|
||||
{watch("link_label") || "Open Link"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</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 className="space-y-4">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Button label</Label>
|
||||
<Input placeholder="e.g. Shop now" {...register("link_label")} />
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
Shown as a button right after the title in the sticky banner. Defaults to "Open Link".
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Review ── */}
|
||||
{currentStep === 3 && (
|
||||
<>
|
||||
<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>
|
||||
<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 Announcements</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 gap-3 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>
|
||||
{linkMode === "link" && (
|
||||
<Button type="button" variant="outline" size="sm" className="shrink-0 text-foreground">
|
||||
{watch("link_label") || "Open Link"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{showInSticky && (
|
||||
<SectionCard title="On Open">
|
||||
<p className="text-sm">
|
||||
{linkMode === "link"
|
||||
? <>Opens <span className="font-medium">{watch("link_url") || "—"}</span> via a “{watch("link_label") || "Open Link"}” button.</>
|
||||
: "Text info only — no action button."}
|
||||
</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(-1)}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user