React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

gherkinfiletreearchitectureserver-actionsplaywright

Filetree Example

Complete file-by-file breakdown of the Cloud Architect Profile Form. Every file needed for a working multi-step form inside Next.js App Router with React 19 server actions, BDD-aligned validation, upload protection, pending UX, and Playwright-ready test hooks.

The Filetree

cloud-architect-profile/
├── features/                              ← BDD Spec
│   ├── profile-form/
│   │   ├── 01-purpose.feature
│   │   ├── 02-field-types.feature
│   │   ├── 03-multi-step.feature
│   │   ├── 04-validation.feature
│   │   ├── 05-submission.feature
│   │   ├── 06-ui-ux.feature
│   │   ├── 07-accessibility.feature
│   │   ├── 08-file-uploads.feature
│   │   ├── 09-type-safety.feature
│   │   └── 10-tech-stack.feature
│   └── step-definitions/
│       ├── form-steps.ts
│       └── upload-steps.ts
├── lib/
│   ├── schemas/
│   │   └── architect-profile.ts           ← Validation Rules (shared zod)
│   └── actions/
│       └── create-profile.ts              ← Submission Logic (server action)
├── stores/
│   └── profile-form-store.ts              ← Step Logic (zustand)
├── components/
│   └── profile-form/
│       ├── profile-form-provider.tsx       ← Form UI (react-hook-form root)
│       ├── profile-form-wizard.tsx         ← Stepper Control (step router)
│       ├── step-indicator.tsx              ← Stepper Control (progress bar)
│       ├── step-navigation.tsx             ← Stepper Control (Back/Next)
│       ├── step-announcer.tsx              ← Accessibility (screen reader)
│       ├── auto-focus-step.tsx             ← Accessibility (focus management)
│       ├── accessible-field.tsx            ← Form UI (reusable field)
│       ├── personal-info-step.tsx          ← Form UI (step 1)
│       ├── experience-step.tsx             ← Form UI (step 2)
│       ├── job-history-step.tsx            ← Form UI (step 3, dynamic fields)
│       ├── skills-step.tsx                 ← Form UI (step 4)
│       ├── uploads-step.tsx                ← File Upload (step 5 layout)
│       ├── file-upload.tsx                 ← File Upload (drag/drop/preview)
│       └── submit-step.tsx                 ← Submission Logic (review + useActionState)
├── app/
│   └── profile/
│       └── create/
│           └── page.tsx                   ← Page entry point
└── e2e/
    └── profile-form.spec.ts               ← Playwright E2E tests

Where Each Responsibility Lives

