@@ -8,15 +8,14 @@ import {
|
|||||||
Hash,
|
Hash,
|
||||||
Layers3,
|
Layers3,
|
||||||
Pencil,
|
Pencil,
|
||||||
Sparkles,
|
ShieldCheck,
|
||||||
|
ShieldOff,
|
||||||
Text,
|
Text,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import type { deleteCategoryAction, upsertCategoryAction } from "@/app/_admin/portfolio/actions";
|
import type { deleteCategoryAction, upsertCategoryAction } from "@/app/_admin/portfolio/actions";
|
||||||
import { StatsCard } from "@/components/dashboard/stats-card";
|
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
@@ -57,6 +56,10 @@ const copy = {
|
|||||||
empty: "No categories yet.",
|
empty: "No categories yet.",
|
||||||
deleteBlocked: "Delete becomes available only when no projects are assigned.",
|
deleteBlocked: "Delete becomes available only when no projects are assigned.",
|
||||||
editCategory: "Edit Category",
|
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;
|
type CategoryAction = typeof upsertCategoryAction;
|
||||||
@@ -145,6 +148,56 @@ function CategoryLocaleFields({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CategoryStatusFields({
|
||||||
|
formId,
|
||||||
|
values,
|
||||||
|
}: {
|
||||||
|
formId: string;
|
||||||
|
values?: CategoryFormValues;
|
||||||
|
}) {
|
||||||
|
const isActive = values?.isActive ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-semibold text-foreground">{copy.statusSection}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{copy.statusHint}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
|
||||||
|
<AppCard level={2} padding="sm" className="rounded-nested">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{isActive ? (
|
||||||
|
<ShieldCheck className="mt-0.5 h-4 w-4 text-status-success" />
|
||||||
|
) : (
|
||||||
|
<ShieldOff className="mt-0.5 h-4 w-4 text-status-warning" />
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-foreground">{copy.active}</p>
|
||||||
|
<Badge variant={isActive ? "success" : "warning"}>
|
||||||
|
{isActive ? "Public" : "Hidden"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{isActive ? copy.visibilityActive : copy.visibilityInactive}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<label
|
||||||
|
htmlFor={`${formId}-isActive`}
|
||||||
|
className="flex items-center gap-3 rounded-nested border border-input bg-card px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<Checkbox id={`${formId}-isActive`} name="isActive" defaultChecked={isActive} />
|
||||||
|
{copy.active}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CategoryForm({
|
function CategoryForm({
|
||||||
formId,
|
formId,
|
||||||
action,
|
action,
|
||||||
@@ -200,17 +253,16 @@ function CategoryForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 rounded-nested border border-input bg-card px-4 py-3 text-sm">
|
|
||||||
<Checkbox name="isActive" defaultChecked={values?.isActive ?? true} />
|
|
||||||
{copy.active}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<CategoryLocaleFields idPrefix={formId} values={values} />
|
<CategoryLocaleFields idPrefix={formId} values={values} />
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<CategoryStatusFields formId={formId} values={values} />
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -232,7 +284,7 @@ function EditCategoryDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{copy.editCategory}</DialogTitle>
|
<DialogTitle>{copy.editCategory}</DialogTitle>
|
||||||
<DialogDescription>{copy.editDescription}</DialogDescription>
|
<DialogDescription>{copy.editDescription}</DialogDescription>
|
||||||
@@ -247,7 +299,7 @@ function EditCategoryDialog({
|
|||||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Status</p>
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Status</p>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<Badge variant={category.isActive ? "success" : "warning"}>
|
<Badge variant={category.isActive ? "success" : "warning"}>
|
||||||
{category.isActive ? "Active" : "Inactive"}
|
{category.isActive ? "Public" : "Hidden"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
@@ -276,7 +328,9 @@ function EditCategoryDialog({
|
|||||||
|
|
||||||
<DialogFooter className="items-center justify-between sm:flex-row">
|
<DialogFooter className="items-center justify-between sm:flex-row">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{category.projectCount > 0 ? copy.deleteBlocked : "Category can be deleted."}
|
{category.projectCount > 0
|
||||||
|
? `Delete blocked. ${copy.deleteBlocked}`
|
||||||
|
: "Category can be deleted."}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row">
|
<div className="flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row">
|
||||||
<form action={removeCategoryAction}>
|
<form action={removeCategoryAction}>
|
||||||
@@ -316,60 +370,69 @@ export function PortfolioCategoriesManager({
|
|||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<AppCard level={3}>
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<CardContent className="flex flex-col gap-6 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<span>
|
||||||
<StatsCard title="Total" value={String(categories.length)} />
|
Total:
|
||||||
<StatsCard title="Active" value={String(activeCount)} />
|
{" "}
|
||||||
<StatsCard title="Assigned" value={String(assignedProjects)} />
|
<span className="font-semibold text-foreground">{categories.length}</span>
|
||||||
</div>
|
</span>
|
||||||
|
<span>
|
||||||
|
Active:
|
||||||
|
{" "}
|
||||||
|
<span className="font-semibold text-foreground">{activeCount}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Assigned:
|
||||||
|
{" "}
|
||||||
|
<span className="font-semibold text-foreground">{assignedProjects}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button>
|
<Button>
|
||||||
<FolderPlus className="h-4 w-4" />
|
<FolderPlus className="h-4 w-4" />
|
||||||
{copy.addCategory}
|
{copy.addCategory}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{copy.addCategory}</DialogTitle>
|
<DialogTitle>{copy.addCategory}</DialogTitle>
|
||||||
<DialogDescription>{copy.modalDescription}</DialogDescription>
|
<DialogDescription>{copy.modalDescription}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
<AppCard level={2} padding="sm" className="rounded-nested">
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<AppCard level={2} padding="sm" className="rounded-nested">
|
<div className="space-y-1">
|
||||||
<Sparkles className="h-4 w-4 text-brand-primary" />
|
<p className="text-sm font-medium text-foreground">1. Basics</p>
|
||||||
<p className="mt-3 text-sm font-medium text-foreground">Start with basics</p>
|
<p className="text-sm text-muted-foreground">Slug and sort order first.</p>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Slug and sort order first.</p>
|
</div>
|
||||||
</AppCard>
|
<div className="space-y-1">
|
||||||
<AppCard level={2} padding="sm" className="rounded-nested">
|
<p className="text-sm font-medium text-foreground">2. Localization</p>
|
||||||
<Layers3 className="h-4 w-4 text-brand-primary" />
|
<p className="text-sm text-muted-foreground">Names and descriptions in all locales.</p>
|
||||||
<p className="mt-3 text-sm font-medium text-foreground">Fill all locales</p>
|
</div>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Keep names and descriptions complete.</p>
|
<div className="space-y-1">
|
||||||
</AppCard>
|
<p className="text-sm font-medium text-foreground">3. Visibility</p>
|
||||||
<AppCard level={2} padding="sm" className="rounded-nested">
|
<p className="text-sm text-muted-foreground">Decide if the category is public or hidden.</p>
|
||||||
<Pencil className="h-4 w-4 text-brand-primary" />
|
</div>
|
||||||
<p className="mt-3 text-sm font-medium text-foreground">Publish when ready</p>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Categories stay manageable from day one.</p>
|
|
||||||
</AppCard>
|
|
||||||
</div>
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
<CategoryForm
|
<CategoryForm
|
||||||
formId="portfolio-category-create-form"
|
formId="portfolio-category-create-form"
|
||||||
action={saveCategoryAction}
|
action={saveCategoryAction}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" form="portfolio-category-create-form">
|
<Button type="submit" form="portfolio-category-create-form">
|
||||||
{copy.saveCategory}
|
{copy.saveCategory}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</CardContent>
|
</div>
|
||||||
</AppCard>
|
|
||||||
|
|
||||||
<AppCard level={3}>
|
<AppCard level={3}>
|
||||||
<CardContent className="space-y-4 p-6">
|
<CardContent className="space-y-4 p-6">
|
||||||
@@ -381,66 +444,51 @@ export function PortfolioCategoriesManager({
|
|||||||
{categories.length === 0 ? (
|
{categories.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{copy.empty}</p>
|
<p className="text-sm text-muted-foreground">{copy.empty}</p>
|
||||||
) : (
|
) : (
|
||||||
<Accordion type="single" collapsible className="space-y-3">
|
<div className="grid gap-3">
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<AccordionItem key={category.id} value={category.id}>
|
<AppCard
|
||||||
<AccordionTrigger className="bg-surface-2 hover:no-underline">
|
key={category.id}
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-3 text-left lg:flex-row lg:items-center lg:justify-between">
|
level={2}
|
||||||
<div className="space-y-1">
|
padding="sm"
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
className="rounded-nested"
|
||||||
<span className="text-base font-semibold text-foreground">
|
>
|
||||||
{category.name.de || category.name.en || category.name.ar}
|
<div className="flex min-w-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
</span>
|
<div className="min-w-0 space-y-1">
|
||||||
<Badge variant={category.isActive ? "success" : "warning"}>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{category.isActive ? "Active" : "Inactive"}
|
<span className="truncate text-base font-semibold text-foreground">
|
||||||
</Badge>
|
{category.name.de || category.name.en || category.name.ar}
|
||||||
</div>
|
</span>
|
||||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
<Badge variant={category.isActive ? "success" : "warning"}>
|
||||||
<span>{category.slug}</span>
|
{category.isActive ? "Active" : "Inactive"}
|
||||||
<span>{copy.projects}: {category.projectCount}</span>
|
</Badge>
|
||||||
<span>{copy.sortOrder}: {category.sortOrder}</span>
|
</div>
|
||||||
</div>
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>{category.slug}</span>
|
||||||
|
<span>{copy.projects}: {category.projectCount}</span>
|
||||||
|
<span>{copy.sortOrder}: {category.sortOrder}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setEditingCategoryId(category.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="space-y-4">
|
|
||||||
<div className="grid gap-4 xl:grid-cols-3">
|
|
||||||
{locales.map((locale) => (
|
|
||||||
<AppCard key={`${category.id}-${locale.key}`} level={2} padding="sm" className="space-y-3 rounded-nested">
|
|
||||||
<p className="text-sm font-medium text-foreground">{locale.label}</p>
|
|
||||||
<p className="text-sm font-semibold text-foreground">
|
|
||||||
{category.name[locale.key.toLowerCase() as "ar" | "en" | "de"]}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm leading-6 text-muted-foreground">
|
|
||||||
{category.description[locale.key.toLowerCase() as "ar" | "en" | "de"]}
|
|
||||||
</p>
|
|
||||||
</AppCard>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditCategoryDialog
|
<Button
|
||||||
category={category}
|
type="button"
|
||||||
open={editingCategoryId === category.id}
|
variant="outline"
|
||||||
onOpenChange={(open) => setEditingCategoryId(open ? category.id : null)}
|
onClick={() => setEditingCategoryId(category.id)}
|
||||||
saveCategoryAction={saveCategoryAction}
|
>
|
||||||
removeCategoryAction={removeCategoryAction}
|
<Pencil className="h-4 w-4" />
|
||||||
/>
|
Edit
|
||||||
</AccordionContent>
|
</Button>
|
||||||
</AccordionItem>
|
</div>
|
||||||
|
|
||||||
|
<EditCategoryDialog
|
||||||
|
category={category}
|
||||||
|
open={editingCategoryId === category.id}
|
||||||
|
onOpenChange={(open) => setEditingCategoryId(open ? category.id : null)}
|
||||||
|
saveCategoryAction={saveCategoryAction}
|
||||||
|
removeCategoryAction={removeCategoryAction}
|
||||||
|
/>
|
||||||
|
</AppCard>
|
||||||
))}
|
))}
|
||||||
</Accordion>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { getAdminAppPath } from "@/lib/admin-routing";
|
||||||
|
|
||||||
const copy = {
|
const copy = {
|
||||||
editProject: "Bearbeiten",
|
editProject: "Bearbeiten",
|
||||||
@@ -40,7 +41,7 @@ export function PortfolioProjectActions({ projectId }: { projectId: string }) {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href={`/portfolio/projects/${projectId}`}>
|
<Link href={getAdminAppPath(`/portfolio/projects/${projectId}`)}>
|
||||||
<FolderKanban className="mr-2 h-4 w-4" />
|
<FolderKanban className="mr-2 h-4 w-4" />
|
||||||
{copy.editProject}
|
{copy.editProject}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+18
-4
@@ -22,16 +22,30 @@ function useTabsContext() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TabsProps = {
|
type TabsProps = {
|
||||||
defaultValue: string;
|
defaultValue?: string;
|
||||||
|
value?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function Tabs({ defaultValue, children, className }: TabsProps) {
|
function Tabs({ defaultValue, value, onValueChange, children, className }: TabsProps) {
|
||||||
const [value, setValue] = React.useState(defaultValue);
|
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 (
|
return (
|
||||||
<TabsContext.Provider value={{ value, setValue }}>
|
<TabsContext.Provider value={{ value: activeValue, setValue }}>
|
||||||
<div className={cn("w-full", className)}>{children}</div>
|
<div className={cn("w-full", className)}>{children}</div>
|
||||||
</TabsContext.Provider>
|
</TabsContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export function getAdminNavigation(
|
|||||||
active: portfolioChild === "new-project",
|
active: portfolioChild === "new-project",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Add Category",
|
label: "Categories",
|
||||||
href: getAdminAppPath("/portfolio/categories"),
|
href: getAdminAppPath("/portfolio/categories"),
|
||||||
icon: Tags,
|
icon: Tags,
|
||||||
active: portfolioChild === "categories",
|
active: portfolioChild === "categories",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getFirstIncompleteWizardStep,
|
||||||
|
getPortfolioWizardProgress,
|
||||||
|
isPortfolioAssetReady,
|
||||||
|
isPortfolioSectionReady,
|
||||||
|
} from "../lib/portfolio-form-progress";
|
||||||
import {
|
import {
|
||||||
assetInputSchema,
|
assetInputSchema,
|
||||||
categoryInputSchema,
|
categoryInputSchema,
|
||||||
@@ -203,4 +209,87 @@ describe("portfolio validation", () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow(/link/i);
|
).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user