From 30c05eb4d647f2b6912d8823437aa231215330fc Mon Sep 17 00:00:00 2001 From: Promzy204-bad Date: Fri, 26 Jun 2026 23:55:28 +0000 Subject: [PATCH 1/2] feat: inline server-side draft validation in create wizard - Add POST /api/commitments/validate route with per-field rules - CreateCommitmentStepConfigure: debounced (500ms) server validation, AbortController cancellation, aria-invalid/aria-describedby, blocks advance on errors with graceful server-down fallback - 18 unit tests covering debounce, field-error mapping, blocking advance, request cancellation, loading state, and multiple simultaneous errors - docs/CREATE_DRAFT_VALIDATION.md documents the validate contract --- .../app/api/commitments/validate/route.ts | 68 ++++ .../CreateCommitmentStepConfigure.tsx | 223 +++++++++++ frontend/docs/CREATE_DRAFT_VALIDATION.md | 97 +++++ .../CreateCommitmentStepConfigure.test.tsx | 347 ++++++++++++++++++ frontend/tests/mocks/handlers.ts | 39 ++ 5 files changed, 774 insertions(+) create mode 100644 frontend/app/api/commitments/validate/route.ts create mode 100644 frontend/components/CreateCommitmentStepConfigure.tsx create mode 100644 frontend/docs/CREATE_DRAFT_VALIDATION.md create mode 100644 frontend/tests/components/CreateCommitmentStepConfigure.test.tsx diff --git a/frontend/app/api/commitments/validate/route.ts b/frontend/app/api/commitments/validate/route.ts new file mode 100644 index 000000000..e2bfa0c32 --- /dev/null +++ b/frontend/app/api/commitments/validate/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; + +export interface CommitmentDraft { + title?: string; + net_amount?: number | string; + currency_preference?: string; + beneficiary_name?: string; + bank_account_number?: string; + bank_name?: string; + inactivity_days?: number | string; +} + +export interface FieldError { + field: string; + message: string; +} + +export interface ValidateResponse { + valid: boolean; + errors: FieldError[]; +} + +const SUPPORTED_CURRENCIES = ["XLM", "USDC", "EURC", "NGN", "KES", "BRL", "PHP", "EUR", "USD"]; + +export async function POST(req: NextRequest): Promise> { + let body: CommitmentDraft; + try { + body = await req.json(); + } catch { + return NextResponse.json({ valid: false, errors: [{ field: "_", message: "Invalid JSON body" }] }, { status: 400 }); + } + + const errors: FieldError[] = []; + + if (!body.title || String(body.title).trim().length < 3) { + errors.push({ field: "title", message: "Title must be at least 3 characters." }); + } + + const amount = Number(body.net_amount); + if (!body.net_amount || isNaN(amount) || amount <= 0) { + errors.push({ field: "net_amount", message: "Amount must be a positive number." }); + } + + if (!body.currency_preference || !SUPPORTED_CURRENCIES.includes(String(body.currency_preference).toUpperCase())) { + errors.push({ field: "currency_preference", message: `Currency must be one of: ${SUPPORTED_CURRENCIES.join(", ")}.` }); + } + + if (!body.beneficiary_name || String(body.beneficiary_name).trim().length < 2) { + errors.push({ field: "beneficiary_name", message: "Beneficiary name must be at least 2 characters." }); + } + + if (!body.bank_account_number || !/^\d{6,20}$/.test(String(body.bank_account_number).trim())) { + errors.push({ field: "bank_account_number", message: "Bank account number must be 6–20 digits." }); + } + + if (!body.bank_name || String(body.bank_name).trim().length < 2) { + errors.push({ field: "bank_name", message: "Bank name must be at least 2 characters." }); + } + + const days = Number(body.inactivity_days); + if (body.inactivity_days !== undefined && body.inactivity_days !== "") { + if (isNaN(days) || days < 30 || days > 3650) { + errors.push({ field: "inactivity_days", message: "Inactivity period must be between 30 and 3650 days." }); + } + } + + return NextResponse.json({ valid: errors.length === 0, errors }); +} diff --git a/frontend/components/CreateCommitmentStepConfigure.tsx b/frontend/components/CreateCommitmentStepConfigure.tsx new file mode 100644 index 000000000..8682d7639 --- /dev/null +++ b/frontend/components/CreateCommitmentStepConfigure.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { CommitmentDraft, FieldError } from "@/app/api/commitments/validate/route"; + +export interface ConfigureFormValues { + title: string; + net_amount: string; + currency_preference: string; + beneficiary_name: string; + bank_account_number: string; + bank_name: string; + inactivity_days: string; +} + +interface Props { + initialValues?: Partial; + onAdvance: (values: ConfigureFormValues) => void; +} + +const SUPPORTED_CURRENCIES = ["XLM", "USDC", "EURC", "NGN", "KES", "BRL", "PHP", "EUR", "USD"]; +const DEBOUNCE_MS = 500; + +const EMPTY: ConfigureFormValues = { + title: "", + net_amount: "", + currency_preference: "", + beneficiary_name: "", + bank_account_number: "", + bank_name: "", + inactivity_days: "", +}; + +export function CreateCommitmentStepConfigure({ initialValues, onAdvance }: Props) { + const [values, setValues] = useState({ ...EMPTY, ...initialValues }); + const [serverErrors, setServerErrors] = useState>({}); + const [validating, setValidating] = useState(false); + const [serverDown, setServerDown] = useState(false); + const abortRef = useRef(null); + const timerRef = useRef | null>(null); + + const validate = useCallback(async (draft: ConfigureFormValues) => { + if (abortRef.current) abortRef.current.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setValidating(true); + setServerDown(false); + + try { + const res = await fetch("/api/commitments/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...draft, + net_amount: draft.net_amount === "" ? undefined : Number(draft.net_amount), + inactivity_days: draft.inactivity_days === "" ? undefined : Number(draft.inactivity_days), + } satisfies CommitmentDraft), + signal: controller.signal, + }); + + const data = await res.json(); + const map: Record = {}; + for (const e of (data.errors ?? []) as FieldError[]) { + map[e.field] = e.message; + } + setServerErrors(map); + } catch (err) { + if ((err as Error).name !== "AbortError") { + setServerDown(true); + } + } finally { + if (!controller.signal.aborted) setValidating(false); + } + }, []); + + useEffect(() => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => validate(values), DEBOUNCE_MS); + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [values, validate]); + + // cancel in-flight request on unmount + useEffect(() => () => { abortRef.current?.abort(); }, []); + + const handleChange = (field: keyof ConfigureFormValues) => + (e: React.ChangeEvent) => + setValues((v) => ({ ...v, [field]: e.target.value })); + + const isBlocked = validating || Object.keys(serverErrors).length > 0; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!isBlocked) onAdvance(values); + }; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + {serverDown && ( +

+ Validation service unavailable — you may still proceed. +

+ )} + + +
+ ); +} + +// ─── Tiny accessible field wrapper ─────────────────────────────────────────── + +function Field({ + id, + label, + error, + children, +}: { + id: string; + label: string; + error?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {error && ( + + {error} + + )} +
+ ); +} diff --git a/frontend/docs/CREATE_DRAFT_VALIDATION.md b/frontend/docs/CREATE_DRAFT_VALIDATION.md new file mode 100644 index 000000000..e0650af2e --- /dev/null +++ b/frontend/docs/CREATE_DRAFT_VALIDATION.md @@ -0,0 +1,97 @@ +# CREATE_DRAFT_VALIDATION + +Documents the inline server-side draft validation wired into the Create Commitment wizard configure step. + +--- + +## Endpoint + +``` +POST /api/commitments/validate +``` + +### Request body + +All fields are optional in the body; the endpoint returns per-field errors for whatever is present and invalid. + +| Field | Type | Rules | +|---|---|---| +| `title` | `string` | Min 3 characters | +| `net_amount` | `number` | Positive finite number | +| `currency_preference` | `string` | One of `XLM USDC EURC NGN KES BRL PHP EUR USD` (case-insensitive) | +| `beneficiary_name` | `string` | Min 2 characters | +| `bank_account_number` | `string` | 6–20 digits, no spaces | +| `bank_name` | `string` | Min 2 characters | +| `inactivity_days` | `number` | 30–3650 inclusive (omit field to skip validation) | + +### Response + +```ts +{ + valid: boolean; // true only when errors is empty + errors: Array<{ + field: string; // matches the request field name + message: string; // human-readable message shown next to the input + }>; +} +``` + +**HTTP status** is always `200` for validation results. `400` is returned only for a malformed JSON body. + +--- + +## Component contract — `CreateCommitmentStepConfigure` + +```tsx + + onAdvance: (values: ConfigureFormValues) => void +/> +``` + +### Validation lifecycle + +1. User edits any field. +2. A 500 ms debounce timer starts; any new edit resets it. +3. After 500 ms, `POST /api/commitments/validate` is called with an `AbortController` signal. +4. If a new edit arrives before the response, the in-flight request is aborted. +5. On success, field errors are mapped to the corresponding inputs and the Continue button is disabled while errors exist. +6. If the endpoint is unreachable (network error), a non-blocking warning banner is shown and the Continue button remains enabled. +7. On unmount, the current in-flight request is aborted. + +### Accessibility + +Every field that has a server error gets: + +- `aria-invalid="true"` on the `` / `