// modules/admin/pages/advertisements/EditAdvertisement.jsx import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useForm, useFieldArray } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { House, Plus, Trash2, ImagePlus } from "lucide-react"; import { useAdvertisements } from "@/contexts/AdminAdvertisementContext"; import { useAuth } from "@/contexts/AuthContext"; import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; import { resolveAssetSrc } from "@/utils/media.util"; import { isValidLink, LINK_ERROR } from "@/utils/link.util"; import { cn } from "@/lib/utils"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; 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 { Switch } from "@/components/ui/switch"; import { Separator } from "@/components/ui/separator"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { DateTimePicker } from "@/components/ui/date-time-picker"; import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet"; import { MAX_CTAS, MAX_BADGE_LABELS, CONTENT_MODES } from "@/data/advertisement.data"; import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data"; // ─── Schema ───────────────────────────────────────────────────────────────── const schema = z.object({ placement: z.string().min(1, "Placement is required."), content_mode: z.enum(["image", "content"]).default("image"), badge_labels: z.array(z.string().min(1)).max(MAX_BADGE_LABELS, `A maximum of ${MAX_BADGE_LABELS} badges is allowed.`).default([]), headline: z.string().optional(), description: z.string().max(100, "Description must be 100 characters or fewer.").optional(), image_asset_id: z.union([z.string(), z.number()]).nullable().optional(), // Hard cap of 2 CTAs per advertisement (max() backs up the UI-level append guard) ctas: z.array(z.object({ label: z.string().min(1, "Label is required."), link: z.string().min(1, "Link is required.").refine(isValidLink, LINK_ERROR), variant: z.enum(["default", "outline"]).default("default"), })).max(MAX_CTAS, `A maximum of ${MAX_CTAS} buttons is allowed.`).default([]), redirect_link: z.string().optional(), landing_page: z.object({ title: z.string().optional(), description: z.string().optional(), body: z.string().optional(), links: z.array(z.object({ label: z.string().optional(), link: z.string().optional(), })).default([]), }).default({}), start_date: z.string().optional(), end_date: z.string().optional(), is_active: z.boolean().default(true), }).superRefine((data, ctx) => { // Image Only ads have no other click-through — the Link is their only // destination, so it's required (Text with Image ads click through via // their own CTA links instead). if (data.content_mode !== "image") return; if (!data.redirect_link?.trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["redirect_link"], message: "Link is required." }); } else if (!isValidLink(data.redirect_link)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["redirect_link"], message: LINK_ERROR }); } }); // ─── Helpers ──────────────────────────────────────────────────────────────── function FieldError({ message }) { if (!message) return null; return

{message}

; } function SectionCard({ title, description, children }) { return (
{(title || description) && (
{title &&

{title}

} {description &&

{description}

}
)} {children}
); } // ─── Page ─────────────────────────────────────────────────────────────────── export default function EditAdvertisement() { const navigate = useNavigate(); const { advertisementId } = useParams(); const { fetchAdvertisement, updateAdvertisement, loading } = useAdvertisements(); const { user } = useAuth(); const [pickerOpen, setPickerOpen] = useState(false); const [selectedAsset, setSelectedAsset] = useState(null); const [imagePreviewUrl, setImagePreviewUrl] = useState(null); // Gates the form's first paint until the fetched advertisement has been // applied via reset(). Without this, fields briefly mount with their empty // defaultValues before the fetch resolves. const [ready, setReady] = useState(false); const { register, handleSubmit, control, reset, watch, setValue, formState: { errors, isDirty }, } = useForm({ resolver: zodResolver(schema), defaultValues: { placement: undefined, content_mode: "image", badge_labels: [], headline: "", description: "", image_asset_id: null, ctas: [], redirect_link: "", landing_page: { title: "", description: "", body: "", links: [] }, start_date: "", end_date: "", is_active: true, }, }); const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" }); const { fields: linkFields, append: appendLink, remove: removeLink } = useFieldArray({ control, name: "landing_page.links" }); const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty); const placement = watch("placement"); const description = watch("description"); const contentMode = watch("content_mode"); const badgeLabelFields = watch("badge_labels") ?? []; const appendBadgeLabel = () => setValue("badge_labels", [...badgeLabelFields, ""], { shouldValidate: true, shouldDirty: true }); const removeBadgeLabel = (index) => setValue("badge_labels", badgeLabelFields.filter((_, i) => i !== index), { shouldValidate: true, shouldDirty: true }); const redirectLink = watch("redirect_link"); const format = PLACEMENT_MAP[placement]?.format; const breadcrumbItems = [ { label: "Home", icon: , to: "/admin" }, { label: "Ads", to: "/admin/advertisements" }, { label: "Edit" }, ]; // ─── Load existing advertisement data ──────────────────────────────────── useEffect(() => { (async () => { const res = await fetchAdvertisement(advertisementId); const ad = res?.data?.data ?? null; if (!ad) return; if (ad.image) { setSelectedAsset(ad.image); // ad.image already carries a stream_token for S3-backed assets // (minted server-side in controllers/admin/advertisements.controller.js) // — no separate token round-trip needed. setImagePreviewUrl(resolveAssetSrc(ad.image)); } reset({ placement: ad.placement ?? undefined, content_mode: ad.content_mode ?? "image", badge_labels: ad.badge_labels ?? [], headline: ad.headline ?? "", description: ad.description ?? "", image_asset_id: ad.image?.asset_id ?? null, ctas: (ad.ctas ?? []).map((c, i) => ({ label: c.label ?? "", link: c.link ?? "", variant: c.variant ?? (i === 0 ? "default" : "outline"), })), redirect_link: ad.redirect_link ?? "", landing_page: { title: ad.landing_page?.title ?? "", description: ad.landing_page?.description ?? "", body: ad.landing_page?.body ?? "", links: (ad.landing_page?.links ?? []).map((l) => ({ label: l.label ?? "", link: l.link ?? "" })), }, start_date: ad.start_date ?? "", end_date: ad.end_date ?? "", is_active: ad.is_active ?? true, }); setReady(true); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [advertisementId]); const onSubmit = async (values) => { if (!isDirty) { bypassOnce(); return navigate("/admin/advertisements"); } const payload = { ...values, image_asset_id: values.image_asset_id || null, redirect_link: values.redirect_link || null, landing_page: values.redirect_link ? null : values.landing_page, start_date: values.start_date || null, end_date: values.end_date || null, updatedBy: user?.user_id ?? null, }; const res = await updateAdvertisement(advertisementId, payload); if (res) { bypassOnce(); navigate("/admin/advertisements"); } }; if (!ready) { return (
); } return (

Edit advertisement

Update this hero or banner placement.

{format && (

Format: {format} — determined by the placement above.

)}
{CONTENT_MODES.map((m) => ( ))}
{contentMode === "image" && (

Internal label shown in the Ads list — not displayed on the ad itself.

)} {contentMode === "content" && ( <>
{badgeLabelFields.map((_, index) => (
))} {badgeLabelFields.length < MAX_BADGE_LABELS ? ( ) : (

Maximum of {MAX_BADGE_LABELS} badges reached.

)}