mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
added more things
This commit is contained in:
@@ -6,14 +6,12 @@ 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, ImagePlus, X } from "lucide-react";
|
||||
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 { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||
import AlertLayoutPreview from "../../components/notifications/AlertLayoutPreview";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -28,7 +26,7 @@ import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadca
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
import { TIER_COLOR_OPTIONS, getTierColor, getContrastText } from "@/utils/tierColors";
|
||||
import { normalizeExternalUrl } from "@/components/generic/notificationDisplay";
|
||||
import { resolveAssetSrc } from "@/utils/media.util";
|
||||
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,
|
||||
@@ -40,16 +38,13 @@ const normalizeLinkUrl = (raw) => (!raw ? null : raw.startsWith("/") ? raw : nor
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
message: z.string().min(1, "Message 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_mode: z.enum(["info", "link"]).optional(),
|
||||
link_url: z.string().trim().optional(),
|
||||
link_label: z.string().trim().optional(),
|
||||
color: z.string().optional(),
|
||||
image_asset_id: z.string().nullable().optional(),
|
||||
start_date: z.string().optional(),
|
||||
end_date: z.string().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
@@ -69,10 +64,26 @@ const schema = z.object({
|
||||
});
|
||||
}
|
||||
|
||||
if (data.show_in_sticky && data.link_mode === "link" && !data.link_url) {
|
||||
if (data.show_in_sticky && data.show_in_notifications) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Enter a link URL, or switch to text info only.",
|
||||
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"],
|
||||
});
|
||||
}
|
||||
@@ -89,9 +100,8 @@ const schema = z.object({
|
||||
// ─── Steps config ───────────────────────────────────────────────────────────
|
||||
|
||||
const STEPS = [
|
||||
{ label: "Content", description: "Title & message" },
|
||||
{ label: "Content", description: "What it says & how it's shown" },
|
||||
{ label: "Target", description: "Who receives it" },
|
||||
{ label: "Display", description: "Where it shows & schedule" },
|
||||
{ label: "Review", description: "Confirm & save" },
|
||||
];
|
||||
|
||||
@@ -116,50 +126,6 @@ function SectionCard({ title, description, children }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AlertImagePicker({ selectedAsset, onPick, onRemove }) {
|
||||
if (!selectedAsset) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPick}
|
||||
className="w-full sm:w-48 aspect-video rounded-lg border border-dashed flex flex-col items-center justify-center gap-1.5 text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<ImagePlus className="size-4" />
|
||||
<span className="text-xs">Select an image</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const imageUrl = resolveAssetSrc(selectedAsset);
|
||||
|
||||
return (
|
||||
<div className="relative w-full sm:w-48 rounded-lg overflow-hidden border aspect-video group">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={selectedAsset.display_name}
|
||||
className="w-full h-full object-cover cursor-pointer"
|
||||
onClick={onPick}
|
||||
/>
|
||||
<div
|
||||
onClick={onPick}
|
||||
className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<span className="text-white text-xs opacity-0 group-hover:opacity-100">Change image</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="absolute top-1 right-1 size-6"
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||
aria-label="Remove image"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepIndicator({ steps, current, onStepClick }) {
|
||||
return (
|
||||
<div className="flex items-start w-full mb-8">
|
||||
@@ -219,8 +185,6 @@ export default function AddNotificationBroadcast() {
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [targetLabel, setTargetLabel] = useState(null);
|
||||
const [imageAsset, setImageAsset] = useState(null);
|
||||
const [imagePickerOpen, setImagePickerOpen] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -238,11 +202,8 @@ export default function AddNotificationBroadcast() {
|
||||
target_id: null,
|
||||
show_in_sticky: false,
|
||||
show_in_notifications: true,
|
||||
link_mode: "info",
|
||||
link_url: "",
|
||||
link_label: "",
|
||||
color: "indigo",
|
||||
image_asset_id: null,
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
},
|
||||
@@ -255,7 +216,6 @@ export default function AddNotificationBroadcast() {
|
||||
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");
|
||||
@@ -270,31 +230,25 @@ export default function AddNotificationBroadcast() {
|
||||
// fields from earlier steps.
|
||||
const goToStep = async (target) => {
|
||||
if (target > 0) {
|
||||
const valid = await trigger(["title", "message"]);
|
||||
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; }
|
||||
}
|
||||
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,
|
||||
...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.show_in_sticky && link_mode === "link") ? normalizeLinkUrl(values.link_url.trim()) : null,
|
||||
link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
|
||||
link_url: values.link_url?.trim() ? normalizeLinkUrl(values.link_url.trim()) : null,
|
||||
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
|
||||
image_asset_id: values.show_in_sticky ? (values.image_asset_id || null) : null,
|
||||
start_date: values.start_date || null,
|
||||
end_date: values.end_date || null,
|
||||
createdBy: user?.user_id ?? null,
|
||||
@@ -330,66 +284,22 @@ export default function AddNotificationBroadcast() {
|
||||
|
||||
{/* ── 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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ── 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: Display ── */}
|
||||
{currentStep === 2 && (
|
||||
<>
|
||||
<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">
|
||||
@@ -399,10 +309,7 @@ export default function AddNotificationBroadcast() {
|
||||
onCheckedChange={(v) => {
|
||||
const checked = v === true;
|
||||
setValue("show_in_sticky", checked, { shouldValidate: true, shouldDirty: true });
|
||||
// Sticky-only alerts 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 });
|
||||
if (checked) setValue("show_in_notifications", false, { shouldValidate: true, shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
||||
@@ -414,18 +321,16 @@ export default function AddNotificationBroadcast() {
|
||||
<Checkbox
|
||||
id="show_in_notifications"
|
||||
checked={showInNotifications === true}
|
||||
disabled={showInSticky === true}
|
||||
onCheckedChange={(v) => setValue("show_in_notifications", v === true, { shouldValidate: true, shouldDirty: 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={showInSticky ? "text-muted-foreground" : "cursor-pointer"}>
|
||||
<Label htmlFor="show_in_notifications" className="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">
|
||||
@@ -476,103 +381,79 @@ export default function AddNotificationBroadcast() {
|
||||
</SectionCard>
|
||||
|
||||
{showInSticky && (
|
||||
<SectionCard title="Layout image" description="Shown alongside the message when this alert is opened from the sticky banner. Optional.">
|
||||
<AlertImagePicker
|
||||
selectedAsset={imageAsset}
|
||||
onPick={() => setImagePickerOpen(true)}
|
||||
onRemove={() => {
|
||||
setImageAsset(null);
|
||||
setValue("image_asset_id", null, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{showInSticky && (
|
||||
<SectionCard title="On Open" description="What happens when someone opens this alert 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.
|
||||
<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 this alert looks like when opened from the sticky banner.">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-muted-foreground">Sticky banner (collapsed):</p>
|
||||
<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>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-muted-foreground">When clicked (full content):</p>
|
||||
<AlertLayoutPreview
|
||||
title={watch("title")}
|
||||
message={watch("message")}
|
||||
imageAsset={imageAsset}
|
||||
linkLabel={linkMode === "link" ? (watch("link_label") || "Open Link") : null}
|
||||
/>
|
||||
</div>
|
||||
<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 3: Review ── */}
|
||||
{currentStep === 3 && (
|
||||
{/* ── 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">
|
||||
@@ -580,10 +461,12 @@ export default function AddNotificationBroadcast() {
|
||||
<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>
|
||||
{showInNotifications && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Message</p>
|
||||
<p className="font-medium whitespace-pre-wrap">{watch("message") || "—"}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -619,29 +502,21 @@ export default function AddNotificationBroadcast() {
|
||||
</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>
|
||||
<>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -689,16 +564,6 @@ export default function AddNotificationBroadcast() {
|
||||
</div>
|
||||
|
||||
{unsavedChangesDialog}
|
||||
|
||||
<AssetPickerSheet
|
||||
open={imagePickerOpen}
|
||||
onOpenChange={setImagePickerOpen}
|
||||
fileType="image"
|
||||
onSelect={(asset) => {
|
||||
setImageAsset(asset);
|
||||
setValue("image_asset_id", String(asset.asset_id), { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user