From 3eb92382684ab160db504ec66a47d6f9a3daed24 Mon Sep 17 00:00:00 2001 From: MOH Date: Sun, 15 Mar 2026 04:42:09 +0100 Subject: [PATCH] moh/admin-portfolio-ux-improvements --- .../admin/portfolio-categories-manager.tsx | 276 ++-- .../admin/portfolio-project-actions.tsx | 3 +- components/admin/portfolio-project-form.tsx | 1288 ++++++++++++----- components/ui/tabs.tsx | 22 +- lib/admin-navigation.ts | 2 +- lib/portfolio-form-progress.ts | 154 ++ tests/portfolio-validation.test.ts | 89 ++ 7 files changed, 1352 insertions(+), 482 deletions(-) create mode 100644 lib/portfolio-form-progress.ts diff --git a/components/admin/portfolio-categories-manager.tsx b/components/admin/portfolio-categories-manager.tsx index f0b6112..dc292ac 100644 --- a/components/admin/portfolio-categories-manager.tsx +++ b/components/admin/portfolio-categories-manager.tsx @@ -8,15 +8,14 @@ import { Hash, Layers3, Pencil, - Sparkles, + ShieldCheck, + ShieldOff, Text, Trash2, } from "lucide-react"; import type { deleteCategoryAction, upsertCategoryAction } from "@/app/_admin/portfolio/actions"; -import { StatsCard } from "@/components/dashboard/stats-card"; import { AppCard } from "@/components/ui/app-card"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { CardContent } from "@/components/ui/card"; @@ -57,6 +56,10 @@ const copy = { empty: "No categories yet.", deleteBlocked: "Delete becomes available only when no projects are assigned.", editCategory: "Edit Category", + visibilityActive: "Visible on public portfolio filters.", + visibilityInactive: "Hidden from public portfolio filters.", + statusSection: "Visibility & Status", + statusHint: "Control whether the category can be assigned and shown publicly.", }; type CategoryAction = typeof upsertCategoryAction; @@ -145,6 +148,56 @@ function CategoryLocaleFields({ ); } +function CategoryStatusFields({ + formId, + values, +}: { + formId: string; + values?: CategoryFormValues; +}) { + const isActive = values?.isActive ?? true; + + return ( +
+
+

{copy.statusSection}

+

{copy.statusHint}

+
+ +
+ +
+ {isActive ? ( + + ) : ( + + )} +
+
+

{copy.active}

+ + {isActive ? "Public" : "Hidden"} + +
+

+ {isActive ? copy.visibilityActive : copy.visibilityInactive} +

+
+
+
+ + +
+
+ ); +} + function CategoryForm({ formId, action, @@ -200,17 +253,16 @@ function CategoryForm({ /> - - + + + + ); } @@ -232,7 +284,7 @@ function EditCategoryDialog({ return ( - + {copy.editCategory} {copy.editDescription} @@ -247,7 +299,7 @@ function EditCategoryDialog({

Status

- {category.isActive ? "Active" : "Inactive"} + {category.isActive ? "Public" : "Hidden"}
@@ -276,7 +328,9 @@ function EditCategoryDialog({

- {category.projectCount > 0 ? copy.deleteBlocked : "Category can be deleted."} + {category.projectCount > 0 + ? `Delete blocked. ${copy.deleteBlocked}` + : "Category can be deleted."}

@@ -316,60 +370,69 @@ export function PortfolioCategoriesManager({ }, [searchParams]); return ( -
- - -
- - - -
+
+
+
+ + Total: + {" "} + {categories.length} + + + Active: + {" "} + {activeCount} + + + Assigned: + {" "} + {assignedProjects} + +
- - - - - - - {copy.addCategory} - {copy.modalDescription} - + + + + + + + {copy.addCategory} + {copy.modalDescription} + +
- - -

Start with basics

-

Slug and sort order first.

-
- - -

Fill all locales

-

Keep names and descriptions complete.

-
- - -

Publish when ready

-

Categories stay manageable from day one.

-
+
+

1. Basics

+

Slug and sort order first.

+
+
+

2. Localization

+

Names and descriptions in all locales.

+
+
+

3. Visibility

+

Decide if the category is public or hidden.

+
+
- + - - - -
-
- - + + + +
+
+
@@ -381,66 +444,51 @@ export function PortfolioCategoriesManager({ {categories.length === 0 ? (

{copy.empty}

) : ( - +
{categories.map((category) => ( - - -
-
-
- - {category.name.de || category.name.en || category.name.ar} - - - {category.isActive ? "Active" : "Inactive"} - -
-
- {category.slug} - {copy.projects}: {category.projectCount} - {copy.sortOrder}: {category.sortOrder} -
+ +
+
+
+ + {category.name.de || category.name.en || category.name.ar} + + + {category.isActive ? "Active" : "Inactive"} + +
+
+ {category.slug} + {copy.projects}: {category.projectCount} + {copy.sortOrder}: {category.sortOrder}
- - -
- - -
- {locales.map((locale) => ( - -

{locale.label}

-

- {category.name[locale.key.toLowerCase() as "ar" | "en" | "de"]} -

-

- {category.description[locale.key.toLowerCase() as "ar" | "en" | "de"]} -

-
- ))}
- setEditingCategoryId(open ? category.id : null)} - saveCategoryAction={saveCategoryAction} - removeCategoryAction={removeCategoryAction} - /> -
- + +
+ + setEditingCategoryId(open ? category.id : null)} + saveCategoryAction={saveCategoryAction} + removeCategoryAction={removeCategoryAction} + /> +
))} - +
)} diff --git a/components/admin/portfolio-project-actions.tsx b/components/admin/portfolio-project-actions.tsx index d69cfd9..8971ab6 100644 --- a/components/admin/portfolio-project-actions.tsx +++ b/components/admin/portfolio-project-actions.tsx @@ -21,6 +21,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { getAdminAppPath } from "@/lib/admin-routing"; const copy = { editProject: "Bearbeiten", @@ -40,7 +41,7 @@ export function PortfolioProjectActions({ projectId }: { projectId: string }) { - + {copy.editProject} diff --git a/components/admin/portfolio-project-form.tsx b/components/admin/portfolio-project-form.tsx index 51fdbf7..25e0047 100644 --- a/components/admin/portfolio-project-form.tsx +++ b/components/admin/portfolio-project-form.tsx @@ -5,6 +5,11 @@ import { ArrowDown, ArrowUp, CalendarDays, + CheckCircle2, + ChevronLeft, + ChevronRight, + CircleAlert, + CircleDashed, FolderTree, Layers3, Link2, @@ -13,7 +18,9 @@ import { Trash2, UserRound, } from "lucide-react"; +import Link from "next/link"; import { type ReactNode, useEffect, useRef, useState } from "react"; +import { useFormStatus } from "react-dom"; import { MediaFieldPicker, type MediaFieldState } from "@/components/admin/media-field-picker"; import { AppCard } from "@/components/ui/app-card"; @@ -28,8 +35,17 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { moveArrayItem } from "@/lib/array"; +import { getAdminAppPath } from "@/lib/admin-routing"; +import { + getFirstIncompleteWizardStep, + getPortfolioWizardProgress, + isPortfolioAssetReady, + isPortfolioSectionReady, + type PortfolioWizardStep, +} from "@/lib/portfolio-form-progress"; import type { MediaOption } from "@/lib/media"; import type { PortfolioCategoryView, PortfolioProjectView } from "@/lib/portfolio"; import { cn } from "@/lib/utils"; @@ -115,6 +131,33 @@ const viewModeOptions: Array<{ { value: "CASE_STUDY", label: "Case Study", description: "Structured challenge and result view." }, ]; +const wizardSteps: Array<{ + key: PortfolioWizardStep; + label: string; + description: string; +}> = [ + { + key: "basics", + label: "Basics", + description: "Category, slug, client, year, and status.", + }, + { + key: "content", + label: "Localized Content", + description: "Title, service label, and summary in all locales.", + }, + { + key: "sections", + label: "Sections", + description: "Build the project story with ordered content blocks.", + }, + { + key: "assets", + label: "Assets & Media", + description: "Cover and gallery assets from the media library.", + }, +]; + function createMediaFieldState(params: { kind: "IMAGE"; assetId?: string | null; @@ -130,6 +173,21 @@ function createMediaFieldState(params: { }; } +function createAvailableCategories( + categories: PortfolioCategoryView[], + project: PortfolioProjectView | null | undefined, +) { + if (!project) { + return categories; + } + + if (categories.some((category) => category.id === project.category.id)) { + return categories; + } + + return [project.category, ...categories]; +} + function createInitialState( project: PortfolioProjectView | null | undefined, categories: PortfolioCategoryView[], @@ -156,6 +214,55 @@ function createInitialState( }; } +function createInitialSections(project: PortfolioProjectView | null | undefined): SectionFormValue[] { + if (!project?.sections.length) { + return [createEmptySection(0)]; + } + + return project.sections.map((section, index) => ({ + id: section.id, + type: section.type, + titleAr: section.title.ar, + titleEn: section.title.en, + titleDe: section.title.de, + bodyAr: section.body.ar, + bodyEn: section.body.en, + bodyDe: section.body.de, + imagePath: section.imagePath ?? "", + media: createMediaFieldState({ + kind: "IMAGE", + assetId: section.mediaAssetId, + url: section.imagePath, + label: section.title.de || section.title.en || section.title.ar, + }), + linkUrl: section.linkUrl ?? "", + sortOrder: index, + })); +} + +function createInitialAssets(project: PortfolioProjectView | null | undefined): AssetFormValue[] { + if (!project?.assets.length) { + return [createEmptyAsset(0)]; + } + + return project.assets.map((asset, index) => ({ + id: asset.id, + kind: "IMAGE", + filePath: asset.filePath, + fileFieldName: `asset-upload-${index}`, + media: createMediaFieldState({ + kind: "IMAGE", + assetId: asset.mediaAssetId, + url: asset.filePath, + label: asset.alt.de || asset.alt.en || asset.alt.ar, + }), + altAr: asset.alt.ar, + altEn: asset.alt.en, + altDe: asset.alt.de, + sortOrder: index, + })); +} + function createEmptySection(index: number): SectionFormValue { return { type: "RICH_TEXT", @@ -185,30 +292,6 @@ function createEmptyAsset(index: number): AssetFormValue { }; } -function hasText(value: string) { - return value.trim().length > 0; -} - -function sectionReady(section: SectionFormValue) { - if (!hasText(section.titleAr) || !hasText(section.titleEn) || !hasText(section.titleDe)) { - return false; - } - - if (section.type === "GALLERY") { - return hasText(section.media.assetId); - } - - if (section.type === "LINK") { - return hasText(section.linkUrl); - } - - return hasText(section.bodyAr) && hasText(section.bodyEn) && hasText(section.bodyDe); -} - -function assetReady(asset: AssetFormValue) { - return hasText(asset.media.assetId) && hasText(asset.altAr) && hasText(asset.altEn) && hasText(asset.altDe); -} - function LocaleInputs({ title, namePrefix, @@ -270,6 +353,40 @@ function SectionHeader({ ); } +function StepStateBadge({ + complete, + active, + showValidation, +}: { + complete: boolean; + active: boolean; + showValidation: boolean; +}) { + if (complete) { + return Complete; + } + + if (showValidation) { + return Needs Attention; + } + + if (active) { + return Current; + } + + return Incomplete; +} + +function SubmitButton() { + const { pending } = useFormStatus(); + + return ( + + ); +} + export function PortfolioProjectForm({ action, categories, @@ -278,7 +395,32 @@ export function PortfolioProjectForm({ formId, redirectPath, }: PortfolioProjectFormProps) { - const [projectState, setProjectState] = useState(createInitialState(project, categories)); + const availableCategories = createAvailableCategories(categories, project); + const initialProjectState = createInitialState(project, availableCategories); + const initialSections = createInitialSections(project); + const initialAssets = createInitialAssets(project); + const initialProgress = getPortfolioWizardProgress({ + basics: initialProjectState, + content: initialProjectState, + sections: initialSections.map((section) => ({ + type: section.type, + titleAr: section.titleAr, + titleEn: section.titleEn, + titleDe: section.titleDe, + bodyAr: section.bodyAr, + bodyEn: section.bodyEn, + bodyDe: section.bodyDe, + linkUrl: section.linkUrl, + mediaAssetId: section.media.assetId, + })), + assets: initialAssets.map((asset) => ({ + mediaAssetId: asset.media.assetId, + altAr: asset.altAr, + altEn: asset.altEn, + altDe: asset.altDe, + })), + }); + const [projectState, setProjectState] = useState(initialProjectState); const [coverMedia, setCoverMedia] = useState( createMediaFieldState({ kind: "IMAGE", @@ -287,54 +429,48 @@ export function PortfolioProjectForm({ label: project?.title.de ?? project?.title.en ?? project?.title.ar ?? "", }), ); - const [sections, setSections] = useState( - project?.sections.length - ? project.sections.map((section, index) => ({ - id: section.id, - type: section.type, - titleAr: section.title.ar, - titleEn: section.title.en, - titleDe: section.title.de, - bodyAr: section.body.ar, - bodyEn: section.body.en, - bodyDe: section.body.de, - imagePath: section.imagePath ?? "", - media: createMediaFieldState({ - kind: "IMAGE", - assetId: section.mediaAssetId, - url: section.imagePath, - label: section.title.de || section.title.en || section.title.ar, - }), - linkUrl: section.linkUrl ?? "", - sortOrder: index, - })) - : [createEmptySection(0)], - ); - const [assets, setAssets] = useState( - project?.assets.length - ? project.assets.map((asset, index) => ({ - id: asset.id, - kind: "IMAGE", - filePath: asset.filePath, - fileFieldName: `asset-upload-${index}`, - media: createMediaFieldState({ - kind: "IMAGE", - assetId: asset.mediaAssetId, - url: asset.filePath, - label: asset.alt.de || asset.alt.en || asset.alt.ar, - }), - altAr: asset.alt.ar, - altEn: asset.alt.en, - altDe: asset.alt.de, - sortOrder: index, - })) - : [createEmptyAsset(0)], + const [sections, setSections] = useState(initialSections); + const [assets, setAssets] = useState(initialAssets); + const [currentStep, setCurrentStep] = useState( + getFirstIncompleteWizardStep(initialProgress) ?? "basics", ); + const [showValidation, setShowValidation] = useState(false); const sectionsInputRef = useRef(null); const assetsInputRef = useRef(null); const sectionsPayload = JSON.stringify(sections.map((section, index) => ({ ...section, sortOrder: index }))); const assetsPayload = JSON.stringify(assets.map((asset, index) => ({ ...asset, sortOrder: index }))); + const progress = getPortfolioWizardProgress({ + basics: projectState, + content: projectState, + sections: sections.map((section) => ({ + type: section.type, + titleAr: section.titleAr, + titleEn: section.titleEn, + titleDe: section.titleDe, + bodyAr: section.bodyAr, + bodyEn: section.bodyEn, + bodyDe: section.bodyDe, + linkUrl: section.linkUrl, + mediaAssetId: section.media.assetId, + })), + assets: assets.map((asset) => ({ + mediaAssetId: asset.media.assetId, + altAr: asset.altAr, + altEn: asset.altEn, + altDe: asset.altDe, + })), + }); + const firstIncompleteStep = getFirstIncompleteWizardStep(progress); + const currentStepIndex = wizardSteps.findIndex((step) => step.key === currentStep); + const selectedCategory = + availableCategories.find((category) => category.id === projectState.categoryId) ?? null; + const projectLabel = + projectState.titleDe.trim() || + projectState.titleEn.trim() || + projectState.titleAr.trim() || + projectState.slug.trim() || + "Untitled Project"; useEffect(() => { sectionsInputRef.current?.dispatchEvent(new Event("input", { bubbles: true })); @@ -346,8 +482,45 @@ export function PortfolioProjectForm({ assetsInputRef.current?.dispatchEvent(new Event("change", { bubbles: true })); }, [assetsPayload]); + if (!project && availableCategories.length === 0) { + return ( + +
+
+

Portfolio Setup

+

Create a category before the first project.

+

+ Projects require one active category. Start with category basics, then return here to build the project. +

+
+
+ + +
+
+
+ ); + } + return ( - + { + if (!firstIncompleteStep) { + return; + } + + event.preventDefault(); + setShowValidation(true); + setCurrentStep(firstIncompleteStep); + }} + > @@ -355,316 +528,707 @@ export function PortfolioProjectForm({ -
- + +
+
+
+
+ + {projectState.isPublished ? "Published" : "Draft"} + + {projectState.isFeatured ? Featured : null} + {!selectedCategory?.isActive ? Inactive Category : null} +
+
+

{projectLabel}

+

+ {selectedCategory ? `${selectedCategory.name.de || selectedCategory.name.en || selectedCategory.name.ar}` : "No category selected"} +

+
+
-
-
- -
- - -
-
- -
- -
- - setProjectState((current) => ({ ...current, slug: event.target.value }))} className="pl-9" /> -
-
- -
- -
- - setProjectState((current) => ({ ...current, clientName: event.target.value }))} className="pl-9" /> -
-
- -
- -
- - setProjectState((current) => ({ ...current, projectYear: event.target.value }))} className="pl-9" /> -
-
- -
- -
- - setProjectState((current) => ({ ...current, previewUrl: event.target.value }))} className="pl-9" /> -
-
- -
- -
- - setProjectState((current) => ({ ...current, sortOrder: event.target.value }))} className="pl-9" /> -
-
-
- -
- {viewModeOptions.map((option) => ( - - ))} -
- -
- - -
-
- -
- - - setProjectState((current) => ({ ...current, [`title${key}`]: value }))} - /> - - setProjectState((current) => ({ ...current, [`serviceLabel${key}`]: value }))} - /> - - setProjectState((current) => ({ ...current, [`summary${key}`]: value }))} - multiline - /> -
- -
- - - -
- -
- setSections((current) => [...current, createEmptySection(current.length)])}> - - Add Section - - )} - /> - -
- {sections.map((section, index) => ( -
-
-
- - {sectionReady(section) ? "Ready" : "Open"} - -

{`Section ${index + 1}`}

-
-
- - - -
-
- -
-
- - -
- - {section.type === "LINK" ? ( -
- - setSections((current) => current.map((item, currentIndex) => currentIndex === index ? { ...item, linkUrl: event.target.value } : item))} /> +
+
+
+ + {index + 1} + +
+

{step.label}

+

{step.description}

+
+
+ {stepProgress?.complete ? ( + + ) : ( + + )}
- ) : null} +
+ +

{stepProgress?.summary}

+
+
+ + ); + })} + + + + +
+ + +
+
+ +
+ + +
+
+ +
+ +
+ + + setProjectState((current) => ({ ...current, slug: event.target.value })) + } + className="pl-9" + /> +
+
+ +
+ +
+ + + setProjectState((current) => ({ ...current, clientName: event.target.value })) + } + className="pl-9" + /> +
+
+ +
+ +
+ + + setProjectState((current) => ({ ...current, projectYear: event.target.value })) + } + className="pl-9" + /> +
+
+ +
+ +
+ + + setProjectState((current) => ({ ...current, previewUrl: event.target.value })) + } + className="pl-9" + /> +
+
+ +
+ +
+ + + setProjectState((current) => ({ ...current, sortOrder: event.target.value })) + } + className="pl-9" + /> +
+
- {section.type === "GALLERY" ? ( +
+ {viewModeOptions.map((option) => ( + + ))} +
+ +
+ + +
+
+
+
+ + + +
+ + + + setProjectState((current) => ({ ...current, [`title${key}`]: value })) + } + /> + + + setProjectState((current) => ({ ...current, [`serviceLabel${key}`]: value })) + } + /> + + + setProjectState((current) => ({ ...current, [`summary${key}`]: value })) + } + multiline + /> +
+
+
+ + + +
+ setSections((current) => [...current, createEmptySection(current.length)])} + > + + Add Section + + )} + /> + +
+ {sections.map((section, index) => ( +
+
+
+ + {isPortfolioSectionReady({ + type: section.type, + titleAr: section.titleAr, + titleEn: section.titleEn, + titleDe: section.titleDe, + bodyAr: section.bodyAr, + bodyEn: section.bodyEn, + bodyDe: section.bodyDe, + linkUrl: section.linkUrl, + mediaAssetId: section.media.assetId, + }) + ? "Ready" + : "Open"} + +

{`Section ${index + 1}`}

+
+ +
+ + + +
+
+ +
+
+ + +
+ + {section.type === "LINK" ? ( +
+ + + setSections((current) => + current.map((item, currentIndex) => + currentIndex === index + ? { ...item, linkUrl: event.target.value } + : item, + ), + ) + } + /> +
+ ) : null} +
+ + {section.type === "GALLERY" ? ( + + setSections((current) => + current.map((item, currentIndex) => + currentIndex === index + ? { ...item, media, imagePath: media.url } + : item, + ), + ) + } + options={mediaOptions} + hasInitialValue={Boolean(section.media.assetId || section.imagePath)} + inputName={`section-media-${index}`} + fileFieldName={`section-image-upload-${index}`} + allowClear + clearLabel="Remove Image" + emptyValue={{ mode: "upload", assetId: "", url: "", label: "" }} + /> + ) : null} + + + setSections((current) => + current.map((item, currentIndex) => + currentIndex === index ? { ...item, [`title${key}`]: value } : item, + ), + ) + } + /> + + {section.type !== "GALLERY" && section.type !== "LINK" ? ( + + setSections((current) => + current.map((item, currentIndex) => + currentIndex === index ? { ...item, [`body${key}`]: value } : item, + ), + ) + } + multiline + /> + ) : null} +
+ ))} +
+
+
+
+ + +
+ +
+ + setSections((current) => current.map((item, currentIndex) => currentIndex === index ? { ...item, media, imagePath: media.url } : item))} + title="Cover" + value={coverMedia} + onChange={setCoverMedia} options={mediaOptions} - hasInitialValue={Boolean(section.media.assetId || section.imagePath)} - inputName={`section-media-${index}`} - fileFieldName={`section-image-upload-${index}`} + hasInitialValue={Boolean(project?.coverImagePath || project?.coverMediaAssetId)} + inputName="coverMedia" + fileFieldName="coverFile" allowClear - clearLabel="Remove Image" + clearLabel="Remove Cover" emptyValue={{ mode: "upload", assetId: "", url: "", label: "" }} /> - ) : null} +
+
- setSections((current) => current.map((item, currentIndex) => currentIndex === index ? { ...item, [`title${key}`]: value } : item))} - /> - - {section.type !== "GALLERY" && section.type !== "LINK" ? ( - setSections((current) => current.map((item, currentIndex) => currentIndex === index ? { ...item, [`body${key}`]: value } : item))} - multiline + +
+ setAssets((current) => [...current, createEmptyAsset(current.length)])} + > + + Add Asset + + )} /> - ) : null} -
- ))} -
-
-
- setAssets((current) => [...current, createEmptyAsset(current.length)])}> - - Add Asset +
+ {assets.map((asset, index) => ( +
+
+
+ + {isPortfolioAssetReady({ + mediaAssetId: asset.media.assetId, + altAr: asset.altAr, + altEn: asset.altEn, + altDe: asset.altDe, + }) + ? "Ready" + : "Open"} + +

{`Asset ${index + 1}`}

+
+ +
+ + + +
+
+ + + setAssets((current) => + current.map((item, currentIndex) => + currentIndex === index + ? { ...item, media, filePath: media.url } + : item, + ), + ) + } + options={mediaOptions} + hasInitialValue={Boolean(asset.media.assetId || asset.filePath)} + inputName={`asset-media-${index}`} + fileFieldName={asset.fileFieldName} + allowClear + clearLabel="Remove Asset" + emptyValue={{ mode: "upload", assetId: "", url: "", label: "" }} + /> + + + setAssets((current) => + current.map((item, currentIndex) => + currentIndex === index ? { ...item, [`alt${key}`]: value } : item, + ), + ) + } + /> +
+ ))} +
+
+ +
+ + + + +
+
+ - )} - /> + +
-
- {assets.map((asset, index) => ( -
-
-
- - {assetReady(asset) ? "Ready" : "Open"} - -

{`Asset ${index + 1}`}

-
-
- - - -
-
- - setAssets((current) => current.map((item, currentIndex) => currentIndex === index ? { ...item, media, filePath: media.url } : item))} - options={mediaOptions} - hasInitialValue={Boolean(asset.media.assetId || asset.filePath)} - inputName={`asset-media-${index}`} - fileFieldName={asset.fileFieldName} - allowClear - clearLabel="Remove Asset" - emptyValue={{ mode: "upload", assetId: "", url: "", label: "" }} - /> - - setAssets((current) => current.map((item, currentIndex) => currentIndex === index ? { ...item, [`alt${key}`]: value } : item))} - /> -
- ))} +
+ {firstIncompleteStep ? ( +

+ Save becomes available after all steps are complete. +

+ ) : ( +

+ All steps are ready. You can save now. +

+ )} + +
- - -
- -
+ ); } diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx index 404b848..cf742e2 100644 --- a/components/ui/tabs.tsx +++ b/components/ui/tabs.tsx @@ -22,16 +22,30 @@ function useTabsContext() { } type TabsProps = { - defaultValue: string; + defaultValue?: string; + value?: string; + onValueChange?: (value: string) => void; children: React.ReactNode; className?: string; }; -function Tabs({ defaultValue, children, className }: TabsProps) { - const [value, setValue] = React.useState(defaultValue); +function Tabs({ defaultValue, value, onValueChange, children, className }: TabsProps) { + const [internalValue, setInternalValue] = React.useState(defaultValue ?? ""); + const activeValue = value ?? internalValue; + + const setValue = React.useCallback( + (nextValue: string) => { + if (value === undefined) { + setInternalValue(nextValue); + } + + onValueChange?.(nextValue); + }, + [onValueChange, value], + ); return ( - +
{children}
); diff --git a/lib/admin-navigation.ts b/lib/admin-navigation.ts index aae65c6..98993ba 100644 --- a/lib/admin-navigation.ts +++ b/lib/admin-navigation.ts @@ -138,7 +138,7 @@ export function getAdminNavigation( active: portfolioChild === "new-project", }, { - label: "Add Category", + label: "Categories", href: getAdminAppPath("/portfolio/categories"), icon: Tags, active: portfolioChild === "categories", diff --git a/lib/portfolio-form-progress.ts b/lib/portfolio-form-progress.ts new file mode 100644 index 0000000..349e54d --- /dev/null +++ b/lib/portfolio-form-progress.ts @@ -0,0 +1,154 @@ +import type { PortfolioProjectViewMode, PortfolioSectionType } from "@prisma/client"; + +export type PortfolioWizardStep = "basics" | "content" | "sections" | "assets"; + +export type PortfolioWizardStepState = { + key: PortfolioWizardStep; + complete: boolean; + summary: string; +}; + +type ProjectBasicsProgressInput = { + categoryId: string; + slug: string; + clientName: string; + projectYear: string; + sortOrder: string; + viewMode: PortfolioProjectViewMode; +}; + +type ProjectContentProgressInput = { + titleAr: string; + titleEn: string; + titleDe: string; + serviceLabelAr: string; + serviceLabelEn: string; + serviceLabelDe: string; + summaryAr: string; + summaryEn: string; + summaryDe: string; +}; + +type ProjectSectionProgressInput = { + type: PortfolioSectionType; + titleAr: string; + titleEn: string; + titleDe: string; + bodyAr: string; + bodyEn: string; + bodyDe: string; + linkUrl: string; + mediaAssetId: string; +}; + +type ProjectAssetProgressInput = { + mediaAssetId: string; + altAr: string; + altEn: string; + altDe: string; +}; + +type PortfolioWizardProgressInput = { + basics: ProjectBasicsProgressInput; + content: ProjectContentProgressInput; + sections: ProjectSectionProgressInput[]; + assets: ProjectAssetProgressInput[]; +}; + +function hasText(value: string) { + return value.trim().length > 0; +} + +function isValidSlug(value: string) { + return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value.trim()); +} + +function isValidYear(value: string) { + const year = Number(value); + + return Number.isInteger(year) && year >= 2000 && year <= 2100; +} + +function isValidSortOrder(value: string) { + const sortOrder = Number(value); + + return Number.isInteger(sortOrder) && sortOrder >= 0 && sortOrder <= 9999; +} + +export function isPortfolioSectionReady(section: ProjectSectionProgressInput) { + const hasTitles = hasText(section.titleAr) && hasText(section.titleEn) && hasText(section.titleDe); + + if (!hasTitles) { + return false; + } + + if (section.type === "GALLERY") { + return hasText(section.mediaAssetId); + } + + if (section.type === "LINK") { + return hasText(section.linkUrl); + } + + return hasText(section.bodyAr) && hasText(section.bodyEn) && hasText(section.bodyDe); +} + +export function isPortfolioAssetReady(asset: ProjectAssetProgressInput) { + return ( + hasText(asset.mediaAssetId) && + hasText(asset.altAr) && + hasText(asset.altEn) && + hasText(asset.altDe) + ); +} + +export function getPortfolioWizardProgress( + input: PortfolioWizardProgressInput, +): PortfolioWizardStepState[] { + const completedSections = input.sections.filter(isPortfolioSectionReady).length; + const completedAssets = input.assets.filter(isPortfolioAssetReady).length; + + return [ + { + key: "basics", + complete: + hasText(input.basics.categoryId) && + isValidSlug(input.basics.slug) && + hasText(input.basics.clientName) && + isValidYear(input.basics.projectYear) && + isValidSortOrder(input.basics.sortOrder) && + Boolean(input.basics.viewMode), + summary: "Category, slug, client, year, and order are set.", + }, + { + key: "content", + complete: + hasText(input.content.titleAr) && + hasText(input.content.titleEn) && + hasText(input.content.titleDe) && + hasText(input.content.serviceLabelAr) && + hasText(input.content.serviceLabelEn) && + hasText(input.content.serviceLabelDe) && + hasText(input.content.summaryAr) && + hasText(input.content.summaryEn) && + hasText(input.content.summaryDe), + summary: "Title, service label, and summary exist in all locales.", + }, + { + key: "sections", + complete: input.sections.length > 0 && completedSections === input.sections.length, + summary: `${completedSections}/${input.sections.length} sections ready.`, + }, + { + key: "assets", + complete: input.assets.length > 0 && completedAssets === input.assets.length, + summary: `${completedAssets}/${input.assets.length} assets ready.`, + }, + ]; +} + +export function getFirstIncompleteWizardStep( + steps: PortfolioWizardStepState[], +): PortfolioWizardStep | null { + return steps.find((step) => !step.complete)?.key ?? null; +} diff --git a/tests/portfolio-validation.test.ts b/tests/portfolio-validation.test.ts index 64f6d45..5350bc7 100644 --- a/tests/portfolio-validation.test.ts +++ b/tests/portfolio-validation.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; +import { + getFirstIncompleteWizardStep, + getPortfolioWizardProgress, + isPortfolioAssetReady, + isPortfolioSectionReady, +} from "../lib/portfolio-form-progress"; import { assetInputSchema, categoryInputSchema, @@ -203,4 +209,87 @@ describe("portfolio validation", () => { }), ).toThrow(/link/i); }); + + it("calculates wizard progress and points to the first incomplete step", () => { + const progress = getPortfolioWizardProgress({ + basics: { + categoryId: "cat_1", + slug: "case-study-entry", + clientName: "Client", + projectYear: "2025", + sortOrder: "1", + viewMode: "GRID", + }, + content: { + titleAr: "عنوان", + titleEn: "Title", + titleDe: "Titel", + serviceLabelAr: "خدمة", + serviceLabelEn: "Service", + serviceLabelDe: "Service", + summaryAr: "", + summaryEn: "", + summaryDe: "", + }, + sections: [ + { + type: "RICH_TEXT", + titleAr: "العنوان", + titleEn: "Title", + titleDe: "Titel", + bodyAr: "النص", + bodyEn: "Body", + bodyDe: "Text", + linkUrl: "", + mediaAssetId: "", + }, + ], + assets: [ + { + mediaAssetId: "asset_1", + altAr: "بديل", + altEn: "Alt", + altDe: "Alt", + }, + ], + }); + + expect(progress.find((step) => step.key === "basics")?.complete).toBe(true); + expect(progress.find((step) => step.key === "content")?.complete).toBe(false); + expect(getFirstIncompleteWizardStep(progress)).toBe("content"); + }); + + it("marks sections and assets ready only when required fields are present", () => { + expect( + isPortfolioSectionReady({ + type: "LINK", + titleAr: "رابط", + titleEn: "Link", + titleDe: "Link", + bodyAr: "", + bodyEn: "", + bodyDe: "", + linkUrl: "https://example.com", + mediaAssetId: "", + }), + ).toBe(true); + + expect( + isPortfolioAssetReady({ + mediaAssetId: "asset_1", + altAr: "بديل", + altEn: "Alt", + altDe: "Alt", + }), + ).toBe(true); + + expect( + isPortfolioAssetReady({ + mediaAssetId: "", + altAr: "بديل", + altEn: "Alt", + altDe: "Alt", + }), + ).toBe(false); + }); });