Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-12 12:39:47 +08:00
parent 71f758fe0b
commit fa92d924f4
50 changed files with 2202 additions and 2623 deletions
@@ -7,7 +7,7 @@ import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import {
House, Plus, Trash2, ImagePlus, MapPin, FileText,
Link2, CalendarClock, Check, ChevronLeft, ChevronRight,
LayoutTemplate, CalendarClock, Check, ChevronLeft, ChevronRight,
} from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
@@ -25,16 +25,18 @@ import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
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 { PlacementSkeleton } from "@/components/generic/PlacementSkeleton";
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(),
@@ -45,6 +47,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),
@@ -64,31 +76,16 @@ const schema = z.object({
}
});
// TODO(ads-6): Rework this wizard to match the target spec:
// Step 1 Placement — choose ONLY Dashboard, Tier Plans, or Course Details
// (depends on ads-1 registry re-categorization).
// Step 2 Content — pick "full image" vs "content + image":
// full image -> image only
// content+img -> badge label, headline, description,
// CTAs, redirect link
// Step 3 Page Builder — only shown when no redirect link was provided;
// builds an internal landing page (title, description,
// body, links, etc.) — new step, doesn't exist yet.
// Step 4 Scheduling & Display — start date, end date, order, and an
// active/draft switch labeled "Draft" when off
// (currently has start/end/order but check the
// on/off switch's Draft/Inactive labeling matches).
// Step 5 Review — display all details.
// ─── Steps ────────────────────────────────────────────────────────────────────
// richOnly steps are skipped entirely for placements whose format isn't a
// RICH_CONTENT_TYPES format (banner/popup/sidebar never had CTAs).
// The Page Builder step only shows up when no redirect_link was given — it's
// the alternative click-through destination (an internally-authored landing
// page) for ads that don't link straight out to a URL.
const ALL_STEPS = [
{ id: "placement", label: "Placement", icon: MapPin, description: "Choose the page and position this ad will appear in." },
{ id: "content", label: "Content", icon: FileText, description: "Headline, description, and badge text for this placement." },
{ id: "image", label: "Image", icon: ImagePlus, description: "Choose an existing asset from Asset Management." },
{ id: "ctas", label: "CTAs", icon: Link2, description: `Up to ${MAX_CTAS} buttons shown on the placement.`, richOnly: true },
{ id: "scheduling", label: "Scheduling & Display", icon: CalendarClock, description: "Optional start/end dates, manual ordering, and the on/off switch." },
{ id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this advertisement." },
{ id: "placement", label: "Placement", icon: MapPin, description: "Choose where this ad appears — Dashboard, Tier Plans, or Course Details." },
{ id: "content", label: "Content", icon: FileText, description: "Full image, or content with badge, headline, description, and CTAs." },
{ id: "pageBuilder", label: "Page Builder", icon: LayoutTemplate, description: "No redirect link? Build an internal landing page for this ad instead.", skippable: true },
{ id: "scheduling", label: "Scheduling & Display", icon: CalendarClock, description: "Start/end dates, display order, and draft/active status." },
{ id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this advertisement." },
];
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -152,49 +149,27 @@ function Stepper({ steps, stepIndex }) {
);
}
// ─── Step: Placement ────────────────────────────────────────────────────────
// ─── Step 1: Placement ──────────────────────────────────────────────────────
function StepPlacement({ selectedPage, setSelectedPage, placement, setValue, positionOptions, errors, format, isBanner, watch }) {
function StepPlacement({ placement, setValue, errors, format, isBanner, watch }) {
return (
<div className="space-y-5">
<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 });
}}
>
<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 })}
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 })}
>
<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>
{placement && (
@@ -231,47 +206,9 @@ function StepPlacement({ selectedPage, setSelectedPage, placement, setValue, pos
);
}
// ─── Step: Content ──────────────────────────────────────────────────────────
// ─── Step 2: Content ────────────────────────────────────────────────────────
function StepContent({ register, errors, showRichContent, description, format }) {
if (!showRichContent) {
return (
<div>
<Label className="mb-1.5 block">Headline (optional)</Label>
<Input placeholder="Internal label for this ad" {...register("headline")} />
</div>
);
}
return (
<div className="space-y-5">
<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>
</div>
);
}
// ─── Step: Image ────────────────────────────────────────────────────────────
function StepImage({ selectedAsset, imageUrl, setPickerOpen }) {
function StepImagePicker({ selectedAsset, imageUrl, setPickerOpen }) {
return selectedAsset ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
@@ -298,71 +235,191 @@ function StepImage({ selectedAsset, imageUrl, setPickerOpen }) {
);
}
// ─── Step: CTAs ─────────────────────────────────────────────────────────────
function StepContent({
register, errors, setValue, watch, description, format,
selectedAsset, imageUrl, setPickerOpen,
ctaFields, appendCta, removeCta,
}) {
const contentMode = watch("content_mode");
function StepCtas({ ctaFields, register, errors, watch, setValue, appendCta, removeCta }) {
return (
<div className="space-y-3">
{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>
<div className="w-[120px]">
<Select
value={watch(`ctas.${index}.variant`) ?? "default"}
onValueChange={(v) => setValue(`ctas.${index}.variant`, v)}
<div className="space-y-5">
<div>
<Label className="mb-1.5 block">Content type</Label>
<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 })}
className={cn(
"rounded-lg border p-3 text-left transition-colors",
contentMode === m.value ? "border-primary bg-primary/5" : "hover:bg-muted/50"
)}
>
<SelectTrigger>
<SelectValue placeholder="Style" />
</SelectTrigger>
<SelectContent>
{CTA_VARIANTS.map((v) => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
<Trash2 className="size-4" />
</Button>
<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>
</div>
<div>
<Label className="mb-1.5 block">Image</Label>
<StepImagePicker selectedAsset={selectedAsset} imageUrl={imageUrl} setPickerOpen={setPickerOpen} />
</div>
{contentMode === "content" && (
<div className="space-y-5">
<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>
<div>
<Label className="mb-1.5 block">Calls to action</Label>
<div className="space-y-3">
{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>
<div className="w-[120px]">
<Select
value={watch(`ctas.${index}.variant`) ?? "default"}
onValueChange={(v) => setValue(`ctas.${index}.variant`, v)}
>
<SelectTrigger>
<SelectValue placeholder="Style" />
</SelectTrigger>
<SelectContent>
{CTA_VARIANTS.map((v) => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))}
</SelectContent>
</Select>
</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 CTA
</Button>
) : (
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
)}
</div>
</div>
</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 CTA
</Button>
) : (
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
)}
<Separator />
<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">
Where clicking the ad itself goes to. Leave blank to build an internal landing page in the next step instead.
</p>
</div>
</div>
);
}
// ─── Step: Scheduling & Display ─────────────────────────────────────────────
// ─── Step 3: Page Builder ───────────────────────────────────────────────────
function StepPageBuilder({ register, linkFields, appendLink, removeLink }) {
return (
<div className="space-y-5">
<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>
</div>
);
}
// ─── Step 4: Scheduling & Display ───────────────────────────────────────────
function StepScheduling({ register, watch, setValue }) {
const isActive = watch("is_active");
return (
<div className="space-y-5">
<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 ?? "", { shouldValidate: 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 ?? "", { shouldValidate: true })}
placeholder="No end date"
/>
</div>
</div>
<Separator />
@@ -372,9 +429,9 @@ function StepScheduling({ register, watch, setValue }) {
<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">{isActive ? "Active" : "Draft"}</Label>
<Switch
checked={watch("is_active")}
checked={isActive}
onCheckedChange={(v) => setValue("is_active", v)}
/>
</div>
@@ -383,7 +440,7 @@ function StepScheduling({ register, watch, setValue }) {
);
}
// ─── Step: Review ───────────────────────────────────────────────────────────
// ─── Step 5: Review ─────────────────────────────────────────────────────────
function SummaryRow({ label, value }) {
if (!value) return null;
@@ -398,6 +455,7 @@ function SummaryRow({ label, value }) {
function StepReview({ data, selectedAsset, imageUrl }) {
const placementMeta = PLACEMENT_MAP[data.placement];
const ctas = (data.ctas ?? []).filter((c) => c.label || c.link);
const hasLandingPage = !data.redirect_link && (data.landing_page?.title || data.landing_page?.body);
return (
<div className="space-y-4">
@@ -407,16 +465,20 @@ function StepReview({ data, selectedAsset, imageUrl }) {
<span className="text-sm font-medium">Placement</span>
{placementMeta && <Badge variant="secondary" className="ml-auto capitalize">{placementMeta.format}</Badge>}
</div>
<SummaryRow label="Page" value={placementMeta?.pageLabel} />
<SummaryRow label="Position" value={placementMeta?.slotLabel} />
<SummaryRow label="Size" value={data.size} />
<SummaryRow label="Page" value={placementMeta?.pageLabel} />
<SummaryRow label="Size" value={data.size} />
</div>
<div className="border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Content</p>
<SummaryRow label="Badge" value={data.badge_label} />
<SummaryRow label="Headline" value={data.headline} />
<SummaryRow label="Description" value={data.description} />
<SummaryRow label="Type" value={data.content_mode === "content" ? "Content + image" : "Full image"} />
{data.content_mode === "content" && (
<>
<SummaryRow label="Badge" value={data.badge_label} />
<SummaryRow label="Headline" value={data.headline} />
<SummaryRow label="Description" value={data.description} />
</>
)}
</div>
<div className="border rounded-lg p-4 space-y-1">
@@ -441,12 +503,26 @@ function StepReview({ data, selectedAsset, imageUrl }) {
</div>
)}
<div className="border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Click-through</p>
{data.redirect_link ? (
<SummaryRow label="Redirect link" value={data.redirect_link} />
) : hasLandingPage ? (
<>
<SummaryRow label="Page title" value={data.landing_page?.title} />
<SummaryRow label="Links" value={(data.landing_page?.links ?? []).filter((l) => l.label || l.link).length || null} />
</>
) : (
<p className="text-sm text-muted-foreground">No redirect link or landing page set — this ad won't link anywhere when clicked.</p>
)}
</div>
<div className="border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Scheduling & display</p>
<SummaryRow label="Start date" value={data.start_date} />
<SummaryRow label="End date" value={data.end_date} />
<SummaryRow label="Order" value={data.order} />
<SummaryRow label="Active" value={data.is_active ? "Yes" : "No"} />
<SummaryRow label="Status" value={data.is_active ? "Active" : "Draft"} />
</div>
</div>
);
@@ -462,7 +538,6 @@ export default function AddAdvertisement() {
const [pickerOpen, setPickerOpen] = useState(false);
const [selectedAsset, setSelectedAsset] = useState(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState(null);
const [selectedPage, setSelectedPage] = useState(null);
const [step, setStep] = useState(0);
const {
@@ -478,11 +553,14 @@ export default function AddAdvertisement() {
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,
@@ -492,23 +570,23 @@ export default function AddAdvertisement() {
});
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
const { fields: linkFields, append: appendLink, remove: removeLink } = useFieldArray({ control, name: "landing_page.links" });
// selectedPage/selectedAsset live outside the form and their setValue()
// calls don't pass shouldDirty, so isDirty alone would miss them.
// selectedAsset lives outside the form and its setValue() call doesn't pass
// shouldDirty, so isDirty alone would miss it.
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(
isDirty || !!selectedAsset || !!selectedPage
isDirty || !!selectedAsset
);
const placement = watch("placement");
const description = watch("description");
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 steps = useMemo(
() => ALL_STEPS.filter((s) => !s.richOnly || showRichContent),
[showRichContent]
() => ALL_STEPS.filter((s) => !s.skippable || !redirectLink?.trim()),
[redirectLink]
);
const stepIndex = Math.min(step, steps.length - 1);
const current = steps[stepIndex];
@@ -523,8 +601,7 @@ export default function AddAdvertisement() {
const handleNext = async () => {
let fields = [];
if (current.id === "placement") fields = ["placement"];
else if (current.id === "content") fields = showRichContent ? ["headline", "description", "badge_label"] : ["headline"];
else if (current.id === "ctas") fields = ["ctas"];
else if (current.id === "content") fields = watch("content_mode") === "content" ? ["headline", "description", "badge_label", "ctas"] : [];
const valid = fields.length ? await trigger(fields) : true;
if (valid) setStep((s) => Math.min(s + 1, steps.length - 1));
@@ -537,6 +614,8 @@ export default function AddAdvertisement() {
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,
@@ -557,7 +636,7 @@ export default function AddAdvertisement() {
<div className="w-full max-w-2xl pb-10 space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight mb-1">New advertisement</h1>
<p className="text-sm text-muted-foreground">Create a banner, popup, or hero placement.</p>
<p className="text-sm text-muted-foreground">Create a hero or banner placement.</p>
</div>
<Stepper steps={steps} stepIndex={stepIndex} />
@@ -570,11 +649,8 @@ export default function AddAdvertisement() {
{current.id === "placement" && (
<StepPlacement
selectedPage={selectedPage}
setSelectedPage={setSelectedPage}
placement={placement}
setValue={setValue}
positionOptions={positionOptions}
errors={errors}
format={format}
isBanner={isBanner}
@@ -585,25 +661,26 @@ export default function AddAdvertisement() {
<StepContent
register={register}
errors={errors}
showRichContent={showRichContent}
setValue={setValue}
watch={watch}
description={description}
format={format}
/>
)}
{current.id === "image" && (
<StepImage selectedAsset={selectedAsset} imageUrl={imagePreviewUrl} setPickerOpen={setPickerOpen} />
)}
{current.id === "ctas" && (
<StepCtas
selectedAsset={selectedAsset}
imageUrl={imagePreviewUrl}
setPickerOpen={setPickerOpen}
ctaFields={ctaFields}
register={register}
errors={errors}
watch={watch}
setValue={setValue}
appendCta={appendCta}
removeCta={removeCta}
/>
)}
{current.id === "pageBuilder" && (
<StepPageBuilder
register={register}
linkFields={linkFields}
appendLink={appendLink}
removeLink={removeLink}
/>
)}
{current.id === "scheduling" && (
<StepScheduling register={register} watch={watch} setValue={setValue} />
)}
@@ -18,37 +18,40 @@ import {
} from "@/components/ui/alert-dialog";
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
import { PLACEMENT_MAP } from "@/data/placement.data";
import { TablePagination } from "@/components/generic/Table/TablePagination";
const PAGE_SIZE = 24;
export default function AdvertisementList() {
const navigate = useNavigate();
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement } = useAdvertisements();
const [typeFilter, setTypeFilter] = useState("all");
const [placementFilter, setPlacementFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
// TODO(ads-3): Filters are not actually filtering — the "status" filter in
// particular compares against the stored `status` column, but status is only
// recomputed on read (see deriveStatus() in
// controllers/admin/advertisements.controller.js) and never persisted back
// to the DB. An ad that lapsed to "expired" still has status="active" in
// the row, so filtering by status here misses/matches the wrong rows.
// Needs either persisting the derived status on write/read, or filtering
// server-side using the same derivation logic. Also verify type/placement
// filters actually round-trip once ads-1/ads-2 land.
useEffect(() => {
const buildFilters = () => {
const filters = [];
if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter });
if (placementFilter !== "all") filters.push({ field: "placement", value: placementFilter });
if (statusFilter !== "all") filters.push({ field: "status", value: statusFilter });
if (search.trim()) filters.push({ field: "headline", op: "ilike", value: search.trim() });
if (typeFilter !== "all") filters.push({ id: "type", value: typeFilter });
if (statusFilter !== "all") filters.push({ id: "status", value: statusFilter });
if (search.trim()) filters.push({ id: "headline", value: search.trim() });
return filters;
};
fetchAdvertisements({ page: 1, limit: 24, filters });
// Single source of truth for fetching — filter setters below always pair
// their state update with setPage(1) in the same handler so this only
// ever fires once per change (no separate "reset page" effect racing it).
useEffect(() => {
fetchAdvertisements({ page, limit: PAGE_SIZE, filters: buildFilters() });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typeFilter, placementFilter, statusFilter, search]);
}, [typeFilter, statusFilter, search, page]);
const handleTypeFilter = (v) => { setTypeFilter(v); setPage(1); };
const handleStatusFilter = (v) => { setStatusFilter(v); setPage(1); };
const runSearch = () => { setSearch(searchInput); setPage(1); };
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -77,7 +80,7 @@ export default function AdvertisementList() {
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Advertisements</h1>
<p className="text-sm text-muted-foreground">Manage public-facing banners, popups, and promotional placements</p>
<p className="text-sm text-muted-foreground">Manage public-facing hero and banner placements</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => navigate("/admin/advertisements/archived")}>
@@ -101,7 +104,7 @@ export default function AdvertisementList() {
{/* ── Filters ────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 flex-wrap">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<Select value={typeFilter} onValueChange={handleTypeFilter}>
<SelectTrigger className="w-[150px] bg-background">
<SelectValue placeholder="All types" />
</SelectTrigger>
@@ -113,20 +116,7 @@ export default function AdvertisementList() {
</SelectContent>
</Select>
{/* TODO(ads-2): Remove this "All placements" dropdown entirely. */}
<Select value={placementFilter} onValueChange={setPlacementFilter}>
<SelectTrigger className="w-[220px] bg-background">
<SelectValue placeholder="All placements" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All placements</SelectItem>
{PLACEMENTS.map((p) => (
<SelectItem key={p.key} value={p.key}>{p.pageLabel} — {p.slotLabel}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<Select value={statusFilter} onValueChange={handleStatusFilter}>
<SelectTrigger className="w-[150px] bg-background">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
@@ -138,31 +128,23 @@ export default function AdvertisementList() {
</SelectContent>
</Select>
{/* TODO(ads-5): Verify this already satisfies the spec — search only
fires on button click / Enter (`search` state, not `searchInput`,
drives the fetch effect above), typing alone does not refetch.
Looks done already; double-check then mark complete. */}
<div className="flex items-center gap-2 flex-1 min-w-[160px]">
<div className="relative flex-1">
<div className="relative w-64">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search advertisements..."
className="pl-8 bg-background"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") setSearch(searchInput); }}
onKeyDown={(e) => { if (e.key === "Enter") runSearch(); }}
/>
</div>
<Button variant="outline" size="icon" className="shrink-0 bg-background" onClick={() => setSearch(searchInput)} aria-label="Search">
<Button variant="outline" size="icon" className="shrink-0 bg-background" onClick={runSearch} aria-label="Search">
<Search className="size-4" />
</Button>
</div>
</div>
{/* TODO(ads-8): Add pagination controls for this grid — currently
always fetches page 1 / limit 24 with no way to reach further
pages (see `pagination` from useAdvertisements, already returned
by the API but unused here). */}
{/* ── Grid ───────────────────────────────────────────────────── */}
{loading ? (
<div className="flex items-center justify-center py-20">
@@ -171,17 +153,28 @@ export default function AdvertisementList() {
) : advertisements.length === 0 ? (
<EmptyState onCreate={() => navigate("/admin/advertisements/add")} />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{advertisements.map((ad) => (
<AdvertisementCard
key={ad.advertisement_id}
ad={ad}
onView={() => navigate(`/admin/advertisements/${ad.advertisement_id}/view`)}
onEdit={() => navigate(`/admin/advertisements/${ad.advertisement_id}/edit`)}
onArchive={() => handleArchive(ad.advertisement_id)}
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{advertisements.map((ad) => (
<AdvertisementCard
key={ad.advertisement_id}
ad={ad}
onView={() => navigate(`/admin/advertisements/${ad.advertisement_id}/view`)}
onEdit={() => navigate(`/admin/advertisements/${ad.advertisement_id}/edit`)}
onArchive={() => handleArchive(ad.advertisement_id)}
/>
))}
</div>
<div className="bg-background rounded-lg border">
<TablePagination
pagination={pagination}
onPageChange={setPage}
rowCount={advertisements.length}
recordLabel="advertisement"
/>
))}
</div>
</div>
</>
)}
</div>
</div>
@@ -298,7 +291,7 @@ function EmptyState({ onCreate }) {
<Megaphone className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">No advertisements yet</p>
<p className="text-sm text-muted-foreground">Create your first banner, popup, or hero placement.</p>
<p className="text-sm text-muted-foreground">Create your first hero or banner placement.</p>
</div>
<Button onClick={onCreate}>
<Plus className="size-4" />
@@ -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>
);
}
}