ResponsibilityFile(s)Why it lives there
BDD Specfeatures/**/*.featureGherkin files stakeholders read and approve before coding
Validation Ruleslib/schemas/architect-profile.tsShared zod schema -- imported by both client (zodResolver) and server (safeParse)
Form UIcomponents/profile-form/*-step.tsxOne Client Component per step, each uses useFormContext
Step Logicstores/profile-form-store.tsZustand store tracks current step, completed steps, navigation rules
Stepper Controlstep-indicator.tsx, step-navigation.tsxUI chrome for the wizard -- progress bar and Back/Next buttons
Submission Logiclib/actions/create-profile.ts + submit-step.tsxServer action handles data, submit-step bridges react-hook-form to useActionState
File Uploadfile-upload.tsx + server action file validationClient: drag/drop + preview + size/type check. Server: duplicate MIME validation
Playwright Testse2e/profile-form.spec.tsE2E tests that map directly to Gherkin scenarios

File-by-File Code

1. BDD Spec -- features/profile-form/04-validation.feature

The Gherkin files are the source of truth. Every validation rule, UI behavior, and error message originates here. Developers implement code to satisfy these scenarios.

# features/profile-form/04-validation.feature
Feature: Profile Form Validation
 
  Background:
    Given the architect is logged in
    And they navigate to "/profile/create"
 
  Scenario: Required fields show errors when empty
    Given the architect is on step 1 "Personal Info"
    And they have not filled in any fields
    When they click "Next"
    Then they should see "Full name is required"
    And they should see "Email is required"
    And the form should not advance to step 2
 
  Scenario: Email format is validated
    Given the architect is on step 1 "Personal Info"
    When they type "not-an-email" in the "Email" field
    And they click "Next"
    Then they should see "Please enter a valid email address"
 
  Scenario: LinkedIn URL must point to linkedin.com
    Given the architect is on step 1 "Personal Info"
    When they type "https://twitter.com/someone" in the "LinkedIn URL" field
    And they click "Next"
    Then they should see "Must be a valid LinkedIn profile URL"
 
  Scenario: Years of experience must be between 0 and 50
    Given the architect is on step 2 "Experience"
    When they type "-3" in the "Years of Experience" field
    And they click "Next"
    Then they should see "Must be between 0 and 50 years"
 
  Scenario: At least one cloud platform is required
    Given the architect is on step 4 "Skills"
    And no cloud platforms are checked
    When they click "Next"
    Then they should see "Select at least one cloud platform"
 
  Scenario: Job end date must be after start date
    Given the architect is on step 3 "Job History"
    When they set start date to "2025-06-01"
    And they set end date to "2024-01-01"
    And they click "Next"
    Then they should see "End date must be after start date"
 
  Scenario: Bio cannot exceed 1000 characters
    Given the architect is on step 2 "Experience"
    When they type 1001 characters in the "Bio" field
    Then they should see "Bio must be 1000 characters or fewer"
 
  Scenario: Profile photo must be an image under 5MB
    Given the architect is on step 5 "Uploads"
    When they select a 15MB PNG for "Profile Photo"
    Then they should see "File must be under 5MB"
 
  Scenario: Profile photo rejects non-image files
    Given the architect is on step 5 "Uploads"
    When they select a .exe file for "Profile Photo"
    Then they should see "Only JPEG, PNG, and WebP files are accepted"

2. Validation Rules -- lib/schemas/architect-profile.ts

The single source of truth for all validation. Every zod error message matches a Gherkin Then they should see "..." exactly. Imported by the client (zodResolver) and the server action (safeParse).

// lib/schemas/architect-profile.ts
import { z } from "zod";
 
// ── Shared constants ───────────────────────────────────
const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_DIAGRAM_COUNT = 10;
 
// ── Reusable file validator ────────────────────────────
const imageFileSchema = z
  .instanceof(File)
  .refine((file) => file.size <= MAX_FILE_SIZE, "File must be under 5MB")
  .refine(
    (file) => ACCEPTED_IMAGE_TYPES.includes(file.type),
    "Only JPEG, PNG, and WebP files are accepted"
  );
 
// ── Step 1: Personal Info ──────────────────────────────
export const personalInfoSchema = z.object({
  fullName: z
    .string()
    .min(1, "Full name is required")
    .min(2, "Name must be at least 2 characters"),
  email: z
    .string()
    .min(1, "Email is required")
    .email("Please enter a valid email address"),
  phone: z.string().optional(),
  linkedinUrl: z
    .string()
    .url("Must be a valid URL")
    .refine(
      (url) => url.includes("linkedin.com/"),
      "Must be a valid LinkedIn profile URL"
    )
    .or(z.literal("")),
});
 
// ── Step 2: Experience ─────────────────────────────────
export const experienceSchema = z.object({
  yearsOfExperience: z
    .number({ invalid_type_error: "Must be a number" })
    .int("Must be a whole number")
    .min(0, "Must be between 0 and 50 years")
    .max(50, "Must be between 0 and 50 years"),
  currentRole: z.string().min(1, "Current role is required"),
  certifications: z.array(z.string()),
  bio: z.string().max(1000, "Bio must be 1000 characters or fewer").optional(),
});
 
// ── Step 3: Job History ────────────────────────────────
const jobEntrySchema = z
  .object({
    company: z.string().min(1, "Company name is required"),
    role: z.string().min(1, "Role is required"),
    startDate: z.string().min(1, "Start date is required"),
    endDate: z.string().optional(),
    isCurrent: z.boolean().default(false),
    description: z.string().max(500).optional(),
  })
  .refine(
    (data) => {
      if (data.isCurrent || !data.endDate) return true;
      return new Date(data.endDate) > new Date(data.startDate);
    },
    { message: "End date must be after start date", path: ["endDate"] }
  );
 
export const jobHistorySchema = z.object({
  jobs: z.array(jobEntrySchema).min(1, "Add at least one position"),
});
 
// ── Step 4: Skills ─────────────────────────────────────
export const skillsSchema = z.object({
  cloudPlatforms: z
    .array(z.enum(["aws", "azure", "gcp"]))
    .min(1, "Select at least one cloud platform"),
  specialties: z.array(z.string()).min(1, "Select at least one specialty"),
  awsProficiency: z.enum(["beginner", "intermediate", "expert"]).optional(),
  azureProficiency: z.enum(["beginner", "intermediate", "expert"]).optional(),
  gcpProficiency: z.enum(["beginner", "intermediate", "expert"]).optional(),
});
 
// ── Step 5: Uploads ────────────────────────────────────
export const uploadsSchema = z.object({
  profilePhoto: imageFileSchema.optional(),
  architectureDiagrams: z
    .array(imageFileSchema)
    .max(MAX_DIAGRAM_COUNT, `Maximum ${MAX_DIAGRAM_COUNT} files allowed`)
    .optional(),
  siteScreenshots: z
    .array(imageFileSchema)
    .max(MAX_DIAGRAM_COUNT, `Maximum ${MAX_DIAGRAM_COUNT} files allowed`)
    .optional(),
});
 
// ── Per-step schemas (used by StepNavigation.trigger()) ──
export const STEP_SCHEMAS = {
  1: personalInfoSchema,
  2: experienceSchema,
  3: jobHistorySchema,
  4: skillsSchema,
  5: uploadsSchema,
} as const;
 
// ── Combined schema (used by zodResolver + server action) ──
export const architectProfileSchema = personalInfoSchema
  .merge(experienceSchema)
  .merge(jobHistorySchema)
  .merge(skillsSchema)
  .merge(uploadsSchema);
 
// ── Inferred types ─────────────────────────────────────
export type ArchitectProfile = z.infer<typeof architectProfileSchema>;
export type PersonalInfo = z.infer<typeof personalInfoSchema>;
export type Experience = z.infer<typeof experienceSchema>;
export type JobHistory = z.infer<typeof jobHistorySchema>;
export type Skills = z.infer<typeof skillsSchema>;
export type Uploads = z.infer<typeof uploadsSchema>;

3. Step Logic -- stores/profile-form-store.ts

Zustand store owns the step state machine. Knows which step is active, which are completed, and whether navigation is allowed. Decoupled from form data (react-hook-form owns that).

// stores/profile-form-store.ts
import { create } from "zustand";
 
export type StepStatus = "upcoming" | "current" | "completed";
 
export const STEPS = [
  { id: 1, label: "Personal Info" },
  { id: 2, label: "Experience" },
  { id: 3, label: "Job History" },
  { id: 4, label: "Skills" },
  { id: 5, label: "Uploads" },
  { id: 6, label: "Review & Submit" },
] as const;
 
export const TOTAL_STEPS = STEPS.length;
 
interface ProfileFormState {
  currentStep: number;
  completedSteps: Set<number>;
  goToNext: () => void;
  goToPrevious: () => void;
  goToStep: (step: number) => void;
  markCompleted: (step: number) => void;
  getStepStatus: (step: number) => StepStatus;
  canNavigateTo: (step: number) => boolean;
  reset: () => void;
}
 
export const useProfileFormStore = create<ProfileFormState>((set, get) => ({
  currentStep: 1,
  completedSteps: new Set<number>(),
 
  goToNext: () => {
    const { currentStep } = get();
    if (currentStep < TOTAL_STEPS) {
      set((state) => {
        const completed = new Set(state.completedSteps);
        completed.add(currentStep);
        return { currentStep: currentStep + 1, completedSteps: completed };
      });
    }
  },
 
  goToPrevious: () => {
    const { currentStep } = get();
    if (currentStep > 1) {
      set({ currentStep: currentStep - 1 });
    }
  },
 
  goToStep: (step: number) => {
    const { canNavigateTo } = get();
    if (canNavigateTo(step)) {
      set({ currentStep: step });
    }
  },
 
  markCompleted: (step: number) => {
    set((state) => {
      const completed = new Set(state.completedSteps);
      completed.add(step);
      return { completedSteps: completed };
    });
  },
 
  getStepStatus: (step: number): StepStatus => {
    const { currentStep, completedSteps } = get();
    if (step === currentStep) return "current";
    if (completedSteps.has(step)) return "completed";
    return "upcoming";
  },
 
  canNavigateTo: (step: number): boolean => {
    const { completedSteps, currentStep } = get();
    if (step === currentStep) return true;
    if (completedSteps.has(step)) return true;
    if (step === currentStep + 1) return true;
    return false;
  },
 
  reset: () => set({ currentStep: 1, completedSteps: new Set() }),
}));

4. Submission Logic -- lib/actions/create-profile.ts

Server action receives FormData, validates server-side with the same zod schema, validates uploaded files independently, persists data, and redirects. The two-argument signature (prevState, formData) is required by useActionState.

// lib/actions/create-profile.ts
"use server";
 
import { z } from "zod";
import { redirect } from "next/navigation";
import { architectProfileSchema } from "@/lib/schemas/architect-profile";
 
// Server-only file schema -- stricter, checks actual MIME
const serverFileSchema = z
  .instanceof(File)
  .refine((file) => file.size <= 5 * 1024 * 1024, "File must be under 5MB")
  .refine(
    (file) => ["image/jpeg", "image/png", "image/webp"].includes(file.type),
    "Invalid file type"
  );
 
export type ProfileActionState = {
  success: boolean;
  message: string;
  fieldErrors: Record<string, string>;
};
 
const initialState: ProfileActionState = {
  success: false,
  message: "",
  fieldErrors: {},
};
 
export async function createProfile(
  prevState: ProfileActionState,
  formData: FormData
): Promise<ProfileActionState> {
  try {
    // ── 1. Parse text fields from FormData ───────────
    const rawData = {
      fullName: formData.get("fullName") as string,
      email: formData.get("email") as string,
      phone: formData.get("phone") as string,
      linkedinUrl: formData.get("linkedinUrl") as string,
      yearsOfExperience: Number(formData.get("yearsOfExperience")),
      currentRole: formData.get("currentRole") as string,
      certifications: formData.getAll("certifications") as string[],
      bio: formData.get("bio") as string,
      jobs: JSON.parse(formData.get("jobs") as string),
      cloudPlatforms: formData.getAll("cloudPlatforms") as string[],
      specialties: formData.getAll("specialties") as string[],
      awsProficiency: formData.get("awsProficiency") as string,
      azureProficiency: formData.get("azureProficiency") as string,
      gcpProficiency: formData.get("gcpProficiency") as string,
    };
 
    // ── 2. Validate text fields (same schema as client) ──
    const textSchema = architectProfileSchema.omit({
      profilePhoto: true,
      architectureDiagrams: true,
      siteScreenshots: true,
    });
    const textResult = textSchema.safeParse(rawData);
 
    if (!textResult.success) {
      const fieldErrors: Record<string, string> = {};
      for (const issue of textResult.error.issues) {
        const path = issue.path.join(".");
        fieldErrors[path] = issue.message;
      }
      return { success: false, message: "", fieldErrors };
    }
 
    // ── 3. Validate files server-side (dual validation) ──
    const profilePhoto = formData.get("profilePhoto") as File | null;
    if (profilePhoto && profilePhoto.size > 0) {
      const fileResult = serverFileSchema.safeParse(profilePhoto);
      if (!fileResult.success) {
        return {
          success: false,
          message: "",
          fieldErrors: { profilePhoto: fileResult.error.issues[0].message },
        };
      }
    }
 
    const diagrams = formData.getAll("architectureDiagrams") as File[];
    for (const diagram of diagrams) {
      if (diagram.size > 0) {
        const fileResult = serverFileSchema.safeParse(diagram);
        if (!fileResult.success) {
          return {
            success: false,
            message: "",
            fieldErrors: {
              architectureDiagrams: fileResult.error.issues[0].message,
            },
          };
        }
      }
    }
 
    const screenshots = formData.getAll("siteScreenshots") as File[];
    for (const screenshot of screenshots) {
      if (screenshot.size > 0) {
        const fileResult = serverFileSchema.safeParse(screenshot);
        if (!fileResult.success) {
          return {
            success: false,
            message: "",
            fieldErrors: {
              siteScreenshots: fileResult.error.issues[0].message,
            },
          };
        }
      }
    }
 
    // ── 4. Check for duplicate email ─────────────────
    const existingProfile = await findProfileByEmail(textResult.data.email);
    if (existingProfile) {
      return {
        success: false,
        message: "",
        fieldErrors: { email: "This email is already registered" },
      };
    }
 
    // ── 5. Upload files to storage ───────────────────
    const photoUrl = profilePhoto?.size
      ? await uploadToStorage(profilePhoto, "profiles")
      : null;
    const diagramUrls = await Promise.all(
      diagrams
        .filter((f) => f.size > 0)
        .map((f) => uploadToStorage(f, "diagrams"))
    );
    const screenshotUrls = await Promise.all(
      screenshots
        .filter((f) => f.size > 0)
        .map((f) => uploadToStorage(f, "screenshots"))
    );
 
    // ── 6. Create profile record ─────────────────────
    const profile = await createProfileRecord({
      ...textResult.data,
      photoUrl,
      diagramUrls,
      screenshotUrls,
    });
 
    redirect(`/profile/${profile.id}`);
  } catch (error) {
    // redirect() throws internally -- rethrow it
    if (error instanceof Error && error.message === "NEXT_REDIRECT") {
      throw error;
    }
 
    console.error("Profile creation failed:", error);
    return {
      success: false,
      message: "Something went wrong. Please try again.",
      fieldErrors: {},
    };
  }
}
 
// Replace with your actual DB/storage layer
async function findProfileByEmail(email: string) {
  return null;
}
 
async function uploadToStorage(file: File, folder: string): Promise<string> {
  return `https://storage.example.com/${folder}/${file.name}`;
}
 
async function createProfileRecord(data: Record<string, unknown>) {
  return { id: "new-profile-id" };
}

5. Form UI Root -- components/profile-form/profile-form-provider.tsx

Creates the single useForm instance shared across all steps. The zodResolver connects zod validation to react-hook-form. mode: "onBlur" gives real-time feedback when the user tabs away from a field.

// components/profile-form/profile-form-provider.tsx
"use client";
 
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
  architectProfileSchema,
  type ArchitectProfile,
} from "@/lib/schemas/architect-profile";
 
const defaultValues: Partial<ArchitectProfile> = {
  fullName: "",
  email: "",
  phone: "",
  linkedinUrl: "",
  yearsOfExperience: 0,
  currentRole: "",
  certifications: [],
  bio: "",
  jobs: [
    { company: "", role: "", startDate: "", endDate: "", isCurrent: false, description: "" },
  ],
  cloudPlatforms: [],
  specialties: [],
};
 
export function ProfileFormProvider({ children }: { children: React.ReactNode }) {
  const methods = useForm<ArchitectProfile>({
    resolver: zodResolver(architectProfileSchema),
    defaultValues,
    mode: "onBlur",
  });
 
  return <FormProvider {...methods}>{children}</FormProvider>;
}

6. Stepper Control -- components/profile-form/profile-form-wizard.tsx

Routes to the correct step component based on zustand state. Wraps the active step in accessibility helpers (screen reader announcer and auto-focus).

// components/profile-form/profile-form-wizard.tsx
"use client";
 
import { useProfileFormStore } from "@/stores/profile-form-store";
import { StepIndicator } from "./step-indicator";
import { StepNavigation } from "./step-navigation";
import { StepAnnouncer } from "./step-announcer";
import { AutoFocusStep } from "./auto-focus-step";
import { PersonalInfoStep } from "./personal-info-step";
import { ExperienceStep } from "./experience-step";
import { JobHistoryStep } from "./job-history-step";
import { SkillsStep } from "./skills-step";
import { UploadsStep } from "./uploads-step";
import { SubmitStep } from "./submit-step";
 
const STEP_COMPONENTS: Record<number, React.ComponentType> = {
  1: PersonalInfoStep,
  2: ExperienceStep,
  3: JobHistoryStep,
  4: SkillsStep,
  5: UploadsStep,
  6: SubmitStep,
};
 
export function ProfileFormWizard() {
  const { currentStep } = useProfileFormStore();
  const StepComponent = STEP_COMPONENTS[currentStep];
 
  return (
    <div className="space-y-8" data-testid="profile-form-wizard">
      <StepIndicator />
      <StepAnnouncer />
      <AutoFocusStep>
        <StepComponent />
      </AutoFocusStep>
      <StepNavigation />
    </div>
  );
}

7. Stepper Control -- components/profile-form/step-indicator.tsx

The progress bar. Shows checkmarks for completed steps, highlights the current step, and disables future steps. Each step button has ARIA labels for screen readers.

// components/profile-form/step-indicator.tsx
"use client";
 
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import { STEPS, useProfileFormStore } from "@/stores/profile-form-store";
 
export function StepIndicator() {
  const { currentStep, getStepStatus, canNavigateTo, goToStep } =
    useProfileFormStore();
 
  return (
    <nav aria-label="Form progress" data-testid="step-indicator">
      <p className="sr-only">
        Step {currentStep} of {STEPS.length}
      </p>
      <ol className="flex items-center gap-2">
        {STEPS.map((step) => {
          const status = getStepStatus(step.id);
          const navigable = canNavigateTo(step.id);
 
          return (
            <li key={step.id} className="flex items-center gap-2">
              <button
                type="button"
                onClick={() => goToStep(step.id)}
                disabled={!navigable}
                aria-current={status === "current" ? "step" : undefined}
                aria-label={`Step ${step.id}: ${step.label}${
                  status === "completed" ? " (completed)" : ""
                }`}
                data-testid={`step-${step.id}`}
                data-status={status}
                className={cn(
                  "flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors",
                  status === "current" && "bg-primary text-primary-foreground",
                  status === "completed" &&
                    "bg-primary/20 text-primary hover:bg-primary/30",
                  status === "upcoming" &&
                    "bg-muted text-muted-foreground cursor-not-allowed"
                )}
              >
                {status === "completed" ? (
                  <Check className="h-4 w-4" aria-hidden="true" />
                ) : (
                  step.id
                )}
              </button>
              <span
                className={cn(
                  "hidden text-sm sm:inline",
                  status === "current" && "font-semibold",
                  status === "upcoming" && "text-muted-foreground"
                )}
              >
                {step.label}
              </span>
              {step.id < STEPS.length && (
                <div
                  className={cn(
                    "hidden h-px w-8 sm:block",
                    status === "completed" ? "bg-primary" : "bg-muted"
                  )}
                  aria-hidden="true"
                />
              )}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

8. Stepper Control -- components/profile-form/step-navigation.tsx

Back/Next/Submit buttons. "Next" validates the current step's fields via trigger() before advancing. The step-to-field mapping ensures only the active step's fields are checked.

// components/profile-form/step-navigation.tsx
"use client";
 
import { useFormContext } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { useProfileFormStore, TOTAL_STEPS } from "@/stores/profile-form-store";
import type { ArchitectProfile } from "@/lib/schemas/architect-profile";
 
const STEP_FIELDS: Record<number, (keyof ArchitectProfile)[]> = {
  1: ["fullName", "email", "phone", "linkedinUrl"],
  2: ["yearsOfExperience", "currentRole", "certifications", "bio"],
  3: ["jobs"],
  4: ["cloudPlatforms", "specialties"],
  5: ["profilePhoto", "architectureDiagrams", "siteScreenshots"],
};
 
export function StepNavigation() {
  const { currentStep, goToNext, goToPrevious } = useProfileFormStore();
  const { trigger } = useFormContext<ArchitectProfile>();
 
  const handleNext = async () => {
    const fields = STEP_FIELDS[currentStep];
    const isValid = await trigger(fields);
    if (isValid) {
      goToNext();
    }
  };
 
  const isFirstStep = currentStep === 1;
  const isLastStep = currentStep === TOTAL_STEPS;
 
  return (
    <div className="flex justify-between pt-6" data-testid="step-navigation">
      {!isFirstStep ? (
        <Button
          type="button"
          variant="outline"
          onClick={goToPrevious}
          data-testid="btn-back"
        >
          Back
        </Button>
      ) : (
        <div />
      )}
 
      {!isLastStep ? (
        <Button type="button" onClick={handleNext} data-testid="btn-next">
          Next
        </Button>
      ) : (
        <Button type="submit" data-testid="btn-submit">
          Submit Profile
        </Button>
      )}
    </div>
  );
}

9. Form UI -- components/profile-form/accessible-field.tsx

Reusable field wrapper that wires up aria-required, aria-invalid, aria-describedby, and role="alert" error messages. Used by every step component.

// components/profile-form/accessible-field.tsx
"use client";
 
import { useFormContext } from "react-hook-form";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import type { ArchitectProfile } from "@/lib/schemas/architect-profile";
import type { HTMLInputTypeAttribute } from "react";
 
interface AccessibleFieldProps {
  name: keyof ArchitectProfile;
  label: string;
  type?: HTMLInputTypeAttribute;
  required?: boolean;
  placeholder?: string;
}
 
export function AccessibleField({
  name,
  label,
  type = "text",
  required = false,
  placeholder,
}: AccessibleFieldProps) {
  const {
    register,
    formState: { errors },
  } = useFormContext<ArchitectProfile>();
 
  const error = errors[name];
  const errorId = `${name}-error`;
 
  return (
    <div className="space-y-1.5">
      <Label htmlFor={name}>
        {label}
        {required && (
          <span aria-hidden="true" className="text-destructive ml-0.5">*</span>
        )}
      </Label>
      <Input
        id={name}
        type={type}
        placeholder={placeholder}
        aria-required={required}
        aria-invalid={!!error}
        aria-describedby={error ? errorId : undefined}
        data-testid={`field-${name}`}
        {...register(name)}
      />
      {error && (
        <p id={errorId} role="alert" className="text-sm text-destructive">
          {error.message as string}
        </p>
      )}
    </div>
  );
}

10. Form UI Step 1 -- components/profile-form/personal-info-step.tsx

Uses AccessibleField for each input. Matches the Gherkin: "step 1 should render with name, email, phone, LinkedIn fields."

// components/profile-form/personal-info-step.tsx
"use client";
 
import { AccessibleField } from "./accessible-field";
 
export function PersonalInfoStep() {
  return (
    <fieldset data-testid="step-personal-info">
      <legend className="text-lg font-semibold mb-4">Personal Information</legend>
      <div className="space-y-4">
        <AccessibleField name="fullName" label="Full Name" required />
        <AccessibleField name="email" label="Email" type="email" required />
        <AccessibleField name="phone" label="Phone" type="tel" />
        <AccessibleField
          name="linkedinUrl"
          label="LinkedIn URL"
          type="url"
          placeholder="https://linkedin.com/in/your-profile"
        />
      </div>
    </fieldset>
  );
}

11. Form UI Step 2 -- components/profile-form/experience-step.tsx

Number input for years, text input for role, checkbox group for certifications, and a textarea with character counter for bio.

// components/profile-form/experience-step.tsx
"use client";
 
import { useFormContext } from "react-hook-form";
import { AccessibleField } from "./accessible-field";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import type { ArchitectProfile } from "@/lib/schemas/architect-profile";
 
const CERTIFICATIONS = [
  "AWS Solutions Architect Professional",
  "AWS DevOps Engineer Professional",
  "Google Cloud Professional Architect",
  "Azure Solutions Architect Expert",
  "Terraform Associate",
  "Kubernetes Administrator (CKA)",
] as const;
 
export function ExperienceStep() {
  const {
    register,
    watch,
    setValue,
    formState: { errors },
  } = useFormContext<ArchitectProfile>();
 
  const bio = watch("bio") ?? "";
  const certifications = watch("certifications") ?? [];
 
  const toggleCertification = (cert: string) => {
    const updated = certifications.includes(cert)
      ? certifications.filter((c) => c !== cert)
      : [...certifications, cert];
    setValue("certifications", updated, { shouldValidate: true });
  };
 
  return (
    <fieldset data-testid="step-experience">
      <legend className="text-lg font-semibold mb-4">Experience</legend>
      <div className="space-y-4">
        <AccessibleField
          name="yearsOfExperience"
          label="Years of Experience"
          type="number"
          required
        />
        <AccessibleField name="currentRole" label="Current Role" required />
 
        <div>
          <Label>Certifications</Label>
          <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
            {CERTIFICATIONS.map((cert) => (
              <label
                key={cert}
                className="flex items-center gap-2 text-sm cursor-pointer"
              >
                <Checkbox
                  checked={certifications.includes(cert)}
                  onCheckedChange={() => toggleCertification(cert)}
                  data-testid={`cert-${cert.toLowerCase().replace(/\s+/g, "-")}`}
                />
                {cert}
              </label>
            ))}
          </div>
          <p className="text-xs text-muted-foreground mt-1">
            {certifications.length} selected
          </p>
        </div>
 
        <div>
          <Label htmlFor="bio">Bio</Label>
          <Textarea
            id="bio"
            rows={4}
            aria-invalid={!!errors.bio}
            aria-describedby={errors.bio ? "bio-error" : "bio-counter"}
            data-testid="field-bio"
            {...register("bio")}
          />
          <p
            id="bio-counter"
            className={`text-xs mt-1 ${bio.length > 1000 ? "text-destructive" : "text-muted-foreground"}`}
          >
            {bio.length} / 1000
          </p>
          {errors.bio && (
            <p id="bio-error" role="alert" className="text-sm text-destructive">
              {errors.bio.message}
            </p>
          )}
        </div>
      </div>
    </fieldset>
  );
}

12. Form UI Step 3 -- components/profile-form/job-history-step.tsx

Dynamic field array with useFieldArray. Matches Gherkin: "supports repeatable entries", "Add Another Position", and "Remove" behavior.

// components/profile-form/job-history-step.tsx
"use client";
 
import { useFormContext, useFieldArray } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import type { ArchitectProfile } from "@/lib/schemas/architect-profile";
 
const MAX_JOBS = 10;
 
export function JobHistoryStep() {
  const {
    register,
    control,
    formState: { errors },
    watch,
  } = useFormContext<ArchitectProfile>();
 
  const { fields, append, remove } = useFieldArray({ control, name: "jobs" });
 
  return (
    <fieldset data-testid="step-job-history">
      <legend className="text-lg font-semibold mb-4">Job History</legend>
 
      {fields.map((field, index) => {
        const isCurrent = watch(`jobs.${index}.isCurrent`);
 
        return (
          <div
            key={field.id}
            className="border rounded-lg p-4 space-y-3 mb-4"
            data-testid={`job-entry-${index}`}
          >
            <div className="flex justify-between items-center">
              <h3 className="font-medium">Position {index + 1}</h3>
              {fields.length > 1 && (
                <Button
                  type="button"
                  variant="ghost"
                  size="sm"
                  onClick={() => remove(index)}
                  data-testid={`remove-job-${index}`}
                >
                  Remove
                </Button>
              )}
            </div>
 
            <div className="grid grid-cols-2 gap-4">
              <div>
                <Label htmlFor={`jobs.${index}.company`}>Company *</Label>
                <Input
                  id={`jobs.${index}.company`}
                  aria-required="true"
                  data-testid={`job-${index}-company`}
                  {...register(`jobs.${index}.company`)}
                />
                {errors.jobs?.[index]?.company && (
                  <p role="alert" className="text-sm text-destructive mt-1">
                    {errors.jobs[index].company.message}
                  </p>
                )}
              </div>
              <div>
                <Label htmlFor={`jobs.${index}.role`}>Role *</Label>
                <Input
                  id={`jobs.${index}.role`}
                  aria-required="true"
                  data-testid={`job-${index}-role`}
                  {...register(`jobs.${index}.role`)}
                />
              </div>
            </div>
 
            <div className="grid grid-cols-2 gap-4">
              <div>
                <Label htmlFor={`jobs.${index}.startDate`}>Start Date *</Label>
                <Input
                  id={`jobs.${index}.startDate`}
                  type="date"
                  aria-required="true"
                  data-testid={`job-${index}-start`}
                  {...register(`jobs.${index}.startDate`)}
                />
              </div>
              <div>
                <Label htmlFor={`jobs.${index}.endDate`}>End Date</Label>
                <Input
                  id={`jobs.${index}.endDate`}
                  type="date"
                  disabled={isCurrent}
                  data-testid={`job-${index}-end`}
                  {...register(`jobs.${index}.endDate`)}
                />
                {errors.jobs?.[index]?.endDate && (
                  <p role="alert" className="text-sm text-destructive mt-1">
                    {errors.jobs[index].endDate.message}
                  </p>
                )}
              </div>
            </div>
 
            <div className="flex items-center gap-2">
              <Checkbox
                id={`jobs.${index}.isCurrent`}
                {...register(`jobs.${index}.isCurrent`)}
                data-testid={`job-${index}-current`}
              />
              <Label htmlFor={`jobs.${index}.isCurrent`}>I currently work here</Label>
            </div>
 
            <div>
              <Label htmlFor={`jobs.${index}.description`}>Description</Label>
              <Textarea
                id={`jobs.${index}.description`}
                rows={3}
                data-testid={`job-${index}-description`}
                {...register(`jobs.${index}.description`)}
              />
            </div>
          </div>
        );
      })}
 
      {fields.length < MAX_JOBS && (
        <Button
          type="button"
          variant="outline"
          onClick={() =>
            append({
              company: "",
              role: "",
              startDate: "",
              endDate: "",
              isCurrent: false,
              description: "",
            })
          }
          data-testid="add-job"
        >
          Add Another Position
        </Button>
      )}
 
      {errors.jobs?.root && (
        <p role="alert" className="text-sm text-destructive mt-2">
          {errors.jobs.root.message}
        </p>
      )}
    </fieldset>
  );
}

13. Form UI Step 4 -- components/profile-form/skills-step.tsx

Cloud platform checkboxes, specialty multi-select, and radio buttons for proficiency per platform.

// components/profile-form/skills-step.tsx
"use client";
 
import { useFormContext } from "react-hook-form";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import type { ArchitectProfile } from "@/lib/schemas/architect-profile";
 
const PLATFORMS = [
  { value: "aws" as const, label: "Amazon Web Services (AWS)" },
  { value: "azure" as const, label: "Microsoft Azure" },
  { value: "gcp" as const, label: "Google Cloud Platform (GCP)" },
];
 
const SPECIALTIES = [
  "Serverless",
  "Containers & Kubernetes",
  "Networking & Security",
  "Data & Analytics",
  "Machine Learning",
  "DevOps & CI/CD",
  "Cost Optimization",
  "Migration",
];
 
const PROFICIENCY_LEVELS = ["beginner", "intermediate", "expert"] as const;
 
export function SkillsStep() {
  const {
    watch,
    setValue,
    formState: { errors },
  } = useFormContext<ArchitectProfile>();
 
  const cloudPlatforms = watch("cloudPlatforms") ?? [];
  const specialties = watch("specialties") ?? [];
 
  const togglePlatform = (platform: "aws" | "azure" | "gcp") => {
    const updated = cloudPlatforms.includes(platform)
      ? cloudPlatforms.filter((p) => p !== platform)
      : [...cloudPlatforms, platform];
    setValue("cloudPlatforms", updated, { shouldValidate: true });
  };
 
  const toggleSpecialty = (specialty: string) => {
    const updated = specialties.includes(specialty)
      ? specialties.filter((s) => s !== specialty)
      : [...specialties, specialty];
    setValue("specialties", updated, { shouldValidate: true });
  };
 
  return (
    <fieldset data-testid="step-skills">
      <legend className="text-lg font-semibold mb-4">Skills</legend>
      <div className="space-y-6">
        {/* Cloud Platforms */}
        <div>
          <Label>Cloud Platforms *</Label>
          <div className="space-y-2 mt-2">
            {PLATFORMS.map((platform) => (
              <label key={platform.value} className="flex items-center gap-2 text-sm">
                <Checkbox
                  checked={cloudPlatforms.includes(platform.value)}
                  onCheckedChange={() => togglePlatform(platform.value)}
                  data-testid={`platform-${platform.value}`}
                />
                {platform.label}
              </label>
            ))}
          </div>
          {errors.cloudPlatforms && (
            <p role="alert" className="text-sm text-destructive mt-1">
              {errors.cloudPlatforms.message}
            </p>
          )}
        </div>
 
        {/* Proficiency per selected platform */}
        {cloudPlatforms.map((platform) => {
          const fieldName = `${platform}Proficiency` as keyof ArchitectProfile;
          const currentValue = watch(fieldName) as string | undefined;
 
          return (
            <div key={platform}>
              <Label>{platform.toUpperCase()} Proficiency</Label>
              <RadioGroup
                value={currentValue}
                onValueChange={(val) => setValue(fieldName, val, { shouldValidate: true })}
                className="flex gap-4 mt-2"
                data-testid={`proficiency-${platform}`}
              >
                {PROFICIENCY_LEVELS.map((level) => (
                  <label key={level} className="flex items-center gap-1.5 text-sm capitalize">
                    <RadioGroupItem value={level} />
                    {level}
                  </label>
                ))}
              </RadioGroup>
            </div>
          );
        })}
 
        {/* Specialties */}
        <div>
          <Label>Specialties *</Label>
          <div className="grid grid-cols-2 gap-2 mt-2">
            {SPECIALTIES.map((specialty) => (
              <label key={specialty} className="flex items-center gap-2 text-sm">
                <Checkbox
                  checked={specialties.includes(specialty)}
                  onCheckedChange={() => toggleSpecialty(specialty)}
                  data-testid={`specialty-${specialty.toLowerCase().replace(/\s+&?\s*/g, "-")}`}
                />
                {specialty}
              </label>
            ))}
          </div>
          {errors.specialties && (
            <p role="alert" className="text-sm text-destructive mt-1">
              {errors.specialties.message}
            </p>
          )}
        </div>
      </div>
    </fieldset>
  );
}

