mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
add: more commits
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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 });
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user