added more things

This commit is contained in:
rgrgogu
2026-08-03 22:48:16 +08:00
parent 2e649fc96e
commit cd16e996e5
17 changed files with 691 additions and 1017 deletions
@@ -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 { isValidLink, LINK_ERROR } from "@/utils/link.util";
import { cn } from "@/lib/utils";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
@@ -24,7 +25,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { MAX_CTAS, CONTENT_MODES } from "@/data/advertisement.data";
import { MAX_CTAS, MAX_BADGE_LABELS, CONTENT_MODES } from "@/data/advertisement.data";
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
// ─── Schema ─────────────────────────────────────────────────────────────────
@@ -32,14 +33,14 @@ import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
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(),
badge_labels: z.array(z.string().min(1)).max(MAX_BADGE_LABELS, `A maximum of ${MAX_BADGE_LABELS} badges is allowed.`).default([]),
headline: z.string().optional(),
description: z.string().optional(),
description: z.string().max(100, "Description must be 100 characters or fewer.").optional(),
image_asset_id: z.union([z.string(), z.number()]).nullable().optional(),
// Hard cap of 2 CTAs per advertisement (max() backs up the UI-level append guard)
ctas: z.array(z.object({
label: z.string().min(1, "Label is required."),
link: z.string().min(1, "Link is required."),
link: z.string().min(1, "Link is required.").refine(isValidLink, LINK_ERROR),
variant: z.enum(["default", "outline"]).default("default"),
})).max(MAX_CTAS, `A maximum of ${MAX_CTAS} buttons is allowed.`).default([]),
redirect_link: z.string().optional(),
@@ -56,16 +57,14 @@ const schema = z.object({
end_date: z.string().optional(),
is_active: z.boolean().default(true),
}).superRefine((data, ctx) => {
const format = PLACEMENT_MAP[data.placement]?.format;
if (format === "hero" && (data.description?.length ?? 0) > 200) {
ctx.addIssue({
code: z.ZodIssueCode.too_big,
maximum: 200,
type: "string",
inclusive: true,
message: "Description must be 200 characters or fewer for hero placements.",
path: ["description"],
});
// Image Only ads have no other click-through — the Link is their only
// destination, so it's required (Text with Image ads click through via
// their own CTA links instead).
if (data.content_mode !== "image") return;
if (!data.redirect_link?.trim()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["redirect_link"], message: "Link is required." });
} else if (!isValidLink(data.redirect_link)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["redirect_link"], message: LINK_ERROR });
}
});
@@ -90,11 +89,6 @@ function SectionCard({ title, description, children }) {
);
}
const CTA_VARIANTS = [
{ value: "default", label: "Primary" },
{ value: "outline", label: "Outline" },
];
// ─── Page ───────────────────────────────────────────────────────────────────
export default function EditAdvertisement() {
@@ -124,7 +118,7 @@ export default function EditAdvertisement() {
defaultValues: {
placement: undefined,
content_mode: "image",
badge_label: "",
badge_labels: [],
headline: "",
description: "",
image_asset_id: null,
@@ -145,6 +139,9 @@ export default function EditAdvertisement() {
const placement = watch("placement");
const description = watch("description");
const contentMode = watch("content_mode");
const badgeLabelFields = watch("badge_labels") ?? [];
const appendBadgeLabel = () => setValue("badge_labels", [...badgeLabelFields, ""], { shouldValidate: true, shouldDirty: true });
const removeBadgeLabel = (index) => setValue("badge_labels", badgeLabelFields.filter((_, i) => i !== index), { shouldValidate: true, shouldDirty: true });
const redirectLink = watch("redirect_link");
const format = PLACEMENT_MAP[placement]?.format;
@@ -172,7 +169,7 @@ export default function EditAdvertisement() {
reset({
placement: ad.placement ?? undefined,
content_mode: ad.content_mode ?? "image",
badge_label: ad.badge_label ?? "",
badge_labels: ad.badge_labels ?? [],
headline: ad.headline ?? "",
description: ad.description ?? "",
image_asset_id: ad.image?.asset_id ?? null,
@@ -264,13 +261,18 @@ export default function EditAdvertisement() {
)}
</SectionCard>
<SectionCard title="Content" description="Full image, or content with badge, headline, description, and CTAs.">
<SectionCard title="Type" description="Image Only, or Text with Image with badges, headline, description, and links.">
<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 })}
onClick={() => {
setValue("content_mode", m.value, { shouldValidate: true, shouldDirty: true });
// redirect_link only applies to Image Only ads (Text with
// Image ads click through via their own CTA links instead)
if (m.value === "content") setValue("redirect_link", "", { 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"
@@ -278,7 +280,7 @@ export default function EditAdvertisement() {
>
<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."}
{m.value === "image" ? "No text." : "Left-aligned text, image on the right."}
</p>
</button>
))}
@@ -297,8 +299,28 @@ export default function EditAdvertisement() {
{contentMode === "content" && (
<>
<div>
<Label className="mb-1.5 block">Badge label</Label>
<Input placeholder="e.g. Ad" {...register("badge_label")} />
<Label className="mb-1.5 block">Badge labels</Label>
<div className="space-y-2">
{badgeLabelFields.map((_, index) => (
<div key={index} className="flex gap-2 items-start">
<div className="flex-1">
<Input placeholder="e.g. Ad" {...register(`badge_labels.${index}`)} />
<FieldError message={errors.badge_labels?.[index]?.message} />
</div>
<Button type="button" variant="ghost" size="icon" onClick={() => removeBadgeLabel(index)} aria-label="Remove">
<Trash2 className="size-4" />
</Button>
</div>
))}
{badgeLabelFields.length < MAX_BADGE_LABELS ? (
<Button type="button" variant="outline" size="sm" onClick={appendBadgeLabel}>
<Plus className="size-3.5" />
Add badge label
</Button>
) : (
<p className="text-xs text-muted-foreground">Maximum of {MAX_BADGE_LABELS} badges reached.</p>
)}
</div>
</div>
<div>
<Label className="mb-1.5 block">Headline</Label>
@@ -307,14 +329,12 @@ export default function EditAdvertisement() {
<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 className="flex justify-between items-start mt-1">
<FieldError message={errors.description?.message} />
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 100 ? "text-destructive" : "text-muted-foreground"}`}>
{description?.length ?? 0}/100
</span>
</div>
</div>
</>
)}
@@ -349,8 +369,8 @@ export default function EditAdvertisement() {
{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.`}
title="Links"
description={`Up to ${MAX_CTAS} buttons shown on the placement. The first is styled Primary, the second Outline.`}
>
{ctaFields.map((field, index) => (
<div key={field.id} className="flex gap-2 items-start">
@@ -362,21 +382,6 @@ export default function EditAdvertisement() {
<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, { shouldDirty: true })}
>
<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>
@@ -390,7 +395,7 @@ export default function EditAdvertisement() {
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
>
<Plus className="size-3.5" />
Add CTA
Add link
</Button>
) : (
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
@@ -398,51 +403,54 @@ 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>
{contentMode === "image" && (
<SectionCard title="Click-through" description="Where clicking the image goes to.">
<div>
<Label className="mb-1.5 block">Link</Label>
<Input placeholder="e.g. /courses or https://example.com" {...register("redirect_link")} />
<FieldError message={errors.redirect_link?.message} />
<p className="text-xs text-muted-foreground mt-1.5">
Where clicking the image goes to.
</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>
{!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>
</>
)}
</SectionCard>
<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">