Gherkin para Código
Transforme requisitos BDD Gherkin em implementação real com React 19 + Next.js 15. Cada seção mostra primeiro o cenário Gherkin, depois o código exato que o satisfaz.
Busque em todas as páginas da documentação
Transforme requisitos BDD Gherkin em implementação real com React 19 + Next.js 15. Cada seção mostra primeiro o cenário Gherkin, depois o código exato que o satisfaz.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
O projeto de exemplo é um Formulário de Perfil de Arquiteto de Nuvem -- um formulário de múltiplos passos com 6 etapas, incluindo upload de arquivos, validação de esquema, ações de servidor e UI acessível.
Cenários de validação Gherkin se traduzem diretamente em esquemas zod. Cada Then they should see "..." mapeia para uma mensagem de erro zod.
Feature: Profile Validation Schema
Scenario: Full name is required and has minimum length
Given the architect submits step 1 with an empty "Full Name" field
Then they should see "Full name is required"
Scenario: Full name must be at least 2 characters
Given the architect types "A" in the "Full Name" field
When they click "Next"
Then they should see "Name must be at least 2 characters"
Scenario: Email must be valid format
Given the architect types "not-an-email" in the "Email" field
When they click "Next"
Then they should see "Please enter a valid email address"
Scenario: LinkedIn URL must point to linkedin.com
Given the architect types "https://twitter.com/someone" in the "LinkedIn URL"
When 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 types "-3" in the "Years of Experience" field
When 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 checks no cloud platforms on step 4
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 sets start date "2025-06-01" and end date "2024-01-01"
When they click "Next"
Then they should see "End date must be after start date"
Scenario: Bio cannot exceed 1000 characters
Given the architect types 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 selects 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 selects a .exe file for "Profile Photo"
Then they should see "Only JPEG, PNG, and WebP files are accepted"// lib/schemas/architect-profile.ts
import { z } from "zod";
// ── 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())
.min(0),
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 ────────────────────────────────────
const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_DIAGRAM_COUNT = 10;
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"
);
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(),
});
// ── Combined Schema ────────────────────────────────────
export const architectProfileSchema = personalInfoSchema
.merge(experienceSchema)
.merge(jobHistorySchema)
.merge(skillsSchema)
.merge(uploadsSchema);
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>;Como mapeia: Toda string Then they should see "..." se torna a string exata em uma mensagem de erro .min(), .max(), .refine() ou .email() do zod. O esquema É a especificação.
Cenários de interação de campo Gherkin definem como register, useFieldArray e zodResolver se conectam.
Feature: Form Field Registration
Scenario: Personal info fields are wired to form state
Given the architect is on step 1 "Personal Info"
When they type "Jane Doe" in "Full Name"
And they type "jane@example.com" in "Email"
And they type "+1-555-0100" in "Phone"
Then the form state should contain those values
Scenario: Certification multi-select tracks checked items
Given the architect is on step 2 "Experience"
When they check "AWS Solutions Architect Professional"
And they check "Terraform Associate"
Then the certifications array should contain both values
Scenario: Job history supports adding and removing entries
Given the architect is on step 3 "Job History"
And they have 2 job entries
When they click "Remove" on the first entry
Then only 1 job entry should remain
When they click "Add Another Position"
Then 2 job entries should appear again
Scenario: Validation runs on blur for immediate feedback
Given the architect is on step 1 "Personal Info"
When they type "bad" in the "Email" field
And they move focus to the next field
Then they should see "Please enter a valid email address"// components/profile-form/personal-info-step.tsx
"use client";
import { useFormContext } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ArchitectProfile } from "@/lib/schemas/architect-profile";
export function PersonalInfoStep() {
const {
register,
formState: { errors },
} = useFormContext<ArchitectProfile>();
return (
<fieldset>
<legend className="text-lg font-semibold">Personal Information</legend>
<div className="space-y-4">
<div>
<Label htmlFor="fullName">
Full Name <span aria-hidden="true">*</span>
</Label>
<Input
id="fullName"
aria-required="true"
aria-invalid={!!errors.fullName}
aria-describedby={errors.fullName ? "fullName-error" : undefined}
{...register("fullName")}
/>
{errors.fullName && (
<p id="fullName-error" role="alert" className="text-sm text-destructive mt-1">
{errors.fullName.message}
</p>
)}
</div>
<div>
<Label htmlFor="email">
Email <span aria-hidden="true">*</span>
</Label>
<Input
id="email"
type="email"
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-destructive mt-1">
{errors.email.message}
</p>
)}
</div>
<div>
<Label htmlFor="phone">Phone</Label>
<Input id="phone" type="tel" {...register("phone")} />
</div>
<div>
<Label htmlFor="linkedinUrl">LinkedIn URL</Label>
<Input
id="linkedinUrl"
type="url"
placeholder="https://linkedin.com/in/your-profile"
aria-invalid={!!errors.linkedinUrl}
aria-describedby={errors.linkedinUrl ? "linkedin-error" : undefined}
{...register("linkedinUrl")}
/>
{errors.linkedinUrl && (
<p id="linkedin-error" role="alert" className="text-sm text-destructive mt-1">
{errors.linkedinUrl.message}
</p>
)}
</div>
</div>
</fieldset>
);
}// 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>
<legend className="text-lg font-semibold">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">
<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)}
>
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"
{...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"
{...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"
{...register(`jobs.${index}.startDate`)}
/>
</div>
<div>
<Label htmlFor={`jobs.${index}.endDate`}>End Date</Label>
<Input
id={`jobs.${index}.endDate`}
type="date"
disabled={isCurrent}
{...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`)}
/>
<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}
{...register(`jobs.${index}.description`)}
/>
</div>
</div>
);
})}
{fields.length < MAX_JOBS && (
<Button
type="button"
variant="outline"
onClick={() =>
append({
company: "",
role: "",
startDate: "",
endDate: "",
isCurrent: false,
description: "",
})
}
>
Add Another Position
</Button>
)}
{errors.jobs?.root && (
<p role="alert" className="text-sm text-destructive mt-2">
{errors.jobs.root.message}
</p>
)}
</fieldset>
);
}// 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: "", isCurrent: false }],
cloudPlatforms: [],
specialties: [],
};
interface ProfileFormProviderProps {
children: React.ReactNode;
}
export function ProfileFormProvider({ children }: ProfileFormProviderProps) {
const methods = useForm<ArchitectProfile>({
resolver: zodResolver(architectProfileSchema),
defaultValues,
mode: "onBlur", // ← Gherkin: "move focus to next field" triggers validation
});
return <FormProvider {...methods}>{children}</FormProvider>;
}Como mapeia:
When they type "..." in "..." → register("fieldName") no inputThen the form state should contain those values → react-hook-form rastreia valores automaticamente via inputs não controladosWhen they click "Add Another Position" → useFieldArray.append()When they click "Remove" → useFieldArray.remove(index)And they move focus to the next field → mode: "onBlur" em useFormCenários de navegação Gherkin definem a máquina de estados do passo -- qual passo está ativo, quais estão concluídos e como a navegação é restrita.
Feature: Multi-Step Form State
Scenario: Initial state shows step 1
Given the architect opens the profile form
Then the current step should be 1
And steps 2 through 6 should be locked
And the "Back" button should be hidden
Scenario: Completing step 1 unlocks step 2
Given the architect fills all required fields on step 1
When they click "Next"
Then the current step should be 2
And step 1 should show a checkmark
And the "Back" button should be visible
Scenario: Navigating back preserves completed status
Given the architect has completed steps 1 through 3
And they are on step 4
When they click "Back"
Then the current step should be 3
And steps 1 through 3 should still show checkmarks
Scenario: Clicking a completed step jumps to it
Given the architect has completed steps 1 through 4
And they are on step 5
When they click step 2 in the progress indicator
Then the current step should be 2
Scenario: Cannot jump ahead past incomplete steps
Given the architect is on step 2
And step 3 has not been completed
When they click step 4 in the progress indicator
Then the current step should remain 2
Scenario: Review step shows all entered data
Given the architect has completed steps 1 through 5
When they arrive at step 6 "Review"
Then they should see a summary of all entered data
And each section should have an "Edit" link// 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>;
// Actions
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();
// Can always go to current step or completed steps
if (step === currentStep) return true;
if (completedSteps.has(step)) return true;
// Can go to next step if all previous steps are completed
if (step === currentStep + 1) return true;
return false;
},
reset: () => set({ currentStep: 1, completedSteps: new Set() }),
}));// components/profile-form/step-indicator.tsx
"use client";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import {
STEPS,
useProfileFormStore,
type StepStatus,
} from "@/stores/profile-form-store";
export function StepIndicator() {
const { currentStep, getStepStatus, canNavigateTo, goToStep } =
useProfileFormStore();
return (
<nav aria-label="Form progress">
<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)" : ""
}`}
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>
);
}// 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";
// Maps step number to the fields that must validate before advancing
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">
{!isFirstStep ? (
<Button type="button" variant="outline" onClick={goToPrevious}>
Back
</Button>
) : (
<div /> // Spacer
)}
{!isLastStep ? (
<Button type="button" onClick={handleNext}>
Next
</Button>
) : (
<Button type="submit">Submit Profile</Button>
)}
</div>
);
}Como mapeia:
Then the current step should be 1 → currentStep state em zustandAnd steps 2 through 6 should be locked → canNavigateTo() retorna falseThen step 1 should show a checkmark → getStepStatus(1) === "completed" renderiza <Check />When they click step 2 in the progress indicator → goToStep(2)When they click "Next" → valida os campos do passo atual com trigger(), então goToNext()Cenários de submissão Gherkin definem o contrato da ação do servidor -- o que ela recebe, o que retorna e como lida com erros.
Feature: Profile Submission Server Action
Scenario: Successful profile creation
Given the architect submits a valid complete profile
Then the server should receive all form fields and files
And a new profile record should be created
And the response should contain the profile ID
And the architect should be redirected to "/profile/me"
Scenario: Server rejects duplicate email
Given the architect submits a profile with email "taken@example.com"
And that email already exists in the database
Then the server should return field error "This email is already registered"
And the error should be associated with the "email" field
Scenario: Server validates file types on backend
Given an attacker bypasses client-side validation
And submits a PHP file disguised as a JPEG
Then the server should reject the file
And return "Invalid file type"
Scenario: Server handles unexpected errors gracefully
Given the database is temporarily unavailable
When the architect submits a profile
Then the server should return "Something went wrong. Please try again."
And no partial data should be persisted// lib/actions/create-profile.ts
"use server";
import { z } from "zod";
import { redirect } from "next/navigation";
import { architectProfileSchema } from "@/lib/schemas/architect-profile";
// Server-side only schema -- stricter than client
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 with zod ─────────────
const textResult = architectProfileSchema
.omit({ profilePhoto: true, architectureDiagrams: true, siteScreenshots: true })
.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,
},
};
}
}
}
// ── 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"))
);
// ── 6. Create profile record ─────────────────────
const profile = await createProfileRecord({
...textResult.data,
photoUrl,
diagramUrls,
});
redirect(`/profile/${profile.id}`);
} catch (error) {
// redirect() throws a NEXT_REDIRECT error -- 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: {},
};
}
}
// Placeholder data functions -- replace with your actual DB/storage layer
async function findProfileByEmail(email: string) {
// db.profile.findUnique({ where: { email } })
return null;
}
async function uploadToStorage(file: File, folder: string): Promise<string> {
// Upload to S3/Cloudflare R2/etc. and return URL
return `https://storage.example.com/${folder}/${file.name}`;
}
async function createProfileRecord(data: Record<string, unknown>) {
// db.profile.create({ data })
return { id: "new-profile-id" };
}Como mapeia:
Then the server should receive all form fields and files → Análise de FormData na açãoThen the server should return field error "..." → fieldErrors no tipo de retornoAnd the error should be associated with the "email" field → { email: "..." } em fieldErrorsThen the server should reject the file → Validação dupla com serverFileSchemaThen the server should return "Something went wrong..." → Bloco catch retorna mensagem genéricauseActionStateCenários Gherkin para estados de carregamento, exibição de erros e recuperação definem como useActionState se conecta à UI.
Feature: Submission UX with useActionState
Scenario: Submit button shows loading state
Given the architect clicks "Submit Profile"
Then the button text should change to "Submitting..."
And the button should be disabled
And a spinner should appear
Scenario: Field errors navigate to the correct step
Given the server returns an error on the "email" field
Then the form should navigate to step 1
And the "Email" field should be highlighted with an error
And the screen reader should announce the error
Scenario: Generic server error shows toast notification
Given the server returns "Something went wrong. Please try again."
Then a toast notification should appear with the error message
And the submit button should be re-enabled
And all entered data should be preserved
Scenario: Successful submission shows success state
Given the server creates the profile successfully
Then the architect should see "Profile created successfully!"
And they should be redirected to their profile page// components/profile-form/submit-step.tsx
"use client";
import { useActionState, useEffect, useRef } 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";
// Map field names to step numbers for error navigation
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();
const errorAnnouncerRef = useRef<HTMLDivElement>(null);
// Handle server-returned field errors
useEffect(() => {
if (!state.fieldErrors || Object.keys(state.fieldErrors).length === 0) return;
// Set errors on react-hook-form fields
const firstErrorField = Object.keys(state.fieldErrors)[0];
for (const [field, message] of Object.entries(state.fieldErrors)) {
setError(field as keyof ArchitectProfile, { message });
}
// Navigate to the step containing the first error
const targetStep = FIELD_TO_STEP[firstErrorField];
if (targetStep) {
goToStep(targetStep);
}
}, [state.fieldErrors, setError, goToStep]);
// Handle 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 values for server action
const handleSubmit = () => {
const values = getValues();
const formData = new FormData();
// Text fields
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));
// Multi-value fields
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);
}
// Files
if (values.profilePhoto) {
formData.set("profilePhoto", values.profilePhoto);
}
if (values.architectureDiagrams) {
for (const diagram of values.architectureDiagrams) {
formData.append("architectureDiagrams", diagram);
}
}
if (values.siteScreenshots) {
for (const screenshot of values.siteScreenshots) {
formData.append("siteScreenshots", screenshot);
}
}
formAction(formData);
};
const values = getValues();
return (
<div>
<h2 className="text-lg font-semibold mb-4">Review Your Profile</h2>
{/* ── Summary sections with edit links ── */}
<div className="space-y-4">
<ReviewSection
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 },
]}
/>
<ReviewSection
title="Experience"
step={2}
items={[
{ label: "Years", value: String(values.yearsOfExperience) },
{ label: "Role", value: values.currentRole },
{ label: "Certifications", value: values.certifications.join(", ") },
]}
/>
<ReviewSection
title="Job History"
step={3}
items={values.jobs.map((job) => ({
label: job.company,
value: job.role,
}))}
/>
<ReviewSection
title="Skills"
step={4}
items={[
{ label: "Platforms", value: values.cloudPlatforms.join(", ") },
{ label: "Specialties", value: values.specialties.join(", ") },
]}
/>
<ReviewSection
title="Uploads"
step={5}
items={[
{
label: "Profile Photo",
value: values.profilePhoto ? "Uploaded" : "None",
},
{
label: "Diagrams",
value: `${values.architectureDiagrams?.length ?? 0} files`,
},
]}
/>
</div>
{/* Screen reader error announcer */}
<div ref={errorAnnouncerRef} role="alert" aria-live="assertive" className="sr-only" />
{/* Submit button with pending state */}
<div className="pt-6">
<Button
type="button"
onClick={handleSubmit}
disabled={isPending}
className="w-full"
>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
Submitting...
</>
) : (
"Submit Profile"
)}
</Button>
</div>
</div>
);
}
// ── Review section with edit link ──────────────────────
function ReviewSection({
title,
step,
items,
}: {
title: string;
step: number;
items: { label: string; value?: string }[];
}) {
const { goToStep } = useProfileFormStore();
return (
<div className="border rounded-lg p-4">
<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)}
>
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 || "-"}</dd>
</div>
))}
</dl>
</div>
);
}Como mapeia:
Then the button text should change to "Submitting..." → isPending do useActionStateAnd a spinner should appear → <Loader2 className="animate-spin" />Then the form should navigate to step 1 → Pesquisa FIELD_TO_STEP + goToStep()And the screen reader should announce the error → role="alert" e aria-live="assertive"Then a toast notification should appear → useToast() no efeito de erroCenários de upload Gherkin definem pré-visualização, validação, arrastar e soltar e comportamento de remoção.
Feature: File Upload Component
Scenario: Architect uploads a profile photo and sees preview
Given the architect is on step 5 "Uploads"
When they select a JPEG file "headshot.jpg" (2MB) for "Profile Photo"
Then a thumbnail preview of "headshot.jpg" should appear
And the file name "headshot.jpg" should be displayed
And the file size "2 MB" should be displayed
Scenario: Drag and drop into upload zone
Given the architect drags a PNG file over the "Architecture Diagrams" zone
Then the drop zone border should turn blue
And the text should change to "Drop file here"
When they drop the file
Then a thumbnail preview should appear
And the drop zone should return to its default state
Scenario: Removing an uploaded file
Given the architect has uploaded 3 architecture diagrams
When they click the remove button on "diagram-2.png"
Then "diagram-2.png" should be removed from the preview list
And 2 thumbnails should remain
And the "Architecture Diagrams" file count should show "2 files"
Scenario: Client-side validation rejects oversized file
Given the architect selects a 15MB file for "Profile Photo"
Then the file should not be added to the preview
And they should see "File must be under 5MB"
Scenario: Client-side validation rejects wrong file type
Given the architect selects a .pdf file for "Profile Photo"
Then the file should not be added to the preview
And they should see "Only JPEG, PNG, and WebP files are accepted"
Scenario: Maximum file count enforced
Given the architect has uploaded 10 architecture diagrams
When they try to add another file
Then the file input should be disabled
And they should see "Maximum 10 files allowed"// 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, setError, 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);
const fileArray = Array.from(newFiles);
const validFiles: File[] = [];
for (const file of fileArray) {
const error = validateFile(file);
if (error) {
setClientError(error);
return; // Stop on first error
}
validFiles.push(file);
}
if (validFiles.length === 0) return;
// Create preview URLs
const newPreviews = validFiles.map((file) => ({
file,
url: URL.createObjectURL(file),
}));
if (multiple) {
const updated = [...files, ...validFiles];
field.onChange(updated);
setPreviews((prev) => [...prev, ...newPreviews]);
} else {
// Revoke old preview
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) {
const updated = files.filter((_, i) => i !== index);
field.onChange(updated);
setPreviews((prev) => prev.filter((_, i) => i !== index));
} else {
field.onChange(undefined);
setPreviews([]);
}
// Reset input so the same file can be re-selected
if (inputRef.current) inputRef.current.value = "";
},
[field, files, multiple, previews]
);
// ── Drag and drop handlers ──
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">
<label className="text-sm font-medium">{label}</label>
{/* Drop zone */}
<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",
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"
/>
{/* Client-side validation error */}
{clientError && (
<p role="alert" className="text-sm text-destructive">
{clientError}
</p>
)}
{/* File count */}
{multiple && files.length > 0 && (
<p className="text-sm text-muted-foreground">
{files.length} {files.length === 1 ? "file" : "files"}
</p>
)}
{/* Previews */}
{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}`}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
);
}// components/profile-form/uploads-step.tsx
"use client";
import { FileUpload } from "./file-upload";
export function UploadsStep() {
return (
<fieldset>
<legend className="text-lg font-semibold">Upload Files</legend>
<div className="space-y-6 mt-4">
<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>
);
}Como mapeia:
Then a thumbnail preview should appear → URL.createObjectURL() em addFiles()And the file name "headshot.jpg" should be displayed → preview.file.name na grade de pré-visualizaçãoThen the drop zone border should turn blue → isDragging && "border-primary bg-primary/5"And the text should change to "Drop file here" → isDragging ? "Drop file here" : ...When they click the remove button → removeFile(index) revoga a URL e atualiza o campoThen the file input should be disabled → isMaxReached desabilita o input e a zona de uploadCenários de acessibilidade Gherkin definem atributos ARIA, comportamento do teclado e anúncios do leitor de tela que os componentes shadcn/ui devem satisfazer.
Feature: Accessible Form with shadcn/ui
Scenario: Every required field has accessible markup
Given the architect opens the form
Then every required input should have aria-required="true"
And every input should have an associated label via htmlFor/id
And required fields should show a visual asterisk
Scenario: Validation errors are announced to screen readers
Given the architect submits step 1 with empty required fields
Then each error message should have role="alert"
And the first error message should receive focus
And invalid inputs should have aria-invalid="true"
Scenario: Error messages are linked to inputs
Given the "Email" field has a validation error
Then the input should have aria-describedby pointing to the error message
And a screen reader should read "Email, invalid, Please enter a valid email address"
Scenario: Step changes are announced
Given the architect moves from step 1 to step 2
Then the screen reader should announce "Step 2 of 6: Experience"
And focus should move to the first field of step 2
Scenario: Keyboard navigation works through the form
Given the architect is on step 1
When they press Tab repeatedly
Then focus should move through: Full Name, Email, Phone, LinkedIn URL, Next button
And pressing Enter on the Next button should advance the form
Scenario: File upload is keyboard accessible
Given the architect tabs to the upload drop zone
Then the drop zone should show a focus ring
When they press Enter
Then the file picker dialog should open// 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`;
const descriptionId = error ? errorId : undefined;
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={descriptionId}
{...register(name)}
/>
{error && (
<p
id={errorId}
role="alert"
className="text-sm text-destructive"
>
{error.message as string}
</p>
)}
</div>
);
}// 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"
/>
);
}// 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(() => {
// Focus the first focusable element in the new step
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>;
}Como mapeia:
Then every required input should have aria-required="true" → Prop aria-required={required}And invalid inputs should have aria-invalid="true" → aria-invalid={!!error}Then the input should have aria-describedby pointing to the error message → aria-describedby={errorId}Then the screen reader should announce "Step 2 of 6: Experience" → StepAnnouncer com aria-live="polite"And focus should move to the first field of step 2 → AutoFocusStep consulta o primeiro inputThen the drop zone should show a focus ring → tabIndex={0} + Tailwind focus-visible:ring-2 na zona de uploadCenários de segurança Gherkin definem por que a validação deve ser executada tanto no cliente quanto no servidor.
Feature: Dual Validation
Scenario: Client-side validation gives instant feedback
Given the architect types "bad-email" in the "Email" field
And they blur the field
Then they should see "Please enter a valid email address" within 100ms
And no network request should be made
Scenario: Server-side validation catches bypassed client checks
Given an attacker sends a direct POST request
And the payload contains email "not-an-email"
Then the server should reject the request
And return error "Please enter a valid email address"
Scenario: File type is validated on both client and server
Given the architect uploads a file named "exploit.php.jpg"
And the file's actual MIME type is "application/x-php"
Then the client should reject based on MIME type check
And if somehow it reaches the server, the server should also reject it
Scenario: Both validations use the same zod schema
Given the schema defines email as z.string().email()
Then the client-side resolver should use that schema
And the server action should use that same schema
And error messages should be identical on both sides// lib/schemas/architect-profile.ts (shared -- used by BOTH client and server)
// This is the SAME file shown in "Gherkin to Zod Schema" above.
// The key point: ONE schema, TWO consumers.
import { z } from "zod";
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"),
// ... rest of schema
});// Client uses it via zodResolver:
// components/profile-form/profile-form-provider.tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { architectProfileSchema } from "@/lib/schemas/architect-profile";
const methods = useForm<ArchitectProfile>({
resolver: zodResolver(architectProfileSchema), // ← same schema
mode: "onBlur",
});// Server uses it via safeParse:
// lib/actions/create-profile.ts
"use server";
import { architectProfileSchema } from "@/lib/schemas/architect-profile"; // ← same schema
export async function createProfile(prevState: ProfileActionState, formData: FormData) {
const result = architectProfileSchema.safeParse(rawData); // ← same schema
if (!result.success) {
// Return field errors -- messages are identical to client-side
}
}Como mapeia:
Then they should see "..." within 100ms → zodResolver do lado do cliente é executado no navegador, sem redeThen the server should reject the request → safeParse na ação do servidorThen error messages should be identical on both sides → mesmo arquivo de esquema importado por amboslib/schemas/ é uma camada compartilhada. Componentes do cliente o importam para zodResolver. Ações do servidor o importam para safeParse. Uma única fonte de verdade.Cenários de integração Gherkin definem como todas as peças se compõem no formulário completo.
Feature: Complete Profile Form Assembly
Scenario: Form renders all 6 steps with correct flow
Given the architect navigates to "/profile/create"
Then the step indicator should show 6 steps
And step 1 "Personal Info" should render with name, email, phone, LinkedIn fields
When they complete step 1 and click "Next"
Then step 2 "Experience" should render with years, role, certifications, bio fields
When they complete step 2 and click "Next"
Then step 3 "Job History" should render with repeatable job entry groups
When they complete step 3 and click "Next"
Then step 4 "Skills" should render with platform checkboxes and proficiency radios
When they complete step 4 and click "Next"
Then step 5 "Uploads" should render with photo, diagrams, and screenshots upload zones
When they complete step 5 and click "Next"
Then step 6 "Review" should show a summary of all data with edit links
And the submit button should be enabled// 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>
);
}// 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">
<StepIndicator />
<StepAnnouncer />
<AutoFocusStep>
<StepComponent />
</AutoFocusStep>
<StepNavigation />
</div>
);
}app/
profile/
create/
page.tsx ← Componente de página
components/
profile-form/
profile-form-provider.tsx ← useForm + FormProvider + zodResolver
profile-form-wizard.tsx ← Roteador de passos
step-indicator.tsx ← Barra de progresso com checkmarks
step-navigation.tsx ← Botões Back/Next/Submit
step-announcer.tsx ← Anúncios de passo para leitor de tela
auto-focus-step.tsx ← Foco automático no primeiro campo ao mudar de passo
accessible-field.tsx ← Input acessível reutilizável
personal-info-step.tsx ← Passo 1
experience-step.tsx ← Passo 2
job-history-step.tsx ← Passo 3 (useFieldArray)
skills-step.tsx ← Passo 4
uploads-step.tsx ← Passo 5
file-upload.tsx ← Upload de arquivo com arrastar/soltar e pré-visualização
submit-step.tsx ← Passo 6: Revisão + submissão useActionState
lib/
schemas/
architect-profile.ts ← Esquemas zod compartilhados (cliente + servidor)
actions/
create-profile.ts ← Ação de Servidor
stores/
profile-form-store.ts ← Estado do passo Zustand
Cartão de receita de referência rápida -- pronto para copiar e colar.
O padrão de tradução Gherkin-para-Código:
| Palavra-chave Gherkin | Destino do Código |
|---|---|
Given ... field is empty | zod .min(1, "...") |
Then they should see "error" | string de mensagem de erro zod |
When they type "..." | register("fieldName") no input |
When they click "Next" | trigger(stepFields) então goToNext() |
When they click "Add Another" | useFieldArray.append() |
Then a spinner should appear | isPending de useActionState |
Then the server should reject | safeParse() na ação do servidor |
Then role="alert" | <p role="alert"> em mensagens de erro |
Then screen reader announces | região aria-live="polite" |
Then thumbnail preview | URL.createObjectURL(file) |
When they drag a file | manipuladores onDragOver/onDrop |
Quando usar: Após completar o Checklist de Decisão Gherkin. Use o checklist para definir O QUE o formulário faz, depois este guia para implementar COMO.
Compartilhamento de esquemas entre cliente e servidor -- o arquivo de esquema não deve importar módulos apenas do servidor. Mantenha-o em lib/schemas/ com apenas imports zod.
useActionState requer uma ação de servidor de dois argumentos -- o primeiro argumento é o estado anterior, não os dados do formulário. (prevState, formData) => ... não (formData) => ....
FormData e react-hook-form -- react-hook-form gerencia o estado internamente. Para submeter via ação de servidor, você deve construir manualmente FormData a partir de getValues(). A prop action nativa do formulário ignora react-hook-form.
Loja Zustand reseta na navegação da página -- se o usuário navega para fora e volta, a loja de passos é resetada. Considere o middleware persist se precisar de persistência entre navegações.
Pré-visualizações de arquivo vazam memória -- cada URL.createObjectURL() deve ter um URL.revokeObjectURL() correspondente na remoção ou desmontagem. O componente FileUpload lida com isso em removeFile().
Ações de servidor e redirect() -- redirect() lança um erro especial internamente. Seu bloco catch deve relançá-lo ou o redirecionamento será engolido.
| Alternativa | Use Quando | Não Use Quando |
|---|---|---|
| Formik em vez de react-hook-form | Projeto legado já em Formik | Começando do zero (RHF tem melhor performance) |
| Redux para estado de passo | App já usa Redux intensivamente | Necessidades de estado mais simples (zustand é mais leve) |
| tRPC em vez de ações de servidor | Precisa de camada de API type-safe além de formulários | Interações complexas do lado do cliente necessárias |
| Conform para formulários server-first | Progressive enhancement é prioridade máxima | Interações complexas do lado do cliente necessárias |
Revisado por Chris St. John·Última atualização: 10 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