diff --git a/app/_admin/portfolio/actions.ts b/app/_admin/portfolio/actions.ts index e8c1599..9c0dad2 100644 --- a/app/_admin/portfolio/actions.ts +++ b/app/_admin/portfolio/actions.ts @@ -30,10 +30,21 @@ import { getSiteSettings } from "@/lib/app-config"; import { assetInputSchema, categoryInputSchema, + projectDraftInputSchema, projectInputSchema, sectionInputSchema, } from "@/lib/portfolio-validation"; +/** Build a URL-safe slug from a title, falling back to a unique draft slug. */ +function slugifyForDraft(input: string): string { + const base = input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return base || `draft-${Date.now()}`; +} + async function ensureAdmin() { if (!(await isAdminAuthenticated())) { await clearAdminSessionCookie(); @@ -204,28 +215,67 @@ export async function saveProjectAction(formData: FormData) { const createdMediaAssetIds: string[] = []; try { - const sections = parseJsonArray(formData.get("sections"), "sections").map((section, index) => + const intent = String(formData.get("intent") ?? "save"); + const isDraft = intent === "draft"; + + const parseSection = (section: Record, index: number) => sectionInputSchema.parse({ ...section, media: section.media ? mediaFieldInputSchema.parse(section.media) : undefined, sortOrder: section.sortOrder ?? index, - }), - ); + }); - const assets = parseJsonArray(formData.get("assets"), "assets").map((asset, index) => + const parseAsset = (asset: Record, index: number) => assetInputSchema.parse({ ...asset, media: asset.media ? mediaFieldInputSchema.parse(asset.media) : undefined, sortOrder: asset.sortOrder ?? index, - }), - ); + }); + + // A draft keeps only the entries that are already valid; a full save + // validates every entry strictly. + const sections = parseJsonArray(formData.get("sections"), "sections").flatMap((section, index) => { + if (!isDraft) { + return [parseSection(section, index)]; + } + + try { + return [parseSection(section, index)]; + } catch { + return []; + } + }); + + const assets = parseJsonArray(formData.get("assets"), "assets").flatMap((asset, index) => { + if (!isDraft) { + return [parseAsset(asset, index)]; + } + + try { + return [parseAsset(asset, index)]; + } catch { + return []; + } + }); const coverMedia = parseJsonObject(formData.get("coverMedia"), "coverMedia"); - const parsed = projectInputSchema.parse({ + const rawSlug = String(formData.get("slug") ?? "").trim(); + const slug = + isDraft && !rawSlug + ? slugifyForDraft( + String(formData.get("titleDe") ?? "") || + String(formData.get("titleEn") ?? "") || + String(formData.get("titleAr") ?? ""), + ) + : rawSlug; + const rawYear = String(formData.get("projectYear") ?? "").trim(); + const projectYear = isDraft && !rawYear ? String(new Date().getFullYear()) : rawYear; + + const parsed = (isDraft ? projectDraftInputSchema : projectInputSchema).parse({ id: String(formData.get("id") ?? "").trim() || undefined, categoryId: String(formData.get("categoryId") ?? ""), - slug: String(formData.get("slug") ?? ""), + slug, viewMode: String(formData.get("viewMode") ?? "GRID"), titleAr: String(formData.get("titleAr") ?? ""), titleEn: String(formData.get("titleEn") ?? ""), @@ -234,7 +284,7 @@ export async function saveProjectAction(formData: FormData) { summaryEn: String(formData.get("summaryEn") ?? ""), summaryDe: String(formData.get("summaryDe") ?? ""), clientName: String(formData.get("clientName") ?? ""), - projectYear: String(formData.get("projectYear") ?? ""), + projectYear, serviceLabelAr: String(formData.get("serviceLabelAr") ?? ""), serviceLabelEn: String(formData.get("serviceLabelEn") ?? ""), serviceLabelDe: String(formData.get("serviceLabelDe") ?? ""), @@ -243,7 +293,7 @@ export async function saveProjectAction(formData: FormData) { coverMedia: coverMedia ? mediaFieldInputSchema.parse(coverMedia) : undefined, sortOrder: String(formData.get("sortOrder") ?? "0"), isFeatured: normalizeCheckboxValue(formData, "isFeatured"), - isPublished: normalizeCheckboxValue(formData, "isPublished"), + isPublished: isDraft ? false : normalizeCheckboxValue(formData, "isPublished"), sections, assets, }); diff --git a/components/admin/portfolio-project-form.tsx b/components/admin/portfolio-project-form.tsx index dcbf243..6c73296 100644 --- a/components/admin/portfolio-project-form.tsx +++ b/components/admin/portfolio-project-form.tsx @@ -347,16 +347,26 @@ function SectionHeader({ ); } -function SubmitButton() { +function SubmitButton({ onSelect }: { onSelect: () => void }) { const { pending } = useFormStatus(); return ( - ); } +function DraftButton({ onSelect }: { onSelect: () => void }) { + const { pending } = useFormStatus(); + + return ( + + ); +} + function ViewModeCard({ active, label, @@ -466,6 +476,12 @@ export function PortfolioProjectForm({ const [showValidation, setShowValidation] = useState(false); const sectionsInputRef = useRef(null); const assetsInputRef = useRef(null); + const intentRef = useRef(null); + const setIntent = (value: "save" | "draft") => { + if (intentRef.current) { + intentRef.current.value = value; + } + }; const sectionsPayload = JSON.stringify(sections.map((section, index) => ({ ...section, sortOrder: index }))); const assetsPayload = JSON.stringify(assets.map((asset, index) => ({ ...asset, sortOrder: index }))); @@ -540,6 +556,11 @@ export function PortfolioProjectForm({ action={action} className="space-y-8" onSubmit={(event) => { + // A draft save skips the completeness gate entirely. + if (intentRef.current?.value === "draft") { + return; + } + if (!firstIncompleteStep) { return; } @@ -554,6 +575,7 @@ export function PortfolioProjectForm({ +
@@ -1186,14 +1208,15 @@ export function PortfolioProjectForm({
{firstIncompleteStep ? (

- Fill Basics and Localized Content to save. Sections and assets are optional. + Fill Basics and Localized Content to publish — or save as a draft anytime.

) : (

Ready to save. Sections and assets are optional.

)} - + setIntent("draft")} /> + setIntent("save")} />
diff --git a/lib/portfolio-validation.ts b/lib/portfolio-validation.ts index 7fa3631..48dc673 100644 --- a/lib/portfolio-validation.ts +++ b/lib/portfolio-validation.ts @@ -123,3 +123,21 @@ export const projectInputSchema = z.object({ 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, +}); diff --git a/tests/portfolio-validation.test.ts b/tests/portfolio-validation.test.ts index 5350bc7..99bd6b4 100644 --- a/tests/portfolio-validation.test.ts +++ b/tests/portfolio-validation.test.ts @@ -9,6 +9,7 @@ import { import { assetInputSchema, categoryInputSchema, + projectDraftInputSchema, projectInputSchema, sectionInputSchema, } from "../lib/portfolio-validation"; @@ -65,6 +66,64 @@ describe("portfolio validation", () => { ).toThrow(/slug/i); }); + it("draft schema accepts empty user-facing copy that a strict save rejects", () => { + const draftPayload = { + categoryId: "cat_1", + slug: "draft-123", + viewMode: "GRID" as const, + titleAr: "", + titleEn: "", + titleDe: "", + summaryAr: "", + summaryEn: "", + summaryDe: "", + clientName: "", + projectYear: 2026, + serviceLabelAr: "", + serviceLabelEn: "", + serviceLabelDe: "", + previewUrl: "", + currentCoverImagePath: "", + sortOrder: 0, + isFeatured: false, + isPublished: false, + sections: [], + assets: [], + }; + + expect(projectDraftInputSchema.parse(draftPayload).slug).toBe("draft-123"); + expect(() => projectInputSchema.parse(draftPayload)).toThrow(); + }); + + it("draft schema still requires a slug and a valid year", () => { + const base = { + categoryId: "cat_1", + slug: "draft-1", + viewMode: "GRID" as const, + titleAr: "", + titleEn: "", + titleDe: "", + summaryAr: "", + summaryEn: "", + summaryDe: "", + clientName: "", + projectYear: 2026, + serviceLabelAr: "", + serviceLabelEn: "", + serviceLabelDe: "", + previewUrl: "", + currentCoverImagePath: "", + sortOrder: 0, + isFeatured: false, + isPublished: false, + sections: [], + assets: [], + }; + + expect(() => projectDraftInputSchema.parse({ ...base, slug: "" })).toThrow(/slug/i); + expect(() => projectDraftInputSchema.parse({ ...base, projectYear: 1999 })).toThrow(); + }); + it("accepts valid section and asset payloads", () => { expect( sectionInputSchema.parse({