- Replace animated blob/wave/noise backdrop with clean static gradient (radial ellipse highlights + linear fade) in hero-motion-backdrop - Remove all framer-motion from background layer; delete unused CSS classes: hero-blob, hero-sheet-glow, hero-sheet-wave, hero-sheet-noise, hero-title-shift keyframe, and responsive blob media queries - Add framer-motion stagger entrance to HeroContentMotion, HeroMotionItem, and HeroTitle (slide-up + fade on mount) - Refactor coming-soon page to use HeroContentMotion/HeroMotionItem instead of MotionFade (whileInView unreliable above the fold) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1364 lines
50 KiB
TypeScript
1364 lines
50 KiB
TypeScript
"use client";
|
||
|
||
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@prisma/client";
|
||
import {
|
||
ArrowDown,
|
||
ArrowUp,
|
||
BookOpenText,
|
||
BriefcaseBusiness,
|
||
CalendarDays,
|
||
CheckCircle2,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
CircleAlert,
|
||
CircleDashed,
|
||
Files,
|
||
FolderTree,
|
||
Grid2x2,
|
||
ImagePlus,
|
||
Layers3,
|
||
Link2,
|
||
Plus,
|
||
Text,
|
||
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 { StatsCard } from "@/components/dashboard/stats-card";
|
||
import { AppCard } from "@/components/ui/app-card";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
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";
|
||
|
||
type SectionFormValue = {
|
||
id?: string;
|
||
type: PortfolioSectionType;
|
||
titleAr: string;
|
||
titleEn: string;
|
||
titleDe: string;
|
||
bodyAr: string;
|
||
bodyEn: string;
|
||
bodyDe: string;
|
||
imagePath: string;
|
||
media: MediaFieldState;
|
||
linkUrl: string;
|
||
sortOrder: number;
|
||
};
|
||
|
||
type AssetFormValue = {
|
||
id?: string;
|
||
kind: "IMAGE";
|
||
filePath: string;
|
||
fileFieldName: string;
|
||
media: MediaFieldState;
|
||
altAr: string;
|
||
altEn: string;
|
||
altDe: string;
|
||
sortOrder: number;
|
||
};
|
||
|
||
type ProjectFormState = {
|
||
categoryId: string;
|
||
slug: string;
|
||
clientName: string;
|
||
projectYear: string;
|
||
previewUrl: string;
|
||
sortOrder: string;
|
||
viewMode: PortfolioProjectViewMode;
|
||
isFeatured: boolean;
|
||
isPublished: boolean;
|
||
titleAr: string;
|
||
titleEn: string;
|
||
titleDe: string;
|
||
serviceLabelAr: string;
|
||
serviceLabelEn: string;
|
||
serviceLabelDe: string;
|
||
summaryAr: string;
|
||
summaryEn: string;
|
||
summaryDe: string;
|
||
};
|
||
|
||
type PortfolioProjectFormProps = {
|
||
action: (formData: FormData) => void | Promise<void>;
|
||
categories: PortfolioCategoryView[];
|
||
mediaOptions: MediaOption[];
|
||
project?: PortfolioProjectView | null;
|
||
formId: string;
|
||
redirectPath: string;
|
||
};
|
||
|
||
const locales = [
|
||
{ suffix: "Ar" as const, label: "Arabic" },
|
||
{ suffix: "En" as const, label: "English" },
|
||
{ suffix: "De" as const, label: "German" },
|
||
] as const;
|
||
|
||
const sectionTypeOptions: PortfolioSectionType[] = [
|
||
"RICH_TEXT",
|
||
"GALLERY",
|
||
"STATS",
|
||
"DELIVERABLES",
|
||
"LINK",
|
||
];
|
||
|
||
const sectionTypeLabels: Record<PortfolioSectionType, string> = {
|
||
RICH_TEXT: "Rich Text",
|
||
GALLERY: "Gallery (image)",
|
||
STATS: "Stats (text)",
|
||
DELIVERABLES: "Deliverables (text)",
|
||
LINK: "Link",
|
||
};
|
||
|
||
const viewModeOptions: Array<{
|
||
value: PortfolioProjectViewMode;
|
||
label: string;
|
||
description: string;
|
||
icon: typeof Grid2x2;
|
||
}> = [
|
||
{ value: "GRID", label: "Grid", description: "Balanced modular layout.", icon: Grid2x2 },
|
||
{ value: "STORY", label: "Story", description: "Narrative section flow.", icon: BookOpenText },
|
||
{ value: "CASE_STUDY", label: "Case Study", description: "Structured challenge/solution/outcome view. Sections 1–3 become the lead panels.", icon: BriefcaseBusiness },
|
||
];
|
||
|
||
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;
|
||
url?: string | null;
|
||
label?: string | null;
|
||
}): MediaFieldState {
|
||
return {
|
||
mode: params.assetId ? "library" : "upload",
|
||
assetId: params.assetId ?? "",
|
||
url: params.url ?? "",
|
||
label: params.label ?? "",
|
||
kind: params.kind,
|
||
};
|
||
}
|
||
|
||
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[],
|
||
): ProjectFormState {
|
||
return {
|
||
categoryId: project?.category.id ?? categories[0]?.id ?? "",
|
||
slug: project?.slug ?? "",
|
||
clientName: project?.clientName ?? "",
|
||
projectYear: String(project?.projectYear ?? new Date().getFullYear()),
|
||
previewUrl: project?.previewUrl ?? "",
|
||
sortOrder: String(project?.sortOrder ?? 0),
|
||
viewMode: project?.viewMode ?? "GRID",
|
||
isFeatured: project?.isFeatured ?? false,
|
||
isPublished: project?.isPublished ?? false,
|
||
titleAr: project?.title.ar ?? "",
|
||
titleEn: project?.title.en ?? "",
|
||
titleDe: project?.title.de ?? "",
|
||
serviceLabelAr: project?.serviceLabel.ar ?? "",
|
||
serviceLabelEn: project?.serviceLabel.en ?? "",
|
||
serviceLabelDe: project?.serviceLabel.de ?? "",
|
||
summaryAr: project?.summary.ar ?? "",
|
||
summaryEn: project?.summary.en ?? "",
|
||
summaryDe: project?.summary.de ?? "",
|
||
};
|
||
}
|
||
|
||
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",
|
||
titleAr: "",
|
||
titleEn: "",
|
||
titleDe: "",
|
||
bodyAr: "",
|
||
bodyEn: "",
|
||
bodyDe: "",
|
||
imagePath: "",
|
||
media: createMediaFieldState({ kind: "IMAGE" }),
|
||
linkUrl: "",
|
||
sortOrder: index,
|
||
};
|
||
}
|
||
|
||
function createEmptyAsset(index: number): AssetFormValue {
|
||
return {
|
||
kind: "IMAGE",
|
||
filePath: "",
|
||
fileFieldName: `asset-upload-${index}`,
|
||
media: createMediaFieldState({ kind: "IMAGE" }),
|
||
altAr: "",
|
||
altEn: "",
|
||
altDe: "",
|
||
sortOrder: index,
|
||
};
|
||
}
|
||
|
||
function LocaleInputs({
|
||
title,
|
||
namePrefix,
|
||
values,
|
||
onChange,
|
||
multiline = false,
|
||
}: {
|
||
title: string;
|
||
namePrefix?: string;
|
||
values: { Ar: string; En: string; De: string };
|
||
onChange: (key: "Ar" | "En" | "De", value: string) => void;
|
||
multiline?: boolean;
|
||
}) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="grid gap-3 xl:grid-cols-3">
|
||
{locales.map((locale) => (
|
||
<AppCard
|
||
key={`${title}-${locale.suffix}`}
|
||
level={2}
|
||
layer="single"
|
||
padding="sm"
|
||
className="space-y-2 rounded-nested"
|
||
>
|
||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{locale.label}</p>
|
||
{multiline ? (
|
||
<Textarea
|
||
name={namePrefix ? `${namePrefix}${locale.suffix}` : undefined}
|
||
rows={5}
|
||
placeholder={`${title} ${locale.label}`}
|
||
value={values[locale.suffix]}
|
||
onChange={(event) => onChange(locale.suffix, event.target.value)}
|
||
/>
|
||
) : (
|
||
<Input
|
||
name={namePrefix ? `${namePrefix}${locale.suffix}` : undefined}
|
||
placeholder={`${title} ${locale.label}`}
|
||
value={values[locale.suffix]}
|
||
onChange={(event) => onChange(locale.suffix, event.target.value)}
|
||
/>
|
||
)}
|
||
</AppCard>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SectionHeader({
|
||
title,
|
||
description,
|
||
action,
|
||
}: {
|
||
title: string;
|
||
description: string;
|
||
action?: ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||
<div className="space-y-1">
|
||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||
<p className="text-sm text-muted-foreground">{description}</p>
|
||
</div>
|
||
{action}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StepStateBadge({
|
||
complete,
|
||
active,
|
||
showValidation,
|
||
}: {
|
||
complete: boolean;
|
||
active: boolean;
|
||
showValidation: boolean;
|
||
}) {
|
||
if (complete) {
|
||
return <Badge variant="success">Complete</Badge>;
|
||
}
|
||
|
||
if (showValidation) {
|
||
return <Badge variant="warning">Needs Attention</Badge>;
|
||
}
|
||
|
||
if (active) {
|
||
return <Badge variant="outline">Current</Badge>;
|
||
}
|
||
|
||
return <Badge variant="outline">Incomplete</Badge>;
|
||
}
|
||
|
||
function SubmitButton() {
|
||
const { pending } = useFormStatus();
|
||
|
||
return (
|
||
<Button type="submit" disabled={pending}>
|
||
{pending ? "Saving..." : "Save Project"}
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
function ViewModeCard({
|
||
active,
|
||
label,
|
||
description,
|
||
icon: Icon,
|
||
onClick,
|
||
}: {
|
||
active: boolean;
|
||
label: string;
|
||
description: string;
|
||
icon: typeof Grid2x2;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className={cn(
|
||
"rounded-surface border px-4 py-4 text-left transition-colors",
|
||
active
|
||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||
: "border-border/70 bg-background hover:border-input hover:bg-accent/10",
|
||
)}
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
<div
|
||
className={cn(
|
||
"flex h-10 w-10 items-center justify-center rounded-nested border transition-colors",
|
||
active
|
||
? "border-primary-foreground/20 bg-primary-foreground/10 text-primary-foreground"
|
||
: "border-border/70 bg-surface-2 text-muted-foreground",
|
||
)}
|
||
>
|
||
<Icon className="h-4 w-4" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className={cn("text-sm font-semibold", active ? "text-primary-foreground" : "text-foreground")}>
|
||
{label}
|
||
</p>
|
||
<p className={cn("mt-1 text-sm", active ? "text-primary-foreground/80" : "text-muted-foreground")}>
|
||
{description}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function ToggleField({
|
||
id,
|
||
name,
|
||
checked,
|
||
title,
|
||
description,
|
||
onCheckedChange,
|
||
}: {
|
||
id: string;
|
||
name: string;
|
||
checked: boolean;
|
||
title: string;
|
||
description: string;
|
||
onCheckedChange: (checked: boolean) => void;
|
||
}) {
|
||
return (
|
||
<label
|
||
htmlFor={id}
|
||
className="flex items-start justify-between gap-3 rounded-nested border border-input bg-card px-4 py-3 transition-colors hover:bg-accent/20"
|
||
>
|
||
<div>
|
||
<p className="text-sm font-medium text-foreground">{title}</p>
|
||
<p className="text-xs text-muted-foreground">{description}</p>
|
||
</div>
|
||
<Checkbox
|
||
id={id}
|
||
name={name}
|
||
checked={checked}
|
||
onCheckedChange={(value) => onCheckedChange(value === true)}
|
||
className="mt-0.5"
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
export function PortfolioProjectForm({
|
||
action,
|
||
categories,
|
||
mediaOptions,
|
||
project,
|
||
formId,
|
||
redirectPath,
|
||
}: PortfolioProjectFormProps) {
|
||
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<ProjectFormState>(initialProjectState);
|
||
const [coverMedia, setCoverMedia] = useState<MediaFieldState>(
|
||
createMediaFieldState({
|
||
kind: "IMAGE",
|
||
assetId: project?.coverMediaAssetId,
|
||
url: project?.coverImagePath,
|
||
label: project?.title.de ?? project?.title.en ?? project?.title.ar ?? "",
|
||
}),
|
||
);
|
||
const [sections, setSections] = useState<SectionFormValue[]>(initialSections);
|
||
const [assets, setAssets] = useState<AssetFormValue[]>(initialAssets);
|
||
const [currentStep, setCurrentStep] = useState<PortfolioWizardStep>(
|
||
getFirstIncompleteWizardStep(initialProgress) ?? "basics",
|
||
);
|
||
const [showValidation, setShowValidation] = useState(false);
|
||
const sectionsInputRef = useRef<HTMLInputElement | null>(null);
|
||
const assetsInputRef = useRef<HTMLInputElement | null>(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 }));
|
||
sectionsInputRef.current?.dispatchEvent(new Event("change", { bubbles: true }));
|
||
}, [sectionsPayload]);
|
||
|
||
useEffect(() => {
|
||
assetsInputRef.current?.dispatchEvent(new Event("input", { bubbles: true }));
|
||
assetsInputRef.current?.dispatchEvent(new Event("change", { bubbles: true }));
|
||
}, [assetsPayload]);
|
||
|
||
if (!project && availableCategories.length === 0) {
|
||
return (
|
||
<AppCard level={3} layer="single" padding="lg" className="rounded-surface">
|
||
<div className="space-y-4">
|
||
<div className="space-y-2">
|
||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Portfolio Setup</p>
|
||
<h2 className="text-2xl font-semibold text-foreground">Create a category before the first project.</h2>
|
||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||
Projects require one active category. Start with category basics, then return here to build the project.
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-3">
|
||
<Button asChild>
|
||
<Link href={getAdminAppPath("/portfolio/categories")}>Open Categories</Link>
|
||
</Button>
|
||
<Button asChild variant="outline">
|
||
<Link href={getAdminAppPath("/portfolio")}>Back to Portfolio</Link>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</AppCard>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<form
|
||
id={formId}
|
||
action={action}
|
||
className="space-y-8"
|
||
onSubmit={(event) => {
|
||
if (!firstIncompleteStep) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
setShowValidation(true);
|
||
setCurrentStep(firstIncompleteStep);
|
||
}}
|
||
>
|
||
<input type="hidden" name="id" value={project?.id ?? ""} />
|
||
<input type="hidden" name="redirectPath" value={redirectPath} />
|
||
<input type="hidden" name="currentCoverImagePath" value={project?.coverImagePath ?? ""} />
|
||
<input type="hidden" name="viewMode" value={projectState.viewMode} />
|
||
<input ref={sectionsInputRef} type="hidden" name="sections" value={sectionsPayload} />
|
||
<input ref={assetsInputRef} type="hidden" name="assets" value={assetsPayload} />
|
||
|
||
<AppCard level={3} layer="single" padding="lg" className="space-y-6">
|
||
<div className="flex flex-col gap-6">
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||
<div className="space-y-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Badge variant={projectState.isPublished ? "success" : "warning"}>
|
||
{projectState.isPublished ? "Published" : "Draft"}
|
||
</Badge>
|
||
{projectState.isFeatured ? <Badge variant="outline">Featured</Badge> : null}
|
||
{!selectedCategory?.isActive ? <Badge variant="warning">Inactive Category</Badge> : null}
|
||
</div>
|
||
<div>
|
||
<h2 className="text-2xl font-semibold text-foreground">{projectLabel}</h2>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
{selectedCategory ? `${selectedCategory.name.de || selectedCategory.name.en || selectedCategory.name.ar}` : "No category selected"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||
<StatsCard
|
||
title="Step"
|
||
value={wizardSteps[currentStepIndex]?.label ?? "Basics"}
|
||
icon={Layers3}
|
||
/>
|
||
<StatsCard title="Sections" value={String(sections.length)} icon={Files} />
|
||
<StatsCard title="Assets" value={String(assets.length)} icon={ImagePlus} />
|
||
<StatsCard
|
||
title="Progress"
|
||
value={`${progress.filter((step) => step.complete).length}/${progress.length}`}
|
||
icon={CheckCircle2}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{showValidation && firstIncompleteStep ? (
|
||
<div className="rounded-nested border border-status-warning/30 bg-status-warning-soft px-4 py-3">
|
||
<div className="flex items-start gap-3">
|
||
<CircleAlert className="mt-0.5 h-4 w-4 text-status-warning" />
|
||
<div className="space-y-1">
|
||
<p className="text-sm font-medium text-foreground">Complete the highlighted step before saving.</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
The wizard moved to the first incomplete step so the missing fields are easier to find.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</AppCard>
|
||
|
||
<Tabs value={currentStep} onValueChange={(value) => setCurrentStep(value as PortfolioWizardStep)}>
|
||
<div className="grid gap-6 xl:grid-cols-[300px_minmax(0,1fr)] xl:items-start">
|
||
<AppCard level={2} layer="single" padding="sm" className="xl:sticky xl:top-4">
|
||
<TabsList className="grid h-auto w-full gap-3 rounded-none bg-transparent p-0">
|
||
{wizardSteps.map((step, index) => {
|
||
const stepProgress = progress.find((entry) => entry.key === step.key);
|
||
const isActive = currentStep === step.key;
|
||
|
||
return (
|
||
<TabsTrigger
|
||
key={step.key}
|
||
value={step.key}
|
||
className={cn(
|
||
"w-full rounded-nested border border-border/80 bg-background p-0 text-left hover:bg-accent/30",
|
||
isActive && "border-input bg-accent/20 text-foreground shadow-xs",
|
||
)}
|
||
>
|
||
<div className="flex w-full flex-col gap-3 p-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-3">
|
||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-border/80 bg-surface-2 text-sm font-semibold text-foreground">
|
||
{index + 1}
|
||
</span>
|
||
<div>
|
||
<p className="text-sm font-semibold text-foreground">{step.label}</p>
|
||
</div>
|
||
</div>
|
||
{stepProgress?.complete ? (
|
||
<CheckCircle2 className="h-4 w-4 text-status-success" />
|
||
) : (
|
||
<CircleDashed className="h-4 w-4 text-muted-foreground" />
|
||
)}
|
||
</div>
|
||
<StepStateBadge
|
||
complete={Boolean(stepProgress?.complete)}
|
||
active={isActive}
|
||
showValidation={showValidation && !stepProgress?.complete}
|
||
/>
|
||
</div>
|
||
</TabsTrigger>
|
||
);
|
||
})}
|
||
</TabsList>
|
||
</AppCard>
|
||
|
||
<div className="space-y-6">
|
||
<TabsContent value="basics" className="mt-0">
|
||
<AppCard level={3} layer="single" padding="lg">
|
||
<section className="space-y-6">
|
||
<SectionHeader
|
||
title="Basic Information"
|
||
description="Choose the category and the stable project metadata first."
|
||
/>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="categoryId" className="sr-only">Category</Label>
|
||
<div className="relative">
|
||
<FolderTree className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Select
|
||
name="categoryId"
|
||
value={projectState.categoryId}
|
||
onValueChange={(value) => setProjectState((current) => ({ ...current, categoryId: value }))}
|
||
>
|
||
<SelectTrigger id="categoryId" className="pl-9">
|
||
<SelectValue placeholder="Category" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{availableCategories.map((category) => (
|
||
<SelectItem key={category.id} value={category.id}>
|
||
{category.name.de || category.name.en || category.name.ar}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="slug" className="sr-only">Slug</Label>
|
||
<div className="relative">
|
||
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
id="slug"
|
||
name="slug"
|
||
placeholder="Slug"
|
||
value={projectState.slug}
|
||
onChange={(event) =>
|
||
setProjectState((current) => ({ ...current, slug: event.target.value }))
|
||
}
|
||
className="pl-9"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="clientName" className="sr-only">Client</Label>
|
||
<div className="relative">
|
||
<UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
id="clientName"
|
||
name="clientName"
|
||
placeholder="Client"
|
||
value={projectState.clientName}
|
||
onChange={(event) =>
|
||
setProjectState((current) => ({ ...current, clientName: event.target.value }))
|
||
}
|
||
className="pl-9"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="projectYear" className="sr-only">Year</Label>
|
||
<div className="relative">
|
||
<CalendarDays className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
id="projectYear"
|
||
name="projectYear"
|
||
placeholder="Year"
|
||
type="number"
|
||
min="2000"
|
||
max="2100"
|
||
value={projectState.projectYear}
|
||
onChange={(event) =>
|
||
setProjectState((current) => ({ ...current, projectYear: event.target.value }))
|
||
}
|
||
className="pl-9"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="previewUrl" className="sr-only">Preview URL</Label>
|
||
<div className="relative">
|
||
<Link2 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
id="previewUrl"
|
||
name="previewUrl"
|
||
placeholder="Preview URL"
|
||
value={projectState.previewUrl}
|
||
onChange={(event) =>
|
||
setProjectState((current) => ({ ...current, previewUrl: event.target.value }))
|
||
}
|
||
className="pl-9"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="sortOrder" className="sr-only">Sort Order</Label>
|
||
<div className="relative">
|
||
<Layers3 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
id="sortOrder"
|
||
name="sortOrder"
|
||
placeholder="Sort Order"
|
||
type="number"
|
||
min="0"
|
||
value={projectState.sortOrder}
|
||
onChange={(event) =>
|
||
setProjectState((current) => ({ ...current, sortOrder: event.target.value }))
|
||
}
|
||
className="pl-9"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-3 xl:grid-cols-3">
|
||
{viewModeOptions.map((option) => (
|
||
<ViewModeCard
|
||
key={option.value}
|
||
active={projectState.viewMode === option.value}
|
||
label={option.label}
|
||
description={option.description}
|
||
icon={option.icon}
|
||
onClick={() => setProjectState((current) => ({ ...current, viewMode: option.value }))}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<ToggleField
|
||
id="isFeatured"
|
||
name="isFeatured"
|
||
checked={projectState.isFeatured}
|
||
title="Featured"
|
||
description="Highlight the project across the portfolio."
|
||
onCheckedChange={(checked) =>
|
||
setProjectState((current) => ({ ...current, isFeatured: checked }))
|
||
}
|
||
/>
|
||
<ToggleField
|
||
id="isPublished"
|
||
name="isPublished"
|
||
checked={projectState.isPublished}
|
||
title="Published"
|
||
description="Only published projects appear on public pages."
|
||
onCheckedChange={(checked) =>
|
||
setProjectState((current) => ({ ...current, isPublished: checked }))
|
||
}
|
||
/>
|
||
</div>
|
||
</section>
|
||
</AppCard>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="content" className="mt-0">
|
||
<AppCard level={3} layer="single" padding="lg">
|
||
<section className="space-y-6">
|
||
<SectionHeader
|
||
title="Localized Content"
|
||
description="Finish the user-facing copy before building sections and media."
|
||
/>
|
||
|
||
<LocaleInputs
|
||
title="Project Title"
|
||
namePrefix="title"
|
||
values={{
|
||
Ar: projectState.titleAr,
|
||
En: projectState.titleEn,
|
||
De: projectState.titleDe,
|
||
}}
|
||
onChange={(key, value) =>
|
||
setProjectState((current) => ({ ...current, [`title${key}`]: value }))
|
||
}
|
||
/>
|
||
|
||
<LocaleInputs
|
||
title="Service Label"
|
||
namePrefix="serviceLabel"
|
||
values={{
|
||
Ar: projectState.serviceLabelAr,
|
||
En: projectState.serviceLabelEn,
|
||
De: projectState.serviceLabelDe,
|
||
}}
|
||
onChange={(key, value) =>
|
||
setProjectState((current) => ({ ...current, [`serviceLabel${key}`]: value }))
|
||
}
|
||
/>
|
||
|
||
<LocaleInputs
|
||
title="Summary"
|
||
namePrefix="summary"
|
||
values={{
|
||
Ar: projectState.summaryAr,
|
||
En: projectState.summaryEn,
|
||
De: projectState.summaryDe,
|
||
}}
|
||
onChange={(key, value) =>
|
||
setProjectState((current) => ({ ...current, [`summary${key}`]: value }))
|
||
}
|
||
multiline
|
||
/>
|
||
</section>
|
||
</AppCard>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="sections" className="mt-0">
|
||
<AppCard level={3} layer="single" padding="lg">
|
||
<section className="space-y-6">
|
||
<SectionHeader
|
||
title="Sections"
|
||
description="Each section keeps one clear role in the project story."
|
||
action={(
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => setSections((current) => [...current, createEmptySection(current.length)])}
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
Add Section
|
||
</Button>
|
||
)}
|
||
/>
|
||
|
||
<div className="space-y-4">
|
||
{sections.map((section, index) => (
|
||
<div
|
||
key={section.id ?? `section-${index}`}
|
||
className="space-y-4 rounded-surface border border-border/70 bg-background p-4"
|
||
>
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex items-center gap-3">
|
||
<Badge variant={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,
|
||
}) ? "success" : "outline"}>
|
||
{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"}
|
||
</Badge>
|
||
<p className="text-sm font-medium text-foreground">{`Section ${index + 1}`}</p>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => setSections((current) => moveArrayItem(current, index, index - 1))}
|
||
disabled={index === 0}
|
||
>
|
||
<ArrowUp className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => setSections((current) => moveArrayItem(current, index, index + 1))}
|
||
disabled={index === sections.length - 1}
|
||
>
|
||
<ArrowDown className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
className="text-destructive"
|
||
onClick={() =>
|
||
sections.length > 1 &&
|
||
setSections((current) =>
|
||
current.filter((_, currentIndex) => currentIndex !== index),
|
||
)
|
||
}
|
||
disabled={sections.length === 1}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label className="sr-only">Type</Label>
|
||
<Select
|
||
value={section.type}
|
||
onValueChange={(value) =>
|
||
setSections((current) =>
|
||
current.map((item, currentIndex) =>
|
||
currentIndex === index
|
||
? { ...item, type: value as PortfolioSectionType }
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Type" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{sectionTypeOptions.map((type) => (
|
||
<SelectItem key={type} value={type}>
|
||
{sectionTypeLabels[type]}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{section.type === "LINK" ? (
|
||
<div className="space-y-2">
|
||
<Label className="sr-only">Link URL</Label>
|
||
<Input
|
||
placeholder="Link URL"
|
||
value={section.linkUrl}
|
||
onChange={(event) =>
|
||
setSections((current) =>
|
||
current.map((item, currentIndex) =>
|
||
currentIndex === index
|
||
? { ...item, linkUrl: event.target.value }
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{section.type === "GALLERY" ? (
|
||
<MediaFieldPicker
|
||
title="Section Image"
|
||
value={section.media}
|
||
onChange={(media) =>
|
||
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}
|
||
|
||
<LocaleInputs
|
||
title="Section Title"
|
||
values={{ Ar: section.titleAr, En: section.titleEn, De: section.titleDe }}
|
||
onChange={(key, value) =>
|
||
setSections((current) =>
|
||
current.map((item, currentIndex) =>
|
||
currentIndex === index ? { ...item, [`title${key}`]: value } : item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
|
||
{section.type !== "GALLERY" && section.type !== "LINK" ? (
|
||
<LocaleInputs
|
||
title="Section Body"
|
||
values={{ Ar: section.bodyAr, En: section.bodyEn, De: section.bodyDe }}
|
||
onChange={(key, value) =>
|
||
setSections((current) =>
|
||
current.map((item, currentIndex) =>
|
||
currentIndex === index ? { ...item, [`body${key}`]: value } : item,
|
||
),
|
||
)
|
||
}
|
||
multiline
|
||
/>
|
||
) : null}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</AppCard>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="assets" className="mt-0">
|
||
<AppCard level={3} layer="single" padding="lg">
|
||
<div className="space-y-8">
|
||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
||
<div className="space-y-6">
|
||
<section className="space-y-6">
|
||
<SectionHeader
|
||
title="Cover Media"
|
||
description="Choose the primary cover that represents the project in listings."
|
||
/>
|
||
|
||
<MediaFieldPicker
|
||
title="Cover"
|
||
value={coverMedia}
|
||
onChange={setCoverMedia}
|
||
options={mediaOptions}
|
||
hasInitialValue={Boolean(project?.coverImagePath || project?.coverMediaAssetId)}
|
||
inputName="coverMedia"
|
||
fileFieldName="coverFile"
|
||
allowClear
|
||
clearLabel="Remove Cover"
|
||
emptyValue={{ mode: "upload", assetId: "", url: "", label: "" }}
|
||
/>
|
||
</section>
|
||
|
||
<Separator />
|
||
|
||
<section className="space-y-6">
|
||
<SectionHeader
|
||
title="Assets"
|
||
description="Assets stay ordered and ready for localized alt text."
|
||
action={(
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => setAssets((current) => [...current, createEmptyAsset(current.length)])}
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
Add Asset
|
||
</Button>
|
||
)}
|
||
/>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{assets.map((asset, index) => (
|
||
<div
|
||
key={asset.id ?? `asset-${index}`}
|
||
className="space-y-4 rounded-surface border border-border/70 bg-background p-4"
|
||
>
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex items-center gap-3">
|
||
<Badge variant={isPortfolioAssetReady({
|
||
mediaAssetId: asset.media.assetId,
|
||
altAr: asset.altAr,
|
||
altEn: asset.altEn,
|
||
altDe: asset.altDe,
|
||
}) ? "success" : "outline"}>
|
||
{isPortfolioAssetReady({
|
||
mediaAssetId: asset.media.assetId,
|
||
altAr: asset.altAr,
|
||
altEn: asset.altEn,
|
||
altDe: asset.altDe,
|
||
})
|
||
? "Ready"
|
||
: "Open"}
|
||
</Badge>
|
||
<p className="text-sm font-medium text-foreground">{`Asset ${index + 1}`}</p>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => setAssets((current) => moveArrayItem(current, index, index - 1))}
|
||
disabled={index === 0}
|
||
>
|
||
<ArrowUp className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => setAssets((current) => moveArrayItem(current, index, index + 1))}
|
||
disabled={index === assets.length - 1}
|
||
>
|
||
<ArrowDown className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
className="text-destructive"
|
||
onClick={() =>
|
||
assets.length > 1 &&
|
||
setAssets((current) =>
|
||
current.filter((_, currentIndex) => currentIndex !== index),
|
||
)
|
||
}
|
||
disabled={assets.length === 1}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<MediaFieldPicker
|
||
title="Asset Image"
|
||
value={asset.media}
|
||
onChange={(media) =>
|
||
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: "" }}
|
||
/>
|
||
|
||
<LocaleInputs
|
||
title="Alt Text"
|
||
values={{ Ar: asset.altAr, En: asset.altEn, De: asset.altDe }}
|
||
onChange={(key, value) =>
|
||
setAssets((current) =>
|
||
current.map((item, currentIndex) =>
|
||
currentIndex === index ? { ...item, [`alt${key}`]: value } : item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div className="space-y-4 lg:sticky lg:top-4 lg:self-start">
|
||
<AppCard level={2} layer="single" padding="sm" className="space-y-4">
|
||
<div className="flex items-center gap-2">
|
||
<ImagePlus className="h-4 w-4 text-brand-primary" />
|
||
<p className="text-sm font-semibold text-foreground">Assets Overview</p>
|
||
</div>
|
||
|
||
<div className="grid gap-3">
|
||
<StatsCard title="Cover" value={coverMedia.assetId ? "Selected" : "Missing"} icon={ImagePlus} />
|
||
<StatsCard title="Assets" value={String(assets.length)} icon={Files} />
|
||
<StatsCard
|
||
title="Ready"
|
||
value={String(
|
||
assets.filter((asset) =>
|
||
isPortfolioAssetReady({
|
||
mediaAssetId: asset.media.assetId,
|
||
altAr: asset.altAr,
|
||
altEn: asset.altEn,
|
||
altDe: asset.altDe,
|
||
}),
|
||
).length,
|
||
)}
|
||
icon={CheckCircle2}
|
||
/>
|
||
</div>
|
||
|
||
<div className="rounded-nested border border-dashed border-border/70 bg-background px-4 py-3 text-sm text-muted-foreground">
|
||
Start with the cover, then add gallery assets. Each asset needs media plus localized alt text.
|
||
</div>
|
||
</AppCard>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</AppCard>
|
||
</TabsContent>
|
||
</div>
|
||
</div>
|
||
</Tabs>
|
||
|
||
<AppCard level={3} layer="single" padding="lg">
|
||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => setCurrentStep(wizardSteps[Math.max(currentStepIndex - 1, 0)]!.key)}
|
||
disabled={currentStepIndex === 0}
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
Previous
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() =>
|
||
setCurrentStep(
|
||
wizardSteps[Math.min(currentStepIndex + 1, wizardSteps.length - 1)]!.key,
|
||
)
|
||
}
|
||
disabled={currentStepIndex === wizardSteps.length - 1}
|
||
>
|
||
Next
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
{firstIncompleteStep ? (
|
||
<p className="text-sm text-muted-foreground">
|
||
Save becomes available after all steps are complete.
|
||
</p>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">
|
||
All steps are ready. You can save now.
|
||
</p>
|
||
)}
|
||
<SubmitButton />
|
||
</div>
|
||
</div>
|
||
</AppCard>
|
||
</form>
|
||
);
|
||
}
|