Files
starr-philproperties/src/modules/admin/pages/advertisements/EditAdvertisement.jsx
T
2026-08-03 22:48:16 +08:00

517 lines
29 KiB
React

// 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 <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 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: <House className="size-4" />, 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 (
<section className="bg-muted h-full">
<div className="flex items-center justify-center py-32">
<Spinner className="size-6" />
</div>
</section>
);
}
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">Edit advertisement</h1>
<p className="text-sm text-muted-foreground mb-6">Update this hero or banner placement.</p>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Placement" description="Where this advertisement will appear.">
<div>
<Label className="mb-1.5 block">Placement</Label>
<Select
value={placement || undefined}
onValueChange={(v) => setValue("placement", v, { shouldValidate: true, shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select a placement" />
</SelectTrigger>
<SelectContent>
{PLACEMENTS.map((p) => (
<SelectItem key={p.key} value={p.key}>{p.pageLabel}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.placement?.message} />
</div>
{format && (
<p className="text-xs text-muted-foreground">
Format: <span className="font-medium text-foreground capitalize">{format}</span> — determined by the placement above.
</p>
)}
</SectionCard>
<SectionCard title="Type" description="Image Only, or Text with Image with badges, headline, description, and links.">
<div className="grid grid-cols-2 gap-3">
{CONTENT_MODES.map((m) => (
<button
key={m.value}
type="button"
onClick={() => {
setValue("content_mode", m.value, { shouldValidate: true, shouldDirty: true });
// redirect_link only applies to Image Only ads (Text with
// Image ads click through via their own CTA links instead)
if (m.value === "content") setValue("redirect_link", "", { shouldValidate: true, shouldDirty: true });
}}
className={cn(
"rounded-lg border p-3 text-left transition-colors",
contentMode === m.value ? "border-primary bg-primary/5" : "hover:bg-muted/50"
)}
>
<p className="text-sm font-medium">{m.label}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{m.value === "image" ? "No text." : "Left-aligned text, image on the right."}
</p>
</button>
))}
</div>
{contentMode === "image" && (
<div>
<Label className="mb-1.5 block">Title</Label>
<Input placeholder="e.g. Summer Enrollment Banner" {...register("headline")} />
<p className="text-xs text-muted-foreground mt-1.5">
Internal label shown in the Ads list — not displayed on the ad itself.
</p>
</div>
)}
{contentMode === "content" && (
<>
<div>
<Label className="mb-1.5 block">Badge labels</Label>
<div className="space-y-2">
{badgeLabelFields.map((_, index) => (
<div key={index} className="flex gap-2 items-start">
<div className="flex-1">
<Input placeholder="e.g. Ad" {...register(`badge_labels.${index}`)} />
<FieldError message={errors.badge_labels?.[index]?.message} />
</div>
<Button type="button" variant="ghost" size="icon" onClick={() => removeBadgeLabel(index)} aria-label="Remove">
<Trash2 className="size-4" />
</Button>
</div>
))}
{badgeLabelFields.length < MAX_BADGE_LABELS ? (
<Button type="button" variant="outline" size="sm" onClick={appendBadgeLabel}>
<Plus className="size-3.5" />
Add badge label
</Button>
) : (
<p className="text-xs text-muted-foreground">Maximum of {MAX_BADGE_LABELS} badges reached.</p>
)}
</div>
</div>
<div>
<Label className="mb-1.5 block">Headline</Label>
<Input placeholder="e.g. Discover the World Top Designers" {...register("headline")} />
</div>
<div>
<Label className="mb-1.5 block">Description</Label>
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
<div className="flex justify-between items-start mt-1">
<FieldError message={errors.description?.message} />
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 100 ? "text-destructive" : "text-muted-foreground"}`}>
{description?.length ?? 0}/100
</span>
</div>
</div>
</>
)}
</SectionCard>
<SectionCard title="Image" description="Choose an existing asset from Asset Management.">
{selectedAsset ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
onClick={() => setPickerOpen(true)}
>
<img
src={imagePreviewUrl || resolveAssetSrc(selectedAsset)}
alt={selectedAsset.display_name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<span className="text-white text-sm opacity-0 group-hover:opacity-100">Change image</span>
</div>
</div>
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full h-36 rounded-lg border border-dashed flex flex-col items-center justify-center gap-2 text-muted-foreground hover:bg-muted/50 transition-colors"
>
<ImagePlus className="size-5" />
<span className="text-sm">Select an image</span>
</button>
)}
</SectionCard>
{contentMode === "content" && (
<SectionCard
title="Links"
description={`Up to ${MAX_CTAS} buttons shown on the placement. The first is styled Primary, the second Outline.`}
>
{ctaFields.map((field, index) => (
<div key={field.id} className="flex gap-2 items-start">
<div className="flex-1">
<Input placeholder="Label (e.g. Explore)" {...register(`ctas.${index}.label`)} />
<FieldError message={errors.ctas?.[index]?.label?.message} />
</div>
<div className="flex-1">
<Input placeholder="Link (e.g. /courses)" {...register(`ctas.${index}.link`)} />
<FieldError message={errors.ctas?.[index]?.link?.message} />
</div>
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
<Trash2 className="size-4" />
</Button>
</div>
))}
{ctaFields.length < MAX_CTAS ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
>
<Plus className="size-3.5" />
Add link
</Button>
) : (
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
)}
</SectionCard>
)}
{contentMode === "image" && (
<SectionCard title="Click-through" description="Where clicking the image goes to.">
<div>
<Label className="mb-1.5 block">Link</Label>
<Input placeholder="e.g. /courses or https://example.com" {...register("redirect_link")} />
<FieldError message={errors.redirect_link?.message} />
<p className="text-xs text-muted-foreground mt-1.5">
Where clicking the image goes to.
</p>
</div>
{!redirectLink?.trim() && (
<>
<Separator />
<div>
<Label className="mb-1.5 block">Page title</Label>
<Input placeholder="e.g. Why upgrade to Pro" {...register("landing_page.title")} />
</div>
<div>
<Label className="mb-1.5 block">Page description</Label>
<Textarea rows={2} placeholder="Short summary shown under the title" {...register("landing_page.description")} />
</div>
<div>
<Label className="mb-1.5 block">Body</Label>
<Textarea rows={6} placeholder="Main page content" {...register("landing_page.body")} />
</div>
<div>
<Label className="mb-1.5 block">Links</Label>
<div className="space-y-2">
{linkFields.map((field, index) => (
<div key={field.id} className="flex gap-2 items-start">
<Input placeholder="Label" className="flex-1" {...register(`landing_page.links.${index}.label`)} />
<Input placeholder="URL or path" className="flex-1" {...register(`landing_page.links.${index}.link`)} />
<Button type="button" variant="ghost" size="icon" onClick={() => removeLink(index)} aria-label="Remove">
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={() => appendLink({ label: "", link: "" })}>
<Plus className="size-3.5" />
Add link
</Button>
</div>
</div>
</>
)}
</SectionCard>
)}
<SectionCard title="Scheduling" description="Optional start and end dates for this placement.">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Start date</Label>
<DateTimePicker
value={watch("start_date") || null}
onChange={(iso) => setValue("start_date", iso ?? "", { shouldDirty: true })}
placeholder="No start date"
/>
</div>
<div>
<Label className="mb-1.5 block">End date</Label>
<DateTimePicker
value={watch("end_date") || null}
onChange={(iso) => setValue("end_date", iso ?? "", { shouldDirty: true })}
placeholder="No end date"
/>
</div>
</div>
</SectionCard>
<SectionCard title="Display" description="Draft/active switch.">
<div>
<Label className="mb-1.5 block">Status</Label>
<div className="flex items-center justify-between border rounded-md px-3 h-9">
<span className="text-sm">{watch("is_active") ? "Active" : "Draft"}</span>
<Switch
checked={watch("is_active")}
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
/>
</div>
</div>
</SectionCard>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate("/admin/advertisements")} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save changes
</Button>
</div>
</form>
</div>
</div>
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset, resolvedUrl) => {
setSelectedAsset(asset);
setImagePreviewUrl(resolvedUrl ?? null);
setValue("image_asset_id", asset.asset_id, { shouldValidate: true, shouldDirty: true });
}}
/>
{unsavedChangesDialog}
</section>
);
}