14. File Upload -- components/profile-form/file-upload.tsx

Drag-and-drop with preview thumbnails, client-side type/size validation, and a remove button per file. Uses useController to sync files into react-hook-form state.

// components/profile-form/file-upload.tsx
"use client";
 
import { useCallback, useState, useRef } from "react";
import { useFormContext, useController } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { X, Upload } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ArchitectProfile } from "@/lib/schemas/architect-profile";
 
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_SIZE = 5 * 1024 * 1024;
 
interface FileUploadProps {
  name: "profilePhoto" | "architectureDiagrams" | "siteScreenshots";
  label: string;
  multiple?: boolean;
  maxFiles?: number;
}
 
function formatFileSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
 
export function FileUpload({ name, label, multiple = false, maxFiles = 10 }: FileUploadProps) {
  const { control, clearErrors } = useFormContext<ArchitectProfile>();
  const { field } = useController({ control, name });
  const [isDragging, setIsDragging] = useState(false);
  const [previews, setPreviews] = useState<{ file: File; url: string }[]>([]);
  const [clientError, setClientError] = useState<string | null>(null);
  const inputRef = useRef<HTMLInputElement>(null);
 
  const files: File[] = multiple
    ? (field.value as File[]) ?? []
    : field.value
      ? [field.value as File]
      : [];
 
  const isMaxReached = multiple && files.length >= maxFiles;
 
  const validateFile = useCallback(
    (file: File): string | null => {
      if (!ACCEPTED_TYPES.includes(file.type)) return "Only JPEG, PNG, and WebP files are accepted";
      if (file.size > MAX_SIZE) return "File must be under 5MB";
      if (multiple && files.length >= maxFiles) return `Maximum ${maxFiles} files allowed`;
      return null;
    },
    [files.length, maxFiles, multiple]
  );
 
  const addFiles = useCallback(
    (newFiles: FileList | File[]) => {
      setClientError(null);
      clearErrors(name);
 
      for (const file of Array.from(newFiles)) {
        const error = validateFile(file);
        if (error) { setClientError(error); return; }
      }
 
      const validFiles = Array.from(newFiles);
      const newPreviews = validFiles.map((file) => ({
        file,
        url: URL.createObjectURL(file),
      }));
 
      if (multiple) {
        field.onChange([...files, ...validFiles]);
        setPreviews((prev) => [...prev, ...newPreviews]);
      } else {
        previews.forEach((p) => URL.revokeObjectURL(p.url));
        field.onChange(validFiles[0]);
        setPreviews(newPreviews.slice(0, 1));
      }
    },
    [field, files, multiple, name, clearErrors, validateFile, previews]
  );
 
  const removeFile = useCallback(
    (index: number) => {
      URL.revokeObjectURL(previews[index].url);
      setClientError(null);
      if (multiple) {
        field.onChange(files.filter((_, i) => i !== index));
        setPreviews((prev) => prev.filter((_, i) => i !== index));
      } else {
        field.onChange(undefined);
        setPreviews([]);
      }
      if (inputRef.current) inputRef.current.value = "";
    },
    [field, files, multiple, previews]
  );
 
  const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }, []);
  const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); }, []);
  const handleDrop = useCallback(
    (e: React.DragEvent) => {
      e.preventDefault();
      setIsDragging(false);
      if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
    },
    [addFiles]
  );
 
  return (
    <div className="space-y-3" data-testid={`upload-${name}`}>
      <label className="text-sm font-medium">{label}</label>
 
      <div
        onDragOver={handleDragOver}
        onDragLeave={handleDragLeave}
        onDrop={handleDrop}
        onClick={() => !isMaxReached && inputRef.current?.click()}
        role="button"
        tabIndex={0}
        aria-label={`Upload ${label}`}
        aria-disabled={isMaxReached}
        onKeyDown={(e) => {
          if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (!isMaxReached) inputRef.current?.click(); }
        }}
        className={cn(
          "flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-ring",
          isDragging && "border-primary bg-primary/5",
          !isDragging && "border-muted-foreground/25 hover:border-muted-foreground/50",
          isMaxReached && "cursor-not-allowed opacity-50"
        )}
      >
        <Upload className="h-8 w-8 text-muted-foreground mb-2" aria-hidden="true" />
        <p className="text-sm text-muted-foreground">
          {isDragging ? "Drop file here" : isMaxReached ? `Maximum ${maxFiles} files allowed` : "Click or drag files here"}
        </p>
        <p className="text-xs text-muted-foreground mt-1">JPEG, PNG, or WebP up to 5MB</p>
      </div>
 
      <input
        ref={inputRef}
        type="file"
        accept="image/jpeg,image/png,image/webp"
        multiple={multiple}
        disabled={isMaxReached}
        onChange={(e) => e.target.files && addFiles(e.target.files)}
        className="sr-only"
        aria-hidden="true"
      />
 
      {clientError && (
        <p role="alert" className="text-sm text-destructive" data-testid={`${name}-error`}>
          {clientError}
        </p>
      )}
 
      {multiple && files.length > 0 && (
        <p className="text-sm text-muted-foreground">{files.length} {files.length === 1 ? "file" : "files"}</p>
      )}
 
      {previews.length > 0 && (
        <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
          {previews.map((preview, index) => (
            <div key={preview.url} className="relative group rounded-lg overflow-hidden border">
              <img src={preview.url} alt={preview.file.name} className="w-full h-24 object-cover" />
              <div className="p-2 text-xs">
                <p className="truncate font-medium">{preview.file.name}</p>
                <p className="text-muted-foreground">{formatFileSize(preview.file.size)}</p>
              </div>
              <Button
                type="button"
                variant="destructive"
                size="icon"
                className="absolute top-1 right-1 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
                onClick={(e) => { e.stopPropagation(); removeFile(index); }}
                aria-label={`Remove ${preview.file.name}`}
                data-testid={`remove-file-${index}`}
              >
                <X className="h-3 w-3" />
              </Button>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

15. File Upload Layout -- components/profile-form/uploads-step.tsx

Composes three FileUpload instances: single photo, multiple diagrams, multiple screenshots.

// components/profile-form/uploads-step.tsx
"use client";
 
import { FileUpload } from "./file-upload";
 
export function UploadsStep() {
  return (
    <fieldset data-testid="step-uploads">
      <legend className="text-lg font-semibold mb-4">Upload Files</legend>
      <div className="space-y-6">
        <FileUpload name="profilePhoto" label="Profile Photo" />
        <FileUpload name="architectureDiagrams" label="Architecture Diagrams" multiple maxFiles={10} />
        <FileUpload name="siteScreenshots" label="Site Screenshots" multiple maxFiles={10} />
      </div>
    </fieldset>
  );
}

16. Submission UX -- components/profile-form/submit-step.tsx

Bridges react-hook-form values to the server action via FormData. Uses useActionState for pending/error state. Maps server field errors back to the correct step.

// components/profile-form/submit-step.tsx
"use client";
 
import { useActionState, useEffect } from "react";
import { useFormContext } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import {
  createProfile,
  type ProfileActionState,
} from "@/lib/actions/create-profile";
import { useProfileFormStore, STEPS } from "@/stores/profile-form-store";
import { useToast } from "@/hooks/use-toast";
import type { ArchitectProfile } from "@/lib/schemas/architect-profile";
 
const FIELD_TO_STEP: Record<string, number> = {
  fullName: 1, email: 1, phone: 1, linkedinUrl: 1,
  yearsOfExperience: 2, currentRole: 2, certifications: 2, bio: 2,
  jobs: 3,
  cloudPlatforms: 4, specialties: 4,
  profilePhoto: 5, architectureDiagrams: 5, siteScreenshots: 5,
};
 
const initialState: ProfileActionState = {
  success: false,
  message: "",
  fieldErrors: {},
};
 
export function SubmitStep() {
  const [state, formAction, isPending] = useActionState(createProfile, initialState);
  const { getValues, setError } = useFormContext<ArchitectProfile>();
  const { goToStep } = useProfileFormStore();
  const { toast } = useToast();
 
  // Map server field errors back to react-hook-form + navigate to step
  useEffect(() => {
    if (!state.fieldErrors || Object.keys(state.fieldErrors).length === 0) return;
 
    const firstErrorField = Object.keys(state.fieldErrors)[0];
    for (const [field, message] of Object.entries(state.fieldErrors)) {
      setError(field as keyof ArchitectProfile, { message });
    }
 
    const targetStep = FIELD_TO_STEP[firstErrorField];
    if (targetStep) goToStep(targetStep);
  }, [state.fieldErrors, setError, goToStep]);
 
  // Show toast for generic server errors
  useEffect(() => {
    if (state.message && !state.success) {
      toast({ variant: "destructive", title: "Submission failed", description: state.message });
    }
  }, [state.message, state.success, toast]);
 
  // Build FormData from react-hook-form for the server action
  const handleSubmit = () => {
    const values = getValues();
    const formData = new FormData();
 
    formData.set("fullName", values.fullName);
    formData.set("email", values.email);
    formData.set("phone", values.phone ?? "");
    formData.set("linkedinUrl", values.linkedinUrl ?? "");
    formData.set("yearsOfExperience", String(values.yearsOfExperience));
    formData.set("currentRole", values.currentRole);
    formData.set("bio", values.bio ?? "");
    formData.set("jobs", JSON.stringify(values.jobs));
 
    for (const cert of values.certifications) formData.append("certifications", cert);
    for (const platform of values.cloudPlatforms) formData.append("cloudPlatforms", platform);
    for (const specialty of values.specialties) formData.append("specialties", specialty);
 
    if (values.profilePhoto) formData.set("profilePhoto", values.profilePhoto);
    if (values.architectureDiagrams) {
      for (const d of values.architectureDiagrams) formData.append("architectureDiagrams", d);
    }
    if (values.siteScreenshots) {
      for (const s of values.siteScreenshots) formData.append("siteScreenshots", s);
    }
 
    formAction(formData);
  };
 
  const values = getValues();
 
  return (
    <div data-testid="step-review">
      <h2 className="text-lg font-semibold mb-4">Review Your Profile</h2>
 
      <div className="space-y-4">
        <ReviewCard title="Personal Info" step={1} items={[
          { label: "Name", value: values.fullName },
          { label: "Email", value: values.email },
          { label: "Phone", value: values.phone },
          { label: "LinkedIn", value: values.linkedinUrl },
        ]} />
        <ReviewCard title="Experience" step={2} items={[
          { label: "Years", value: String(values.yearsOfExperience) },
          { label: "Role", value: values.currentRole },
          { label: "Certifications", value: values.certifications.join(", ") },
        ]} />
        <ReviewCard title="Job History" step={3} items={
          values.jobs.map((j) => ({ label: j.company, value: j.role }))
        } />
        <ReviewCard title="Skills" step={4} items={[
          { label: "Platforms", value: values.cloudPlatforms.join(", ") },
          { label: "Specialties", value: values.specialties.join(", ") },
        ]} />
        <ReviewCard title="Uploads" step={5} items={[
          { label: "Profile Photo", value: values.profilePhoto ? "Uploaded" : "None" },
          { label: "Diagrams", value: `${values.architectureDiagrams?.length ?? 0} files` },
          { label: "Screenshots", value: `${values.siteScreenshots?.length ?? 0} files` },
        ]} />
      </div>
 
      <div className="pt-6">
        <Button
          type="button"
          onClick={handleSubmit}
          disabled={isPending}
          className="w-full"
          data-testid="btn-submit-profile"
        >
          {isPending ? (
            <>
              <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
              Submitting...
            </>
          ) : (
            "Submit Profile"
          )}
        </Button>
      </div>
    </div>
  );
}
 
function ReviewCard({
  title,
  step,
  items,
}: {
  title: string;
  step: number;
  items: { label: string; value?: string }[];
}) {
  const { goToStep } = useProfileFormStore();
 
  return (
    <div className="border rounded-lg p-4" data-testid={`review-${step}`}>
      <div className="flex justify-between items-center mb-2">
        <h3 className="font-medium">{title}</h3>
        <Button
          type="button"
          variant="ghost"
          size="sm"
          onClick={() => goToStep(step)}
          data-testid={`edit-step-${step}`}
        >
          Edit
        </Button>
      </div>
      <dl className="grid grid-cols-2 gap-2 text-sm">
        {items.map((item) => (
          <div key={item.label}>
            <dt className="text-muted-foreground">{item.label}</dt>
            <dd>{item.value || "\u2014"}</dd>
          </div>
        ))}
      </dl>
    </div>
  );
}

17. Accessibility -- components/profile-form/step-announcer.tsx

Invisible live region that announces step changes to screen readers.

// components/profile-form/step-announcer.tsx
"use client";
 
import { useEffect, useRef } from "react";
import { STEPS, useProfileFormStore } from "@/stores/profile-form-store";
 
export function StepAnnouncer() {
  const { currentStep } = useProfileFormStore();
  const announcerRef = useRef<HTMLDivElement>(null);
  const previousStep = useRef(currentStep);
 
  useEffect(() => {
    if (currentStep !== previousStep.current) {
      previousStep.current = currentStep;
      const stepLabel = STEPS[currentStep - 1].label;
      if (announcerRef.current) {
        announcerRef.current.textContent = `Step ${currentStep} of ${STEPS.length}: ${stepLabel}`;
      }
    }
  }, [currentStep]);
 
  return (
    <div
      ref={announcerRef}
      role="status"
      aria-live="polite"
      aria-atomic="true"
      className="sr-only"
    />
  );
}

18. Accessibility -- components/profile-form/auto-focus-step.tsx

Moves focus to the first input when the step changes. Ensures keyboard users land in the right place.

// components/profile-form/auto-focus-step.tsx
"use client";
 
import { useEffect, useRef } from "react";
import { useProfileFormStore } from "@/stores/profile-form-store";
 
export function AutoFocusStep({ children }: { children: React.ReactNode }) {
  const { currentStep } = useProfileFormStore();
  const containerRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    const firstInput = containerRef.current?.querySelector<HTMLElement>(
      "input:not([type=hidden]):not([disabled]), select:not([disabled]), textarea:not([disabled])"
    );
    firstInput?.focus();
  }, [currentStep]);
 
  return <div ref={containerRef}>{children}</div>;
}

19. Page Entry -- app/profile/create/page.tsx

Server Component page that wraps the client-side form in the provider. This is where App Router meets the form.

// app/profile/create/page.tsx
import { ProfileFormProvider } from "@/components/profile-form/profile-form-provider";
import { ProfileFormWizard } from "@/components/profile-form/profile-form-wizard";
 
export default function CreateProfilePage() {
  return (
    <main className="max-w-2xl mx-auto py-10 px-4">
      <h1 className="text-2xl font-bold mb-6">Create Your Profile</h1>
      <ProfileFormProvider>
        <ProfileFormWizard />
      </ProfileFormProvider>
    </main>
  );
}

20. Playwright E2E -- e2e/profile-form.spec.ts

End-to-end tests that map directly to Gherkin scenarios. Uses the data-testid attributes added to every component.

// e2e/profile-form.spec.ts
import { test, expect, type Page } from "@playwright/test";
import path from "node:path";
 
const BASE_URL = "http://localhost:3000/profile/create";
 
// ── Helpers ────────────────────────────────────────────
async function fillStep1(page: Page) {
  await page.getByTestId("field-fullName").fill("Jane Doe");
  await page.getByTestId("field-email").fill("jane@example.com");
  await page.getByTestId("field-phone").fill("+1-555-0100");
}
 
async function fillStep2(page: Page) {
  await page.getByTestId("field-yearsOfExperience").fill("8");
  await page.getByTestId("field-currentRole").clear();
  await page.getByTestId("field-currentRole").fill("Principal Architect");
  await page.getByTestId("cert-aws-solutions-architect-professional").click();
}
 
async function fillStep3(page: Page) {
  await page.getByTestId("job-0-company").fill("Acme Corp");
  await page.getByTestId("job-0-role").fill("Cloud Architect");
  await page.getByTestId("job-0-start").fill("2020-01-01");
  await page.getByTestId("job-0-current").click();
}
 
async function fillStep4(page: Page) {
  await page.getByTestId("platform-aws").click();
  await page.getByTestId("specialty-serverless").click();
}
 
async function clickNext(page: Page) {
  await page.getByTestId("btn-next").click();
}
 
// ── Tests mapped to Gherkin features ───────────────────
 
test.describe("Feature: Multi-Step Navigation", () => {
  test.beforeEach(async ({ page }) => {
    await page.goto(BASE_URL);
  });
 
  test("initial state shows step 1", async ({ page }) => {
    await expect(page.getByTestId("step-personal-info")).toBeVisible();
    await expect(page.getByTestId("step-1")).toHaveAttribute("data-status", "current");
    await expect(page.getByTestId("btn-back")).not.toBeVisible();
  });
 
  test("completing step 1 unlocks step 2", async ({ page }) => {
    await fillStep1(page);
    await clickNext(page);
    await expect(page.getByTestId("step-experience")).toBeVisible();
    await expect(page.getByTestId("step-1")).toHaveAttribute("data-status", "completed");
    await expect(page.getByTestId("btn-back")).toBeVisible();
  });
 
  test("navigating back preserves data", async ({ page }) => {
    await fillStep1(page);
    await clickNext(page);
    await page.getByTestId("btn-back").click();
    await expect(page.getByTestId("field-fullName")).toHaveValue("Jane Doe");
  });
});
 
test.describe("Feature: Validation", () => {
  test.beforeEach(async ({ page }) => {
    await page.goto(BASE_URL);
  });
 
  test("required fields show errors when empty", async ({ page }) => {
    await clickNext(page);
    await expect(page.getByText("Full name is required")).toBeVisible();
    await expect(page.getByText("Email is required")).toBeVisible();
    await expect(page.getByTestId("step-personal-info")).toBeVisible();
  });
 
  test("email format is validated", async ({ page }) => {
    await page.getByTestId("field-fullName").fill("Jane Doe");
    await page.getByTestId("field-email").fill("not-an-email");
    await clickNext(page);
    await expect(page.getByText("Please enter a valid email address")).toBeVisible();
  });
 
  test("job end date must be after start date", async ({ page }) => {
    await fillStep1(page);
    await clickNext(page);
    await fillStep2(page);
    await clickNext(page);
 
    await page.getByTestId("job-0-company").fill("Acme");
    await page.getByTestId("job-0-role").fill("Architect");
    await page.getByTestId("job-0-start").fill("2025-06-01");
    await page.getByTestId("job-0-end").fill("2024-01-01");
    await clickNext(page);
    await expect(page.getByText("End date must be after start date")).toBeVisible();
  });
});
 
test.describe("Feature: File Uploads", () => {
  test.beforeEach(async ({ page }) => {
    await page.goto(BASE_URL);
    // Navigate to step 5
    await fillStep1(page); await clickNext(page);
    await fillStep2(page); await clickNext(page);
    await fillStep3(page); await clickNext(page);
    await fillStep4(page); await clickNext(page);
  });
 
  test("upload shows preview with filename and size", async ({ page }) => {
    const filePath = path.resolve("e2e/fixtures/sample.jpg");
    const input = page.getByTestId("upload-profilePhoto").locator("input[type=file]");
    await input.setInputFiles(filePath);
    await expect(page.getByText("sample.jpg")).toBeVisible();
  });
 
  test("rejects oversized file", async ({ page }) => {
    // Create a 6MB buffer for testing
    const input = page.getByTestId("upload-profilePhoto").locator("input[type=file]");
    await input.setInputFiles({
      name: "huge.jpg",
      mimeType: "image/jpeg",
      buffer: Buffer.alloc(6 * 1024 * 1024),
    });
    await expect(page.getByTestId("profilePhoto-error")).toHaveText("File must be under 5MB");
  });
});
 
test.describe("Feature: Submission UX", () => {
  test("submit button shows loading state", async ({ page }) => {
    await page.goto(BASE_URL);
    await fillStep1(page); await clickNext(page);
    await fillStep2(page); await clickNext(page);
    await fillStep3(page); await clickNext(page);
    await fillStep4(page); await clickNext(page);
    // Skip uploads
    await clickNext(page);
 
    // On review step
    await expect(page.getByTestId("step-review")).toBeVisible();
    await page.getByTestId("btn-submit-profile").click();
    await expect(page.getByText("Submitting...")).toBeVisible();
  });
});
 
test.describe("Feature: Accessibility", () => {
  test("required fields have aria-required", async ({ page }) => {
    await page.goto(BASE_URL);
    await expect(page.getByTestId("field-fullName")).toHaveAttribute("aria-required", "true");
    await expect(page.getByTestId("field-email")).toHaveAttribute("aria-required", "true");
  });
 
  test("invalid fields have aria-invalid after failed validation", async ({ page }) => {
    await page.goto(BASE_URL);
    await clickNext(page);
    await expect(page.getByTestId("field-fullName")).toHaveAttribute("aria-invalid", "true");
  });
 
  test("step indicator has aria-current on active step", async ({ page }) => {
    await page.goto(BASE_URL);
    await expect(page.getByTestId("step-1")).toHaveAttribute("aria-current", "step");
  });
});

Responsibility Flow Diagram

Gherkin Feature Files
        │
        ▼
┌──────────────────────────────┐
│   lib/schemas/               │ ← Validation Rules
│   architect-profile.ts       │   (shared zod, one source of truth)
└──────────┬───────────────────┘
           │ imported by
     ┌─────┴─────┐
     ▼           ▼
┌──────────┐ ┌─────────────────────────┐
│ Client   │ │ Server                  │ ← Dual Validation
│ zodRes.  │ │ safeParse in action     │
└────┬─────┘ └────────┬────────────────┘
     │                │
     ▼                ▼
┌──────────────┐ ┌──────────────────────┐
│ react-hook-  │ │ lib/actions/         │ ← Submission Logic
│ form + UI    │ │ create-profile.ts    │
│ components   │ │ (FormData → DB)      │
└────┬─────────┘ └───────┬──────────────┘
     │                   │
     ▼                   │
┌──────────────┐         │
│ zustand      │ ◄───────┘ ← Step Logic + Error Routing
│ step store   │   (FIELD_TO_STEP navigates to error step)
└────┬─────────┘
     │
     ▼
┌──────────────┐
│ Playwright   │ ← E2E tests verify Gherkin scenarios
│ e2e/         │   via data-testid selectors
└──────────────┘

Recipe

Quick-reference: what goes where.

You need to...Put it in...
Add a validation rulelib/schemas/architect-profile.ts (zod)
Add a new form stepNew step component + add to STEP_COMPONENTS in wizard + update STEPS in store
Change step navigation rulesstores/profile-form-store.ts (canNavigateTo)
Handle a new server errorlib/actions/create-profile.ts + FIELD_TO_STEP in submit-step
Add a file upload fielduploadsSchema in schema + new <FileUpload> in uploads-step
Write a new E2E teste2e/profile-form.spec.ts using data-testid selectors
Change acceptance criteriafeatures/profile-form/*.feature first, then update code to match

Gotchas

  • Missing data-testid -- Playwright tests rely on data-testid attributes. Every interactive element needs one. Add them when you create a component, not later.

  • Schema file importing server modules -- lib/schemas/architect-profile.ts is shared between client and server. It must not import anything from "use server" files, next/headers, or database clients.

  • useFieldArray index drift -- when you remove a job entry, all subsequent indexes shift. Use field.id (from useFieldArray) as the React key, not the array index.

  • File previews not cleaned up -- every URL.createObjectURL must have a revokeObjectURL when the file is removed. The FileUpload component handles this, but if you build custom upload UI, track your URLs.

  • redirect() in server actions -- Next.js redirect() throws an internal error. Your try/catch in the server action must re-throw it, or the redirect silently fails.

  • Playwright file upload -- use page.locator('input[type=file]').setInputFiles() with a real file path or a Buffer. The visual drop zone is not a real file input, so target the hidden <input> inside it.

Alternatives

AlternativeUse WhenDon't Use When
Single page.tsx with all steps inlinePrototype or under 10 fieldsProduction form with 20+ fields
Redux instead of zustandAlready using Redux in the appStarting fresh (zustand is lighter)
Conform instead of react-hook-formProgressive enhancement is criticalComplex client interactions (drag/drop, previews)
Vitest component tests instead of PlaywrightFast unit-level form testingNeed to test real browser behavior (file uploads, navigation)