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
@@ -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 });
}}
/>