mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -11,6 +11,7 @@ import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
import { resolveAssetSrc } from "@/utils/media.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";
|
||||
@@ -18,16 +19,19 @@ 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 { RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
|
||||
import { AD_PAGES, PLACEMENT_MAP } from "@/data/placement.data";
|
||||
import { MAX_CTAS, 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_label: z.string().optional(),
|
||||
headline: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
@@ -38,6 +42,16 @@ const schema = z.object({
|
||||
link: z.string().min(1, "Link is required."),
|
||||
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(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
@@ -78,14 +92,6 @@ function SectionCard({ title, description, children }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Convert ISO datetime to value usable by <input type="datetime-local">
|
||||
function toLocalInputValue(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const BANNER_SIZES = [
|
||||
{ value: "sm", label: "Small" },
|
||||
{ value: "md", label: "Medium" },
|
||||
@@ -109,13 +115,9 @@ export default function EditAdvertisement() {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState(null);
|
||||
const [selectedPage, setSelectedPage] = useState(null);
|
||||
// Gates the form's first paint until the fetched advertisement has been
|
||||
// applied via reset() + setSelectedPage(). Without this, the Page/Position
|
||||
// selects briefly mount with their empty defaultValues (no page selected,
|
||||
// no position options yet) before the fetch resolves — that first paint is
|
||||
// enough for the position <Select> to lose track of the eventual value,
|
||||
// leaving it visually unselected even after reset() runs.
|
||||
// applied via reset(). Without this, fields briefly mount with their empty
|
||||
// defaultValues before the fetch resolves.
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const {
|
||||
@@ -130,11 +132,14 @@ export default function EditAdvertisement() {
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
placement: undefined,
|
||||
content_mode: "image",
|
||||
badge_label: "",
|
||||
headline: "",
|
||||
description: "",
|
||||
image_asset_id: null,
|
||||
ctas: [],
|
||||
redirect_link: "",
|
||||
landing_page: { title: "", description: "", body: "", links: [] },
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
order: 0,
|
||||
@@ -144,15 +149,16 @@ export default function EditAdvertisement() {
|
||||
});
|
||||
|
||||
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 redirectLink = watch("redirect_link");
|
||||
const format = PLACEMENT_MAP[placement]?.format;
|
||||
const showRichContent = RICH_CONTENT_TYPES.includes(format);
|
||||
const isBanner = format === "banner";
|
||||
const positionOptions = AD_PAGES.find((p) => p.page === selectedPage)?.placements ?? [];
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
@@ -174,10 +180,10 @@ export default function EditAdvertisement() {
|
||||
// — no separate token round-trip needed.
|
||||
setImagePreviewUrl(resolveAssetSrc(ad.image));
|
||||
}
|
||||
setSelectedPage(PLACEMENT_MAP[ad.placement]?.page ?? null);
|
||||
|
||||
reset({
|
||||
placement: ad.placement ?? undefined,
|
||||
content_mode: ad.content_mode ?? "image",
|
||||
badge_label: ad.badge_label ?? "",
|
||||
headline: ad.headline ?? "",
|
||||
description: ad.description ?? "",
|
||||
@@ -187,8 +193,15 @@ export default function EditAdvertisement() {
|
||||
link: c.link ?? "",
|
||||
variant: c.variant ?? (i === 0 ? "default" : "outline"),
|
||||
})),
|
||||
start_date: toLocalInputValue(ad.start_date),
|
||||
end_date: toLocalInputValue(ad.end_date),
|
||||
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 ?? "",
|
||||
order: ad.order ?? 0,
|
||||
is_active: ad.is_active ?? true,
|
||||
size: ad.size ?? null,
|
||||
@@ -205,6 +218,8 @@ export default function EditAdvertisement() {
|
||||
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,
|
||||
size: PLACEMENT_MAP[values.placement]?.format === "banner" ? (values.size || "md") : null,
|
||||
@@ -234,49 +249,27 @@ export default function EditAdvertisement() {
|
||||
|
||||
<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 banner, popup, or hero placement.</p>
|
||||
<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 className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Page</Label>
|
||||
<Select
|
||||
value={selectedPage ?? undefined}
|
||||
onValueChange={(v) => {
|
||||
setSelectedPage(v);
|
||||
setValue("placement", "", { shouldValidate: false, shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a page" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AD_PAGES.map((p) => (
|
||||
<SelectItem key={p.page} value={p.page}>{p.pageLabel}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Position</Label>
|
||||
<Select
|
||||
value={placement || undefined}
|
||||
onValueChange={(v) => setValue("placement", v, { shouldValidate: true, shouldDirty: true })}
|
||||
disabled={!selectedPage}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={selectedPage ? "Select a position" : "Select a page first"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{positionOptions.map((p) => (
|
||||
<SelectItem key={p.key} value={p.key}>{p.slotLabel}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.placement?.message} />
|
||||
</div>
|
||||
<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 && (
|
||||
@@ -305,39 +298,51 @@ export default function EditAdvertisement() {
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{showRichContent && (
|
||||
<SectionCard title="Content" description="Headline, description, and badge text shown on the hero placement.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Badge label</Label>
|
||||
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
|
||||
</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")} />
|
||||
{format === "hero" && (
|
||||
<div className="flex justify-between items-start mt-1">
|
||||
<FieldError message={errors.description?.message} />
|
||||
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{description?.length ?? 0}/200
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
<SectionCard title="Content" description="Full image, or content with badge, headline, description, and CTAs.">
|
||||
<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 })}
|
||||
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" ? "Just an image, no text overlay." : "Badge, headline, description, and CTAs."}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!showRichContent && (
|
||||
<SectionCard title="Content" description="Optional headline for this placement.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Headline (optional)</Label>
|
||||
<Input placeholder="Internal label for this ad" {...register("headline")} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
{contentMode === "content" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Badge label</Label>
|
||||
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
|
||||
</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")} />
|
||||
{format === "hero" && (
|
||||
<div className="flex justify-between items-start mt-1">
|
||||
<FieldError message={errors.description?.message} />
|
||||
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{description?.length ?? 0}/200
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Image" description="Choose an existing asset from Asset Management.">
|
||||
{selectedAsset ? (
|
||||
@@ -366,7 +371,7 @@ export default function EditAdvertisement() {
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{showRichContent && (
|
||||
{contentMode === "content" && (
|
||||
<SectionCard
|
||||
title="Calls to action"
|
||||
description={`Up to ${MAX_CTAS} buttons shown on the placement. Each button can be styled as Primary or Outline.`}
|
||||
@@ -417,27 +422,81 @@ export default function EditAdvertisement() {
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard title="Click-through" description="Where clicking the ad itself goes to.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Redirect link</Label>
|
||||
<Input placeholder="e.g. /courses or https://example.com" {...register("redirect_link")} />
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
Leave blank to use the internal landing page below instead.
|
||||
</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>
|
||||
<Input type="datetime-local" {...register("start_date")} />
|
||||
<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>
|
||||
<Input type="datetime-local" {...register("end_date")} />
|
||||
<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="Manual ordering and on/off switch.">
|
||||
<SectionCard title="Display" description="Manual ordering and draft/active switch.">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Order</Label>
|
||||
<Input type="number" min={0} {...register("order")} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between border rounded-md px-3 h-9">
|
||||
<Label className="text-sm">Active</Label>
|
||||
<Label className="text-sm">{watch("is_active") ? "Active" : "Draft"}</Label>
|
||||
<Switch
|
||||
checked={watch("is_active")}
|
||||
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
|
||||
@@ -473,4 +532,4 @@ export default function EditAdvertisement() {
|
||||
{unsavedChangesDialog}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user