mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
added more things
This commit is contained in:
@@ -7,7 +7,7 @@ import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
House, Plus, Trash2, ImagePlus, MapPin, FileText,
|
||||
LayoutTemplate, CalendarClock, Check, ChevronLeft, ChevronRight,
|
||||
CalendarClock, Check, ChevronLeft, ChevronRight,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||
@@ -15,6 +15,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
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";
|
||||
@@ -30,7 +31,7 @@ import { DateTimePicker } from "@/components/ui/date-time-picker";
|
||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||
import { PlacementSkeleton } from "@/components/generic/PlacementSkeleton";
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
@@ -38,14 +39,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(),
|
||||
@@ -62,16 +63,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, see redirect_link comment above).
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -81,8 +80,8 @@ const schema = z.object({
|
||||
// page) for ads that don't link straight out to a URL.
|
||||
const ALL_STEPS = [
|
||||
{ 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: "content", label: "Type", icon: FileText, description: "Image Only, or Text with Image with badges, headline, description, and links." },
|
||||
// { 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 and draft/active status." },
|
||||
{ id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this ad." },
|
||||
];
|
||||
@@ -94,11 +93,6 @@ function FieldError({ message }) {
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
const CTA_VARIANTS = [
|
||||
{ value: "default", label: "Primary" },
|
||||
{ value: "outline", label: "Outline" },
|
||||
];
|
||||
|
||||
// ─── Stepper header ─────────────────────────────────────────────────────────
|
||||
|
||||
function Stepper({ steps, stepIndex }) {
|
||||
@@ -109,9 +103,13 @@ function Stepper({ steps, stepIndex }) {
|
||||
const isActive = stepIndex === i;
|
||||
const isDone = stepIndex > i;
|
||||
|
||||
// "contents" drops this wrapper out of layout so the node and its
|
||||
// trailing line become direct siblings in the outer flex row — that
|
||||
// way every line shares the leftover space equally (flex-1), instead
|
||||
// of being sized off its own step's (possibly short) label width.
|
||||
return (
|
||||
<div key={s.id} className="flex items-center flex-1 last:flex-none">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div key={s.id} className="contents">
|
||||
<div className="flex flex-col items-center gap-1 shrink-0">
|
||||
<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",
|
||||
@@ -209,22 +207,30 @@ function StepImagePicker({ selectedAsset, imageUrl, setPickerOpen }) {
|
||||
}
|
||||
|
||||
function StepContent({
|
||||
register, errors, setValue, watch, description, format,
|
||||
register, errors, setValue, watch, description,
|
||||
selectedAsset, imageUrl, setPickerOpen,
|
||||
ctaFields, appendCta, removeCta,
|
||||
}) {
|
||||
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 });
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<div className="mt-4">
|
||||
<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 })}
|
||||
onClick={() => {
|
||||
setValue("content_mode", m.value, { shouldValidate: 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 });
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-lg border p-3 text-left transition-colors",
|
||||
contentMode === m.value ? "border-primary bg-primary/5" : "hover:bg-muted/50"
|
||||
@@ -232,7 +238,7 @@ function StepContent({
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
@@ -257,8 +263,28 @@ function StepContent({
|
||||
{contentMode === "content" && (
|
||||
<div className="space-y-5">
|
||||
<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>
|
||||
@@ -267,18 +293,19 @@ function StepContent({
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Calls to action</Label>
|
||||
<Label className="mb-1.5 block">Links</Label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Up to {MAX_CTAS} buttons. The first is styled Primary, the second Outline.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{ctaFields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-2 items-start">
|
||||
@@ -290,21 +317,6 @@ function StepContent({
|
||||
<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>
|
||||
@@ -318,7 +330,7 @@ function StepContent({
|
||||
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>
|
||||
@@ -328,15 +340,19 @@ function StepContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{contentMode === "image" && (
|
||||
<>
|
||||
<Separator />
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -451,10 +467,10 @@ function StepReview({ data, selectedAsset, imageUrl }) {
|
||||
|
||||
<div className="border rounded-lg p-4 space-y-1">
|
||||
<p className="text-sm font-medium mb-2">Content</p>
|
||||
<SummaryRow label="Type" value={data.content_mode === "content" ? "Content + image" : "Full image"} />
|
||||
<SummaryRow label="Type" value={data.content_mode === "content" ? "Text with Image" : "Image Only"} />
|
||||
{data.content_mode === "content" && (
|
||||
<>
|
||||
<SummaryRow label="Badge" value={data.badge_label} />
|
||||
<SummaryRow label="Badges" value={(data.badge_labels ?? []).filter(Boolean).join(", ")} />
|
||||
<SummaryRow label="Headline" value={data.headline} />
|
||||
<SummaryRow label="Description" value={data.description} />
|
||||
</>
|
||||
@@ -476,26 +492,28 @@ function StepReview({ data, selectedAsset, imageUrl }) {
|
||||
|
||||
{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>
|
||||
<p className="text-sm font-medium mb-2">Links</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">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>
|
||||
{data.content_mode === "image" && (
|
||||
<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="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 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>
|
||||
@@ -533,7 +551,7 @@ export default function AddAdvertisement() {
|
||||
defaultValues: {
|
||||
placement: undefined,
|
||||
content_mode: "image",
|
||||
badge_label: "",
|
||||
badge_labels: [],
|
||||
headline: "",
|
||||
description: "",
|
||||
image_asset_id: null,
|
||||
@@ -558,6 +576,7 @@ export default function AddAdvertisement() {
|
||||
const placement = watch("placement");
|
||||
const description = watch("description");
|
||||
const redirectLink = watch("redirect_link");
|
||||
const contentMode = watch("content_mode");
|
||||
const format = PLACEMENT_MAP[placement]?.format;
|
||||
|
||||
const steps = useMemo(
|
||||
@@ -567,6 +586,10 @@ export default function AddAdvertisement() {
|
||||
const stepIndex = Math.min(step, steps.length - 1);
|
||||
const current = steps[stepIndex];
|
||||
|
||||
// Image Only ads require a valid Link before advancing past the Type step
|
||||
const linkStepInvalid = current.id === "content" && contentMode === "image"
|
||||
&& (!redirectLink?.trim() || !isValidLink(redirectLink));
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Ads", to: "/admin/advertisements" },
|
||||
@@ -577,7 +600,7 @@ export default function AddAdvertisement() {
|
||||
const handleNext = async () => {
|
||||
let fields = [];
|
||||
if (current.id === "placement") fields = ["placement"];
|
||||
else if (current.id === "content") fields = watch("content_mode") === "content" ? ["headline", "description", "badge_label", "ctas"] : [];
|
||||
else if (current.id === "content") fields = watch("content_mode") === "content" ? ["headline", "description", "badge_labels", "ctas"] : ["redirect_link"];
|
||||
|
||||
const valid = fields.length ? await trigger(fields) : true;
|
||||
if (valid) setStep((s) => Math.min(s + 1, steps.length - 1));
|
||||
@@ -637,7 +660,6 @@ export default function AddAdvertisement() {
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
description={description}
|
||||
format={format}
|
||||
selectedAsset={selectedAsset}
|
||||
imageUrl={imagePreviewUrl}
|
||||
setPickerOpen={setPickerOpen}
|
||||
@@ -679,7 +701,7 @@ export default function AddAdvertisement() {
|
||||
Create advertisement
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" onClick={handleNext}>
|
||||
<Button type="button" onClick={handleNext} disabled={linkStepInvalid}>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -271,7 +271,7 @@ function AdvertisementCard({ ad, position, onView, onEdit, onArchive, canMoveUp,
|
||||
|
||||
<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 ad"}</p>
|
||||
<p className="text-sm font-medium leading-snug truncate hover:underline">{ad.headline || ad.badge_labels?.[0] || "Untitled ad"}</p>
|
||||
{placementMeta ? (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{placementMeta.pageLabel} — {placementMeta.slotLabel}</p>
|
||||
) : (
|
||||
@@ -305,7 +305,7 @@ function AdvertisementCard({ ad, position, onView, onEdit, onArchive, canMoveUp,
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Archive this ad?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{ad.headline || ad.badge_label || "This ad"}" will be moved to archived ads. You can restore it later.
|
||||
"{ad.headline || ad.badge_labels?.[0] || "This ad"}" will be moved to archived ads. You can restore it later.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function ViewAdvertisement() {
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
{advertisement.headline || advertisement.badge_label || "Untitled ad"}
|
||||
{advertisement.headline || advertisement.badge_labels?.[0] || "Untitled ad"}
|
||||
</h1>
|
||||
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
@@ -153,7 +153,7 @@ export default function ViewAdvertisement() {
|
||||
|
||||
{/* ── Content ────────────────────────────────────────────────── */}
|
||||
<SectionCard title="Content">
|
||||
<Field label="Badge label">{advertisement.badge_label || "—"}</Field>
|
||||
<Field label="Badge labels">{(advertisement.badge_labels ?? []).join(", ") || "—"}</Field>
|
||||
<Field label="Headline">{advertisement.headline || "—"}</Field>
|
||||
<Field label="Description">{advertisement.description || "—"}</Field>
|
||||
</SectionCard>
|
||||
|
||||
Reference in New Issue
Block a user