mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
517 lines
29 KiB
React
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>
|
|
);
|
|
}
|