add: more commits

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-03 16:21:27 +08:00
parent 17326b2c2e
commit 7e964f2432
112 changed files with 9160 additions and 3461 deletions
@@ -1,14 +1,19 @@
// modules/admin/pages/advertisements/AddAdvertisement.jsx
import { useState } from "react";
import { useMemo, useState } from "react";
import { useNavigate } 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 {
House, Plus, Trash2, ImagePlus, MapPin, FileText,
Link2, CalendarClock, Check, ChevronLeft, ChevronRight,
} from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { useAuth } from "@/contexts/AuthContext";
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";
@@ -16,15 +21,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 { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { PlacementSkeleton } from "@/components/generic/PlacementSkeleton";
import { ADVERTISEMENT_TYPES, RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
import { RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
import { AD_PAGES, PLACEMENT_MAP } from "@/data/placement.data";
// ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({
type: z.enum(["hero", "banner", "popup", "sidebar"], { required_error: "Type is required." }),
placement: z.string().min(1, "Placement is required."),
badge_label: z.string().optional(),
headline: z.string().optional(),
description: z.string().optional(),
@@ -39,9 +48,10 @@ const schema = z.object({
end_date: z.string().optional(),
order: z.coerce.number().min(0).default(0),
is_active: z.boolean().default(true),
size: z.enum(["sm", "md", "lg"]).nullable().optional(),
size: z.enum(["sm", "md", "lg", "xl"]).nullable().optional(),
}).superRefine((data, ctx) => {
if (data.type === "hero" && (data.description?.length ?? 0) > 200) {
const format = PLACEMENT_MAP[data.placement]?.format;
if (format === "hero" && (data.description?.length ?? 0) > 200) {
ctx.addIssue({
code: z.ZodIssueCode.too_big,
maximum: 200,
@@ -53,6 +63,18 @@ const schema = z.object({
}
});
// ─── Steps ────────────────────────────────────────────────────────────────────
// richOnly steps are skipped entirely for placements whose format isn't a
// RICH_CONTENT_TYPES format (banner/popup/sidebar never had CTAs).
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." },
];
// ─── Helpers ────────────────────────────────────────────────────────────────
function FieldError({ message }) {
@@ -60,24 +82,11 @@ function FieldError({ message }) {
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>
);
}
const BANNER_SIZES = [
{ value: "sm", label: "Small" },
{ value: "md", label: "Medium" },
{ value: "lg", label: "Large" },
{ value: "xl", label: "Extra Large" },
];
const CTA_VARIANTS = [
@@ -85,6 +94,348 @@ const CTA_VARIANTS = [
{ value: "outline", label: "Outline" },
];
// ─── Stepper header ─────────────────────────────────────────────────────────
function Stepper({ steps, stepIndex }) {
return (
<div className="flex items-center gap-0">
{steps.map((s, i) => {
const Icon = s.icon;
const isActive = stepIndex === i;
const isDone = stepIndex > i;
return (
<div key={s.id} className="flex items-center flex-1 last:flex-none">
<div className="flex flex-col items-center gap-1">
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center border transition-colors shrink-0",
isDone && "bg-emerald-600 border-emerald-600 text-white",
isActive && "border-primary bg-primary text-primary-foreground",
!isActive && !isDone && "border-border bg-background text-muted-foreground"
)}>
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
</div>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap hidden sm:block",
isActive ? "text-foreground" : "text-muted-foreground",
isDone ? "text-emerald-600" : ""
)}>
{s.label}
</span>
</div>
{i < steps.length - 1 && (
<div className={cn(
"flex-1 h-px mx-2 mb-4 transition-colors",
stepIndex > i ? "bg-emerald-600" : "bg-border"
)} />
)}
</div>
);
})}
</div>
);
}
// ─── Step: Placement ────────────────────────────────────────────────────────
function StepPlacement({ selectedPage, setSelectedPage, placement, setValue, positionOptions, 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>
{placement && (
<div>
<p className="text-xs text-muted-foreground mb-2">
Preview — the highlighted area is roughly where this ad will appear.
</p>
<PlacementSkeleton placement={placement} />
<p className="text-xs text-muted-foreground mt-2">
Format: <span className="font-medium text-foreground capitalize">{format}</span> — determined by the placement above.
</p>
</div>
)}
{isBanner && (
<div>
<Label className="mb-1.5 block">Size</Label>
<Select value={watch("size") ?? "md"} onValueChange={(v) => setValue("size", v)}>
<SelectTrigger>
<SelectValue placeholder="Select a size" />
</SelectTrigger>
<SelectContent>
{BANNER_SIZES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1.5">
Controls the banner's height. Width always stretches full-width.
</p>
</div>
)}
</div>
);
}
// ─── Step: 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 }) {
return selectedAsset ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
onClick={() => setPickerOpen(true)}
>
<img
src={imageUrl || 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>
);
}
// ─── Step: CTAs ─────────────────────────────────────────────────────────────
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)}
>
<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>
);
}
// ─── Step: Scheduling & Display ─────────────────────────────────────────────
function StepScheduling({ register, watch, setValue }) {
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")} />
</div>
<div>
<Label className="mb-1.5 block">End date</Label>
<Input type="datetime-local" {...register("end_date")} />
</div>
</div>
<Separator />
<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>
<Switch
checked={watch("is_active")}
onCheckedChange={(v) => setValue("is_active", v)}
/>
</div>
</div>
</div>
);
}
// ─── Step: Review ───────────────────────────────────────────────────────────
function SummaryRow({ label, value }) {
if (!value) return null;
return (
<div className="flex justify-between py-1.5 text-sm gap-4">
<span className="text-muted-foreground min-w-[110px] shrink-0">{label}</span>
<span className="text-foreground text-right break-words">{value}</span>
</div>
);
}
function StepReview({ data, selectedAsset, imageUrl }) {
const placementMeta = PLACEMENT_MAP[data.placement];
const ctas = (data.ctas ?? []).filter((c) => c.label || c.link);
return (
<div className="space-y-4">
<div className="border rounded-lg p-4 space-y-1">
<div className="flex items-center gap-2 mb-2">
<MapPin className="h-4 w-4 text-muted-foreground" />
<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} />
</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} />
</div>
<div className="border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Image</p>
{selectedAsset ? (
<img
src={imageUrl || resolveAssetSrc(selectedAsset)}
alt={selectedAsset.display_name}
className="h-24 rounded border object-cover"
/>
) : (
<p className="text-sm text-muted-foreground">No image selected.</p>
)}
</div>
{ctas.length > 0 && (
<div className="border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Calls to action</p>
{ctas.map((c, i) => (
<SummaryRow key={i} label={c.label || "—"} value={c.link} />
))}
</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"} />
</div>
</div>
);
}
// ─── Page ───────────────────────────────────────────────────────────────────
export default function AddAdvertisement() {
@@ -94,18 +445,23 @@ 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 {
register,
handleSubmit,
control,
trigger,
getValues,
watch,
setValue,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
type: undefined,
placement: undefined,
badge_label: "",
headline: "",
description: "",
@@ -121,10 +477,19 @@ export default function AddAdvertisement() {
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
const type = watch("type");
const placement = watch("placement");
const description = watch("description");
const showRichContent = RICH_CONTENT_TYPES.includes(type);
const isBanner = type === "banner";
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]
);
const stepIndex = Math.min(step, steps.length - 1);
const current = steps[stepIndex];
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -132,19 +497,33 @@ export default function AddAdvertisement() {
{ label: "New" },
];
const onSubmit = async (values) => {
// Validate only the current step's fields before advancing
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"];
const valid = fields.length ? await trigger(fields) : true;
if (valid) setStep((s) => Math.min(s + 1, steps.length - 1));
};
const handleBack = () => setStep((s) => Math.max(s - 1, 0));
// Called manually — no <form> tag so Enter/click on earlier steps can't accidentally submit
const handleCreate = handleSubmit(async (values) => {
const payload = {
...values,
image_asset_id: values.image_asset_id || null,
start_date: values.start_date || null,
end_date: values.end_date || null,
size: values.type === "banner" ? (values.size || "md") : null,
size: PLACEMENT_MAP[values.placement]?.format === "banner" ? (values.size || "md") : null,
createdBy: user?.user_id ?? null,
};
const res = await createAdvertisement(payload);
if (res) navigate("/admin/advertisements");
};
});
return (
<section className="bg-muted h-full">
@@ -153,204 +532,87 @@ export default function AddAdvertisement() {
<AppBreadcrumb items={breadcrumbItems} />
</div>
<div className="w-full max-w-2xl pb-10">
<h1 className="text-2xl font-semibold tracking-tight mb-1">New advertisement</h1>
<p className="text-sm text-muted-foreground mb-6">Create a banner, popup, or hero placement.</p>
<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>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<Stepper steps={steps} stepIndex={stepIndex} />
<SectionCard title="Placement" description="Where this advertisement will appear.">
<div>
<Label className="mb-1.5 block">Type</Label>
<Select value={type} onValueChange={(v) => setValue("type", v, { shouldValidate: true })}>
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
{ADVERTISEMENT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.type?.message} />
{type && (
<p className="text-xs text-muted-foreground mt-1.5">
{ADVERTISEMENT_TYPES.find((t) => t.value === type)?.description}
</p>
)}
</div>
<div className="rounded-lg border bg-card p-6 min-h-[360px]">
<div className="space-y-0.5 pb-4 mb-1 border-b">
<h2 className="text-sm font-semibold">{current.label}</h2>
<p className="text-xs text-muted-foreground">{current.description}</p>
</div>
{isBanner && (
<div>
<Label className="mb-1.5 block">Size</Label>
<Select value={watch("size") ?? "md"} onValueChange={(v) => setValue("size", v)}>
<SelectTrigger>
<SelectValue placeholder="Select a size" />
</SelectTrigger>
<SelectContent>
{BANNER_SIZES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1.5">
Controls the banner's height. Width always stretches full-width.
</p>
</div>
)}
</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")} />
{type === "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>
{current.id === "placement" && (
<StepPlacement
selectedPage={selectedPage}
setSelectedPage={setSelectedPage}
placement={placement}
setValue={setValue}
positionOptions={positionOptions}
errors={errors}
format={format}
isBanner={isBanner}
watch={watch}
/>
)}
{!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>
{current.id === "content" && (
<StepContent
register={register}
errors={errors}
showRichContent={showRichContent}
description={description}
format={format}
/>
)}
<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={selectedAsset.thumbnail_url || selectedAsset.file_url}
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>
{showRichContent && (
<SectionCard
title="Calls to action"
description={`Up to ${MAX_CTAS} buttons shown on the placement. Each button can be styled as Primary or 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>
<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>
)}
</SectionCard>
{current.id === "image" && (
<StepImage selectedAsset={selectedAsset} imageUrl={imagePreviewUrl} setPickerOpen={setPickerOpen} />
)}
{current.id === "ctas" && (
<StepCtas
ctaFields={ctaFields}
register={register}
errors={errors}
watch={watch}
setValue={setValue}
appendCta={appendCta}
removeCta={removeCta}
/>
)}
{current.id === "scheduling" && (
<StepScheduling register={register} watch={watch} setValue={setValue} />
)}
{current.id === "review" && (
<StepReview data={getValues()} selectedAsset={selectedAsset} imageUrl={imagePreviewUrl} />
)}
</div>
<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")} />
</div>
<div>
<Label className="mb-1.5 block">End date</Label>
<Input type="datetime-local" {...register("end_date")} />
</div>
</div>
</SectionCard>
<div className="flex justify-between gap-2">
<Button
type="button"
variant="outline"
onClick={stepIndex === 0 ? () => navigate(-1) : handleBack}
disabled={loading}
>
<ChevronLeft className="size-4" />
{stepIndex === 0 ? "Cancel" : "Back"}
</Button>
<SectionCard title="Display" description="Manual ordering and on/off 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>
<Switch
checked={watch("is_active")}
onCheckedChange={(v) => setValue("is_active", v)}
/>
</div>
</div>
</SectionCard>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{stepIndex === steps.length - 1 ? (
<Button type="button" onClick={handleCreate} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create advertisement
</Button>
</div>
</form>
) : (
<Button type="button" onClick={handleNext}>
Next
<ChevronRight className="size-4" />
</Button>
)}
</div>
</div>
</div>
@@ -358,11 +620,12 @@ export default function AddAdvertisement() {
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => {
onSelect={(asset, resolvedUrl) => {
setSelectedAsset(asset);
setImagePreviewUrl(resolvedUrl ?? null);
setValue("image_asset_id", asset.asset_id, { shouldValidate: true });
}}
/>
</section>
);
}
}
@@ -5,6 +5,7 @@ import { useNavigate } from "react-router-dom";
import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2 } from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { resolveAssetSrc } from "@/utils/media.util";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button";
@@ -17,24 +18,27 @@ 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";
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 [search, setSearch] = useState("");
useEffect(() => {
const filters = [];
if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter });
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({ 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() });
fetchAdvertisements({ page: 1, limit: 24, filters });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typeFilter, statusFilter, search]);
}, [typeFilter, placementFilter, statusFilter, search]);
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -82,7 +86,7 @@ export default function AdvertisementList() {
{/* ── Filters ────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 flex-wrap">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[150px]">
<SelectTrigger className="w-[150px] bg-background">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
@@ -93,8 +97,20 @@ export default function AdvertisementList() {
</SelectContent>
</Select>
<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}>
<SelectTrigger className="w-[150px]">
<SelectTrigger className="w-[150px] bg-background">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
@@ -109,7 +125,7 @@ export default function AdvertisementList() {
<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"
className="pl-8 bg-background"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@@ -164,11 +180,12 @@ function StatCard({ label, value, tone = "default" }) {
function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
const { fmtDate } = useDateFormat();
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
const TypeIcon = typeMeta.icon ?? Megaphone;
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
const placementMeta = PLACEMENT_MAP[ad.placement] ?? null;
const TypeIcon = typeMeta.icon ?? Megaphone;
const previewSrc = ad.image?.thumbnail_url || ad.image?.file_url || ad.image_url || null;
const previewSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
const isDimmed = ad.status === "expired" || ad.status === "archived";
const dateRange = formatDateRange(ad.start_date, ad.end_date, fmtDate);
@@ -178,13 +195,13 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
<button
type="button"
onClick={onView}
className="h-32 bg-muted relative flex items-center justify-center w-full text-left cursor-pointer"
className="h-32 bg-muted dark:bg-purple-950 relative flex items-center justify-center w-full text-left cursor-pointer"
aria-label="View advertisement details"
>
{previewSrc ? (
<img src={previewSrc} alt={ad.headline || ad.type} className="w-full h-full object-cover" />
) : (
<Megaphone className="size-7 text-muted-foreground" />
<Megaphone className="size-7" />
)}
<span className={`absolute top-2 left-2 text-xs font-medium px-2 py-0.5 rounded-md ${statusMeta.badgeClass ?? "bg-muted text-muted-foreground"}`}>
@@ -199,6 +216,11 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
<div className="p-3 flex flex-col gap-2 flex-1">
<button type="button" onClick={onView} className="text-left">
<p className="text-sm font-medium leading-snug truncate hover:underline">{ad.headline || ad.badge_label || "Untitled advertisement"}</p>
{placementMeta ? (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{placementMeta.pageLabel} — {placementMeta.slotLabel}</p>
) : (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-0.5">Unassigned placement</p>
)}
{dateRange && <p className="text-xs text-muted-foreground mt-0.5">{dateRange}</p>}
</button>
@@ -9,6 +9,7 @@ import { House, Plus, Trash2, ImagePlus } from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { useAuth } from "@/contexts/AuthContext";
import { resolveAssetSrc } from "@/utils/media.util";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -19,12 +20,13 @@ import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { ADVERTISEMENT_TYPES, RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
import { RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
import { AD_PAGES, PLACEMENT_MAP } from "@/data/placement.data";
// ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({
type: z.enum(["hero", "banner", "popup", "sidebar"], { required_error: "Type is required." }),
placement: z.string().min(1, "Placement is required."),
badge_label: z.string().optional(),
headline: z.string().optional(),
description: z.string().optional(),
@@ -39,9 +41,10 @@ const schema = z.object({
end_date: z.string().optional(),
order: z.coerce.number().min(0).default(0),
is_active: z.boolean().default(true),
size: z.enum(["sm", "md", "lg"]).nullable().optional(),
size: z.enum(["sm", "md", "lg", "xl"]).nullable().optional(),
}).superRefine((data, ctx) => {
if (data.type === "hero" && (data.description?.length ?? 0) > 200) {
const format = PLACEMENT_MAP[data.placement]?.format;
if (format === "hero" && (data.description?.length ?? 0) > 200) {
ctx.addIssue({
code: z.ZodIssueCode.too_big,
maximum: 200,
@@ -86,6 +89,7 @@ const BANNER_SIZES = [
{ value: "sm", label: "Small" },
{ value: "md", label: "Medium" },
{ value: "lg", label: "Large" },
{ value: "xl", label: "Extra Large" },
];
const CTA_VARIANTS = [
@@ -103,6 +107,15 @@ 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.
const [ready, setReady] = useState(false);
const {
register,
@@ -115,7 +128,7 @@ export default function EditAdvertisement() {
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
type: undefined,
placement: undefined,
badge_label: "",
headline: "",
description: "",
@@ -131,10 +144,12 @@ export default function EditAdvertisement() {
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
const type = watch("type");
const placement = watch("placement");
const description = watch("description");
const showRichContent = RICH_CONTENT_TYPES.includes(type);
const isBanner = type === "banner";
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" },
@@ -149,10 +164,17 @@ export default function EditAdvertisement() {
const ad = res?.data?.data ?? null;
if (!ad) return;
if (ad.image) setSelectedAsset(ad.image);
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));
}
setSelectedPage(PLACEMENT_MAP[ad.placement]?.page ?? null);
reset({
type: ad.type ?? undefined,
placement: ad.placement ?? undefined,
badge_label: ad.badge_label ?? "",
headline: ad.headline ?? "",
description: ad.description ?? "",
@@ -168,6 +190,8 @@ export default function EditAdvertisement() {
is_active: ad.is_active ?? true,
size: ad.size ?? null,
});
setReady(true);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [advertisementId]);
@@ -180,7 +204,7 @@ export default function EditAdvertisement() {
image_asset_id: values.image_asset_id || null,
start_date: values.start_date || null,
end_date: values.end_date || null,
size: values.type === "banner" ? (values.size || "md") : null,
size: PLACEMENT_MAP[values.placement]?.format === "banner" ? (values.size || "md") : null,
updatedBy: user?.user_id ?? null,
};
@@ -188,6 +212,16 @@ export default function EditAdvertisement() {
if (res) 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">
@@ -202,26 +236,52 @@ export default function EditAdvertisement() {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Placement" description="Where this advertisement will appear.">
<div>
<Label className="mb-1.5 block">Type</Label>
<Select value={type} onValueChange={(v) => setValue("type", v, { shouldValidate: true, shouldDirty: true })}>
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
{ADVERTISEMENT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.type?.message} />
{type && (
<p className="text-xs text-muted-foreground mt-1.5">
{ADVERTISEMENT_TYPES.find((t) => t.value === type)?.description}
</p>
)}
<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>
{format && (
<p className="text-xs text-muted-foreground">
Format: <span className="font-medium text-foreground capitalize">{format}</span> — determined by the placement above.
</p>
)}
{isBanner && (
<div>
<Label className="mb-1.5 block">Size</Label>
@@ -255,7 +315,7 @@ export default function EditAdvertisement() {
<div>
<Label className="mb-1.5 block">Description</Label>
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
{type === "hero" && (
{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"}`}>
@@ -283,7 +343,7 @@ export default function EditAdvertisement() {
onClick={() => setPickerOpen(true)}
>
<img
src={selectedAsset.thumbnail_url || selectedAsset.file_url}
src={imagePreviewUrl || resolveAssetSrc(selectedAsset)}
alt={selectedAsset.display_name}
className="w-full h-full object-cover"
/>
@@ -400,8 +460,9 @@ export default function EditAdvertisement() {
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => {
onSelect={(asset, resolvedUrl) => {
setSelectedAsset(asset);
setImagePreviewUrl(resolvedUrl ?? null);
setValue("image_asset_id", asset.asset_id, { shouldValidate: true, shouldDirty: true });
}}
/>
@@ -5,6 +5,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { House, Edit, ArrowLeft, Megaphone, MousePointerClick, ExternalLink } from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { resolveAssetSrc } from "@/utils/media.util";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button";
@@ -12,6 +13,7 @@ import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
import { PLACEMENT_MAP } from "@/data/placement.data";
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -85,11 +87,12 @@ export default function ViewAdvertisement() {
);
}
const typeMeta = ADVERTISEMENT_TYPE_MAP[advertisement.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[advertisement.status] ?? {};
const TypeIcon = typeMeta.icon ?? Megaphone;
const typeMeta = ADVERTISEMENT_TYPE_MAP[advertisement.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[advertisement.status] ?? {};
const placementMeta = PLACEMENT_MAP[advertisement.placement] ?? null;
const TypeIcon = typeMeta.icon ?? Megaphone;
const previewSrc = advertisement.image?.thumbnail_url || advertisement.image?.file_url || advertisement.image_url || null;
const previewSrc = resolveAssetSrc(advertisement.image) || advertisement.image_url || null;
const ctas = Array.isArray(advertisement.ctas) ? advertisement.ctas : [];
return (
@@ -111,7 +114,7 @@ export default function ViewAdvertisement() {
<h1 className="text-xl font-semibold tracking-tight">
{advertisement.headline || advertisement.badge_label || "Untitled advertisement"}
</h1>
<div className="flex items-center gap-1.5 mt-1">
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
<Badge variant="secondary" className="gap-1">
<TypeIcon className="size-3" />
{typeMeta.label ?? advertisement.type}
@@ -119,6 +122,15 @@ export default function ViewAdvertisement() {
<Badge variant={advertisement.status === "active" ? "default" : "secondary"}>
{statusMeta.label ?? advertisement.status}
</Badge>
{placementMeta ? (
<Badge variant="outline">
{placementMeta.pageLabel} — {placementMeta.slotLabel}
</Badge>
) : (
<Badge variant="outline" className="border-amber-400 text-amber-700 dark:text-amber-400">
Unassigned placement
</Badge>
)}
</div>
</div>
</div>