- Add a Save as draft button that bypasses the completeness gate: the user can save partial work and finish later - projectDraftInputSchema makes the user-facing copy (title, summary, service label, client) optional; the action auto-generates a slug from the title (or draft-<timestamp>), defaults the year to the current year, and forces the project unpublished - Drafts drop not-yet-valid sections/assets instead of failing the save - Pass intent via a hidden field set on click so the server reliably sees it - Tests: draft schema accepts empty copy a strict save rejects, and still requires a slug and a valid year
144 lines
5.1 KiB
TypeScript
144 lines
5.1 KiB
TypeScript
import { PortfolioSectionType } from "@/lib/db/enums";
|
|
import { z } from "zod";
|
|
|
|
import { mediaFieldInputSchema } from "./media-validation";
|
|
|
|
const requiredText = (label: string) =>
|
|
z
|
|
.string()
|
|
.trim()
|
|
.min(1, `${label} is required.`);
|
|
|
|
const optionalTrimmedText = z.string().trim().optional().transform((value) => value ?? "");
|
|
const portfolioProjectViewModes = ["GRID", "STORY", "CASE_STUDY"] as const;
|
|
|
|
export const categoryInputSchema = z.object({
|
|
id: z.string().trim().optional(),
|
|
slug: requiredText("Category slug")
|
|
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Category slug must be lowercase and hyphenated."),
|
|
nameAr: requiredText("Category nameAr"),
|
|
nameEn: requiredText("Category nameEn"),
|
|
nameDe: requiredText("Category nameDe"),
|
|
descriptionAr: requiredText("Category descriptionAr"),
|
|
descriptionEn: requiredText("Category descriptionEn"),
|
|
descriptionDe: requiredText("Category descriptionDe"),
|
|
sortOrder: z.coerce.number().int().min(0).max(9999),
|
|
isActive: z.boolean(),
|
|
});
|
|
|
|
export const sectionInputSchema = z
|
|
.object({
|
|
id: z.string().trim().optional(),
|
|
type: z.nativeEnum(PortfolioSectionType),
|
|
titleAr: requiredText("Section titleAr"),
|
|
titleEn: requiredText("Section titleEn"),
|
|
titleDe: requiredText("Section titleDe"),
|
|
bodyAr: optionalTrimmedText,
|
|
bodyEn: optionalTrimmedText,
|
|
bodyDe: optionalTrimmedText,
|
|
imagePath: optionalTrimmedText,
|
|
media: mediaFieldInputSchema.optional(),
|
|
linkUrl: optionalTrimmedText.refine(
|
|
(value) => value === "" || /^https?:\/\//.test(value) || value.startsWith("/"),
|
|
"Section linkUrl must be an absolute URL or start with /.",
|
|
),
|
|
sortOrder: z.coerce.number().int().min(0).max(9999),
|
|
})
|
|
.superRefine((value, context) => {
|
|
const hasBody = Boolean(value.bodyAr && value.bodyEn && value.bodyDe);
|
|
const hasImage = value.media?.mode === "library"
|
|
? Boolean(value.media.assetId)
|
|
: value.media?.mode === "upload"
|
|
? true
|
|
: Boolean(value.imagePath);
|
|
|
|
if (
|
|
(value.type === PortfolioSectionType.RICH_TEXT ||
|
|
value.type === PortfolioSectionType.STATS ||
|
|
value.type === PortfolioSectionType.DELIVERABLES) &&
|
|
!hasBody
|
|
) {
|
|
context.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["bodyAr"],
|
|
message: "This section type requires body content in all languages.",
|
|
});
|
|
}
|
|
|
|
if (value.type === PortfolioSectionType.GALLERY && !hasImage) {
|
|
context.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["media"],
|
|
message: "Single image sections require an image.",
|
|
});
|
|
}
|
|
|
|
if (value.type === PortfolioSectionType.LINK && !value.linkUrl) {
|
|
context.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ["linkUrl"],
|
|
message: "Link sections require a link URL.",
|
|
});
|
|
}
|
|
});
|
|
|
|
export const assetInputSchema = z.object({
|
|
id: z.string().trim().optional(),
|
|
kind: z.literal("IMAGE"),
|
|
filePath: optionalTrimmedText,
|
|
fileFieldName: optionalTrimmedText,
|
|
media: mediaFieldInputSchema.optional(),
|
|
altAr: requiredText("Asset altAr"),
|
|
altEn: requiredText("Asset altEn"),
|
|
altDe: requiredText("Asset altDe"),
|
|
sortOrder: z.coerce.number().int().min(0).max(9999),
|
|
});
|
|
|
|
export const projectInputSchema = z.object({
|
|
id: z.string().trim().optional(),
|
|
categoryId: requiredText("Project categoryId"),
|
|
slug: requiredText("Project slug")
|
|
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Project slug must be lowercase and hyphenated."),
|
|
viewMode: z.enum(portfolioProjectViewModes).default("GRID"),
|
|
titleAr: requiredText("Project titleAr"),
|
|
titleEn: requiredText("Project titleEn"),
|
|
titleDe: requiredText("Project titleDe"),
|
|
summaryAr: requiredText("Project summaryAr"),
|
|
summaryEn: requiredText("Project summaryEn"),
|
|
summaryDe: requiredText("Project summaryDe"),
|
|
clientName: requiredText("Project clientName"),
|
|
projectYear: z.coerce.number().int().min(2000).max(2100),
|
|
serviceLabelAr: requiredText("Project serviceLabelAr"),
|
|
serviceLabelEn: requiredText("Project serviceLabelEn"),
|
|
serviceLabelDe: requiredText("Project serviceLabelDe"),
|
|
previewUrl: optionalTrimmedText.refine(
|
|
(value) => value === "" || /^https?:\/\//.test(value),
|
|
"Project previewUrl must be an absolute URL.",
|
|
),
|
|
currentCoverImagePath: optionalTrimmedText,
|
|
coverMedia: mediaFieldInputSchema.optional(),
|
|
sortOrder: z.coerce.number().int().min(0).max(9999),
|
|
isFeatured: z.boolean(),
|
|
isPublished: z.boolean(),
|
|
sections: z.array(sectionInputSchema),
|
|
assets: z.array(assetInputSchema),
|
|
});
|
|
|
|
/**
|
|
* Draft variant: the user-facing copy fields are optional so an unfinished
|
|
* project can be saved and completed later. The action still guarantees a
|
|
* slug, a category, and a year (auto-filled), and forces the project unpublished.
|
|
*/
|
|
export const projectDraftInputSchema = projectInputSchema.extend({
|
|
titleAr: optionalTrimmedText,
|
|
titleEn: optionalTrimmedText,
|
|
titleDe: optionalTrimmedText,
|
|
summaryAr: optionalTrimmedText,
|
|
summaryEn: optionalTrimmedText,
|
|
summaryDe: optionalTrimmedText,
|
|
serviceLabelAr: optionalTrimmedText,
|
|
serviceLabelEn: optionalTrimmedText,
|
|
serviceLabelDe: optionalTrimmedText,
|
|
clientName: optionalTrimmedText,
|
|
});
|