From a8f1b2dd23d60c23e0975219deee249a993e80e5 Mon Sep 17 00:00:00 2001 From: FadyGergesRezk <84906847+FadyGergesRezk@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:56:43 +0200 Subject: [PATCH 01/13] feat: add a single client error adapter and shared field-validation helpers --- web-client/src/lib/server-error.test.ts | 185 ++++++++++++++++++++++++ web-client/src/lib/server-error.ts | 138 +++++++++++++++--- web-client/src/lib/validation.ts | 57 ++++++++ 3 files changed, 362 insertions(+), 18 deletions(-) create mode 100644 web-client/src/lib/server-error.test.ts create mode 100644 web-client/src/lib/validation.ts diff --git a/web-client/src/lib/server-error.test.ts b/web-client/src/lib/server-error.test.ts new file mode 100644 index 00000000..41148c28 --- /dev/null +++ b/web-client/src/lib/server-error.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest' +import { httpError } from '@/testing/httpError' +import { classifyError, parseServerError, serverErrorFieldMessages, serverErrorMessage } from './server-error' + +function networkError(message = 'Network Error'): Error { + return Object.assign(new Error(message), { isAxiosError: true, response: undefined }) +} + +function helperError(status: number, error: string): Error { + return Object.assign(new Error(error), { + isAxiosError: true, + response: { status, data: { error } }, + }) +} + +function responseError(status: number, data: unknown): Error { + return Object.assign(new Error('Request failed'), { + isAxiosError: true, + response: { status, data }, + }) +} + +describe('parseServerError', () => { + it('reads a Spring { message } body', () => { + const parsed = parseServerError(httpError(409, 'Member already exists')) + expect(parsed.kind).toBe('conflict') + expect(parsed.status).toBe(409) + expect(parsed.message).toBe('Member already exists') + expect(parsed.fieldErrors).toBeNull() + }) + + it('reads a helper Flask { error } body, using safe copy for 5xx', () => { + const parsed = parseServerError(helperError(502, 'Upstream model unavailable')) + expect(parsed.kind).toBe('server') + expect(parsed.message).toBe('The service is unavailable right now. Try again in a moment.') + }) + + it('reads a helper Flask { error } body for a 4xx status', () => { + const parsed = parseServerError(helperError(404, 'No summary available for this member')) + expect(parsed.kind).toBe('notFound') + expect(parsed.message).toBe('No summary available for this member') + }) + + it('maps errors: [{ message: "field: reason" }] onto field names', () => { + const parsed = parseServerError( + httpError(400, 'Validation failed', [ + { message: 'first_name: must not be blank' }, + { message: 'email: must be a valid email' }, + ]), + ) + expect(parsed.kind).toBe('validation') + expect(parsed.fieldErrors).toEqual({ + first_name: 'must not be blank', + email: 'must be a valid email', + }) + }) + + it('only exposes field errors from validation responses', () => { + const data = { + message: 'Internal Server Error', + errors: [{ message: 'stack: internal implementation detail' }], + } + + const parsed = parseServerError(responseError(500, data)) + expect(parsed.message).toBe('The service is unavailable right now. Try again in a moment.') + expect(parsed.fieldErrors).toBeNull() + }) + + it('falls back safely when the response body has malformed fields', () => { + const parsed = parseServerError( + responseError(400, { + message: { internal: true }, + errors: [null, { message: 42 }], + }), + ) + + expect(parsed.message).toBe('Check the highlighted fields and try again.') + expect(parsed.fieldErrors).toBeNull() + }) + + it('reads the helper error when the Spring message has the wrong type', () => { + const parsed = parseServerError( + responseError(403, { message: { internal: true }, error: 'Access is restricted' }), + ) + + expect(parsed.message).toBe('Access is restricted') + }) + + // 403 bodies are deliberate product copy ("You are not allowed to …"), so they are shown + // as-is; only 401 (session gone) and 5xx (may leak internals) are overridden. + it('keeps the server message on a 403', () => { + const parsed = parseServerError( + httpError(403, 'You are not allowed to create feedback for this member'), + ) + expect(parsed.kind).toBe('forbidden') + expect(parsed.message).toBe('You are not allowed to create feedback for this member') + }) + + it('falls back to safe copy-by-status when the message is generic', () => { + expect(parseServerError(httpError(400, 'Validation failed')).message).toBe( + 'Check the highlighted fields and try again.', + ) + expect(parseServerError(httpError(401, 'Unauthorized')).message).toBe( + 'Your session expired. Sign in again.', + ) + expect(parseServerError(httpError(403, '')).message).toBe( + 'You do not have access to this content.', + ) + expect(parseServerError(httpError(404, '')).message).toBe( + 'This item could not be found.', + ) + expect(parseServerError(httpError(409, 'Validation failed')).message).toBe( + 'This changed while you were working. Refresh and try again.', + ) + expect(parseServerError(httpError(500, 'Internal Server Error')).message).toBe( + 'The service is unavailable right now. Try again in a moment.', + ) + }) + + it('prefers a specific, user-safe server message over the generic fallback', () => { + const parsed = parseServerError(httpError(404, 'No team found with id 42')) + expect(parsed.message).toBe('No team found with id 42') + }) + + it('classifies a network error with no response', () => { + const parsed = parseServerError(networkError()) + expect(parsed.kind).toBe('network') + expect(parsed.message).toBe('Could not reach the server. Check your connection and try again.') + expect(parsed.status).toBeUndefined() + }) + + it('falls back to a generic message for non-Error throw values', () => { + expect(parseServerError('boom').message).toBe('Something went wrong.') + expect(parseServerError(undefined).message).toBe('Something went wrong.') + expect(parseServerError({ weird: true }).message).toBe('Something went wrong.') + }) + + it('uses the Error message for plain JS errors', () => { + const parsed = parseServerError(new Error('Unexpected token')) + expect(parsed.kind).toBe('unknown') + expect(parsed.message).toBe('Unexpected token') + }) +}) + +describe('classifyError', () => { + it('classifies by HTTP status', () => { + expect(classifyError(httpError(400, 'x'))).toBe('validation') + expect(classifyError(httpError(401, 'x'))).toBe('unauthenticated') + expect(classifyError(httpError(403, 'x'))).toBe('forbidden') + expect(classifyError(httpError(404, 'x'))).toBe('notFound') + expect(classifyError(httpError(409, 'x'))).toBe('conflict') + expect(classifyError(httpError(500, 'x'))).toBe('server') + }) + + it('classifies a response-less axios error as network', () => { + expect(classifyError(networkError())).toBe('network') + }) + + it('classifies anything else as unknown', () => { + expect(classifyError(new Error('x'))).toBe('unknown') + expect(classifyError('boom')).toBe('unknown') + }) +}) + +describe('serverErrorMessage', () => { + it('unwraps parseServerError.message', () => { + expect(serverErrorMessage(httpError(404, 'Team not found'))).toBe('Team not found') + }) + + it('uses the provided fallback for non-Error throw values', () => { + expect(serverErrorMessage('boom', 'Custom fallback')).toBe('Custom fallback') + }) +}) + +describe('serverErrorFieldMessages', () => { + it('unwraps parseServerError.fieldErrors', () => { + expect( + serverErrorFieldMessages(httpError(400, 'Validation failed', [{ message: 'name: required' }])), + ).toEqual({ name: 'required' }) + }) + + it('returns null when there are no field errors', () => { + expect(serverErrorFieldMessages(httpError(409, 'Conflict'))).toBeNull() + }) +}) diff --git a/web-client/src/lib/server-error.ts b/web-client/src/lib/server-error.ts index d3dfdbbf..75d5ebb9 100644 --- a/web-client/src/lib/server-error.ts +++ b/web-client/src/lib/server-error.ts @@ -1,32 +1,73 @@ import { isAxiosError } from 'axios' -type ServerErrorBody = { - message?: string - errors?: { message?: string }[] +export type ErrorKind = + | 'validation' + | 'unauthenticated' + | 'forbidden' + | 'notFound' + | 'conflict' + | 'server' + | 'network' + | 'unknown' + +export interface ParsedServerError { + kind: ErrorKind + status?: number + message: string + fieldErrors: Record | null +} + +const FALLBACK_MESSAGE = 'Something went wrong.' + +const STATUS_COPY: Partial> = { + 400: 'Check the highlighted fields and try again.', + 401: 'Your session expired. Sign in again.', + 403: 'You do not have access to this content.', + 404: 'This item could not be found.', + 409: 'This changed while you were working. Refresh and try again.', +} + +function classifyStatus(status: number): ErrorKind { + if (status === 400) return 'validation' + if (status === 401) return 'unauthenticated' + if (status === 403) return 'forbidden' + if (status === 404) return 'notFound' + if (status === 409) return 'conflict' + if (status >= 500) return 'server' + return 'unknown' +} + +function safeCopyForStatus(status: number): string { + if (status >= 500) return 'The service is unavailable right now. Try again in a moment.' + return STATUS_COPY[status] ?? FALLBACK_MESSAGE } -export function serverErrorMessage(error: unknown, fallback = 'Something went wrong.'): string { - if (isAxiosError(error)) { - return error.response?.data?.message ?? error.message +/** Classifies an unknown thrown value into a broad error category, based on HTTP status when available. */ +export function classifyError(error: unknown): ErrorKind { + if (isAxiosError(error)) { + if (!error.response) return 'network' + return classifyStatus(error.response.status) } - return error instanceof Error ? error.message : fallback + return 'unknown' } -/** - * Bean-validation 400s carry `errors: [{message: "field: reason"}]` — map each onto its - * field name so a form can highlight the specific input. Returns null when the error isn't - * that shape (e.g. a top-level 409/403 message), so callers fall back to serverErrorMessage. - */ -export function serverErrorFieldMessages(error: unknown): Record | null { - if (!isAxiosError(error)) return null +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} - const errors = error.response?.data?.errors - if (!errors || errors.length === 0) return null +function extractServerMessage(body: unknown): string | undefined { + if (!isRecord(body)) return undefined + if (typeof body.message === 'string') return body.message + return typeof body.error === 'string' ? body.error : undefined +} + +function extractFieldErrors(body: unknown): Record | null { + if (!isRecord(body) || !Array.isArray(body.errors) || body.errors.length === 0) return null const fields: Record = {} - for (const item of errors) { - if (!item.message) continue + for (const item of body.errors) { + if (!isRecord(item) || typeof item.message !== 'string' || !item.message) continue const separatorIndex = item.message.indexOf(':') if (separatorIndex === -1) continue const field = item.message.slice(0, separatorIndex).trim() @@ -36,3 +77,64 @@ export function serverErrorFieldMessages(error: unknown): Record return Object.keys(fields).length > 0 ? fields : null } + +/** + * The single client error adapter. Reads Spring `{ message }` and helper Flask `{ error }` + * response bodies, maps bean-validation `errors: [{message: "field: reason"}]` onto field + * names, and falls back to safe user-facing copy by HTTP status when the server didn't send + * a usable message. + */ +export function parseServerError(error: unknown): ParsedServerError { + if (isAxiosError(error)) { + const body = error.response?.data + const status = error.response?.status + + if (status === undefined) { + return { + kind: 'network', + message: 'Could not reach the server. Check your connection and try again.', + fieldErrors: null, + } + } + + const fieldErrors = status === 400 ? extractFieldErrors(body) : null + const serverMessage = extractServerMessage(body) + // 5xx bodies can carry stack traces or internal detail, and 401 means the session is gone + // (whatever the server says, "sign in again" is the only useful instruction). Everything + // else — notably 403, whose messages are deliberate product copy ("You are not allowed to + // create feedback for this member") — is more useful than the generic per-status fallback. + // "Validation failed" is Spring's placeholder for a body whose real detail is in `errors`. + const trustServerMessage = + status !== 401 && status < 500 && serverMessage !== 'Validation failed' + const message = + serverMessage && trustServerMessage ? serverMessage : safeCopyForStatus(status) + + return { + kind: classifyStatus(status), + status, + message, + fieldErrors, + } + } + + if (error instanceof Error) { + return { kind: 'unknown', message: error.message, fieldErrors: null } + } + + return { kind: 'unknown', message: FALLBACK_MESSAGE, fieldErrors: null } +} + +export function serverErrorMessage(error: unknown, fallback = FALLBACK_MESSAGE): string { + if (!isAxiosError(error) && !(error instanceof Error)) return fallback + + return parseServerError(error).message +} + +/** + * Bean-validation 400s carry `errors: [{message: "field: reason"}]` — map each onto its + * field name so a form can highlight the specific input. Returns null when the error isn't + * that shape (e.g. a top-level 409/403 message), so callers fall back to serverErrorMessage. + */ +export function serverErrorFieldMessages(error: unknown): Record | null { + return parseServerError(error).fieldErrors +} diff --git a/web-client/src/lib/validation.ts b/web-client/src/lib/validation.ts new file mode 100644 index 00000000..51da5307 --- /dev/null +++ b/web-client/src/lib/validation.ts @@ -0,0 +1,57 @@ +import type { ZodError } from 'zod' + +export type FieldErrors = Record + +type SafeParseResult = { success: true } | { success: false; error: ZodError } + +interface SafeParseSchema { + safeParse: (form: TForm) => SafeParseResult +} + +export function flattenZodFieldErrors(error: ZodError): FieldErrors | null { + const fields: FieldErrors = {} + + for (const issue of error.issues) { + const [field] = issue.path + if (field === undefined) continue + + const fieldName = String(field) + if (fields[fieldName] === undefined) fields[fieldName] = issue.message + } + + return Object.keys(fields).length > 0 ? fields : null +} + +export function validateZodSchema( + schema: SafeParseSchema, + form: TForm, +): FieldErrors | null { + const result = schema.safeParse(form) + return result.success ? null : flattenZodFieldErrors(result.error) +} + +export function pickFieldErrors( + errors: FieldErrors | null, + fieldNames: readonly string[], +): FieldErrors | null { + if (!errors) return null + + const picked: FieldErrors = {} + for (const field of fieldNames) { + if (errors[field] !== undefined) picked[field] = errors[field] + } + + return Object.keys(picked).length > 0 ? picked : null +} + +export function fieldError( + errors: FieldErrors | null, + ...fieldNames: readonly string[] +): string | undefined { + for (const field of fieldNames) { + const message = errors?.[field] + if (message !== undefined) return message + } + + return undefined +} From f790d954dcc0544ef3eb0856aeb9fcaa1e883085 Mon Sep 17 00:00:00 2001 From: FadyGergesRezk <84906847+FadyGergesRezk@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:07:57 +0200 Subject: [PATCH 02/13] feat: add toast, pending-button and inline error primitives for mutation feedback --- web-client/package.json | 4 +- web-client/pnpm-lock.yaml | 88 +++++++++++++++++++ web-client/src/app/layout/AppShell.tsx | 18 +++- web-client/src/components/ui/ErrorNotice.tsx | 48 ++++++++++ .../components/ui/dialog-form-skeleton.tsx | 14 +++ .../src/components/ui/dialog-stepper.tsx | 7 +- .../src/components/ui/pending-button.test.tsx | 52 +++++++++++ .../src/components/ui/pending-button.tsx | 35 ++++++++ web-client/src/components/ui/sonner.tsx | 62 +++++++++++++ web-client/src/components/ui/spinner.tsx | 18 ++++ web-client/src/lib/mutation-feedback-copy.ts | 11 +++ web-client/src/lib/mutation-feedback.test.ts | 47 ++++++++++ web-client/src/lib/mutation-feedback.ts | 31 +++++++ 13 files changed, 429 insertions(+), 6 deletions(-) create mode 100644 web-client/src/components/ui/ErrorNotice.tsx create mode 100644 web-client/src/components/ui/dialog-form-skeleton.tsx create mode 100644 web-client/src/components/ui/pending-button.test.tsx create mode 100644 web-client/src/components/ui/pending-button.tsx create mode 100644 web-client/src/components/ui/sonner.tsx create mode 100644 web-client/src/components/ui/spinner.tsx create mode 100644 web-client/src/lib/mutation-feedback-copy.ts create mode 100644 web-client/src/lib/mutation-feedback.test.ts create mode 100644 web-client/src/lib/mutation-feedback.ts diff --git a/web-client/package.json b/web-client/package.json index 32955900..56412f5b 100644 --- a/web-client/package.json +++ b/web-client/package.json @@ -18,7 +18,7 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest", - "e2e": "playwright test", + "e2e": "pnpm build && playwright test", "e2e:ui": "playwright test --ui" }, "dependencies": { @@ -40,9 +40,11 @@ "react-day-picker": "^10.0.1", "react-dom": "^19.2.6", "react-hook-form": "^7.77.0", + "react-phone-number-input": "^3.4.17", "react-router-dom": "^7.15.1", "rolldown": "^1.0.2", "rollup": "^4.60.4", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.0", "tslib": "^2.8.1", diff --git a/web-client/pnpm-lock.yaml b/web-client/pnpm-lock.yaml index 3aee697c..43b7220f 100644 --- a/web-client/pnpm-lock.yaml +++ b/web-client/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: react-hook-form: specifier: ^7.77.0 version: 7.77.0(react@19.2.6) + react-phone-number-input: + specifier: ^3.4.17 + version: 3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-router-dom: specifier: ^7.15.1 version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -71,6 +74,9 @@ importers: rollup: specifier: ^4.60.4 version: 4.60.4 + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -2180,6 +2186,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -2271,6 +2280,9 @@ packages: typescript: optional: true + country-flag-icons@1.6.20: + resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2815,6 +2827,17 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + input-format@0.3.14: + resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==} + peerDependencies: + react: '>=18.1.0' + react-dom: '>=18.1.0' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -3003,6 +3026,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.13.8: + resolution: {integrity: sha512-80xal1m93rADejw2pMp2MSzFhHCPLEspjHxnH2UtqI+DgAmElsbmLMiqk9niwH9NWAfjsRtaJI+qBrOEmRx9nQ==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -3084,6 +3110,10 @@ packages: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -3393,6 +3423,9 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -3454,6 +3487,15 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-phone-number-input@3.4.17: + resolution: {integrity: sha512-1wcjhBAWHgEBAGLi5/XbeZI7Q3aEHNb2z/dHY6R2Gz70TQvu0ZoOT28NTdwtZf4lyRKXWufnTzVhLPBUD8LfmQ==} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -3641,6 +3683,12 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -6148,6 +6196,8 @@ snapshots: dependencies: clsx: 2.1.1 + classnames@2.5.1: {} + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -6222,6 +6272,8 @@ snapshots: optionalDependencies: typescript: 6.0.3 + country-flag-icons@1.6.20: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6799,6 +6851,13 @@ snapshots: inherits@2.0.4: {} + input-format@0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + prop-types: 15.8.1 + optionalDependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -6957,6 +7016,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.13.8: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -7017,6 +7078,10 @@ snapshots: chalk: 5.6.2 is-unicode-supported: 1.3.0 + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + loupe@3.2.1: {} lru-cache@10.4.3: {} @@ -7308,6 +7373,12 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -7412,6 +7483,18 @@ snapshots: dependencies: react: 19.2.6 + react-is@16.13.1: {} + + react-phone-number-input@3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + classnames: 2.5.1 + country-flag-icons: 1.6.20 + input-format: 0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + libphonenumber-js: 1.13.8 + prop-types: 15.8.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.6): dependencies: react: 19.2.6 @@ -7699,6 +7782,11 @@ snapshots: sisteransi@1.0.5: {} + sonner@2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + source-map-js@1.2.1: {} source-map@0.6.1: {} diff --git a/web-client/src/app/layout/AppShell.tsx b/web-client/src/app/layout/AppShell.tsx index 2bee0499..2a15f8a4 100644 --- a/web-client/src/app/layout/AppShell.tsx +++ b/web-client/src/app/layout/AppShell.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from 'react' import { NavLink, Outlet } from 'react-router-dom' import { CalendarDays, @@ -15,12 +16,12 @@ import { User, Users, } from 'lucide-react' -import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { useTheme } from '@/app/theme/useTheme' import type { Theme } from '@/app/theme/ThemeContext' import { NAV_ITEMS } from '@/app/navPolicy' import { useAuth } from '@/features/auth' import { Avatar, AvatarFallback } from '@/components/ui/avatar' +import { Toaster } from '@/components/ui/sonner' import { DropdownMenu, DropdownMenuContent, @@ -46,6 +47,14 @@ import { useSidebar, } from '@/components/ui/sidebar' +const ReactQueryDevtools = import.meta.env.DEV + ? lazy(() => + import('@tanstack/react-query-devtools').then((module) => ({ + default: module.ReactQueryDevtools, + })), + ) + : null + // Icons are a presentational concern, so they're kept local to the sidebar // rather than in the shared nav policy. const NAV_ICONS: Record = { @@ -201,7 +210,12 @@ function AppShellContent() { - + {ReactQueryDevtools && ( + + + + )} + ) } diff --git a/web-client/src/components/ui/ErrorNotice.tsx b/web-client/src/components/ui/ErrorNotice.tsx new file mode 100644 index 00000000..4297b51e --- /dev/null +++ b/web-client/src/components/ui/ErrorNotice.tsx @@ -0,0 +1,48 @@ +import { AlertCircle, RotateCw } from 'lucide-react' + +import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +interface ErrorNoticeProps { + title?: string + message: string + onRetry?: () => void + retryLabel?: string + compact?: boolean + className?: string +} + +/** + * Shared inline error UI for query/detail failures. Not for render crashes (GlobalErrorBoundary) + * or auth bootstrap failures (AuthenticatedApp) — those keep using ErrorCard. + */ +export function ErrorNotice({ + title = 'Something went wrong', + message, + onRetry, + retryLabel = 'Try again', + compact = false, + className, +}: ErrorNoticeProps) { + return ( + + + {!compact && {title}} + {message} + {onRetry && ( + + + + )} + + ) +} diff --git a/web-client/src/components/ui/dialog-form-skeleton.tsx b/web-client/src/components/ui/dialog-form-skeleton.tsx new file mode 100644 index 00000000..40e14447 --- /dev/null +++ b/web-client/src/components/ui/dialog-form-skeleton.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export function DialogFormSkeleton() { + return ( +
+ +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
+ ) +} diff --git a/web-client/src/components/ui/dialog-stepper.tsx b/web-client/src/components/ui/dialog-stepper.tsx index df06b956..5795fb7f 100644 --- a/web-client/src/components/ui/dialog-stepper.tsx +++ b/web-client/src/components/ui/dialog-stepper.tsx @@ -2,6 +2,7 @@ import { CheckIcon } from 'lucide-react' import { Button } from '@/components/ui/button' import { DialogFooter } from '@/components/ui/dialog' +import { PendingButton } from '@/components/ui/pending-button' import { cn } from '@/lib/utils' export interface DialogStep { @@ -106,9 +107,9 @@ export function DialogStepperFooter({ > {isFirstStep ? 'Cancel' : 'Back'} - + + {isLastStep ? submitLabel : nextLabel} + ) } diff --git a/web-client/src/components/ui/pending-button.test.tsx b/web-client/src/components/ui/pending-button.test.tsx new file mode 100644 index 00000000..20b7e8b8 --- /dev/null +++ b/web-client/src/components/ui/pending-button.test.tsx @@ -0,0 +1,52 @@ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { PendingButton } from './pending-button' + +describe('PendingButton', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + document.body.innerHTML = '
' + container = document.getElementById('root') as HTMLDivElement + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => { + root.unmount() + }) + document.body.innerHTML = '' + }) + + it('renders the label and stays enabled when not pending', async () => { + await act(async () => { + root.render( + + Save profile + , + ) + }) + + const button = container.querySelector('button') + expect(button?.disabled).toBe(false) + expect(button?.textContent).toBe('Save profile') + }) + + it('disables the button and shows the spinner and pending label when pending', async () => { + await act(async () => { + root.render( + + Save profile + , + ) + }) + + const button = container.querySelector('button') + expect(button?.disabled).toBe(true) + expect(button?.textContent).toBe('Saving…') + expect(button?.querySelector('[data-slot="spinner"]')).not.toBeNull() + }) +}) diff --git a/web-client/src/components/ui/pending-button.tsx b/web-client/src/components/ui/pending-button.tsx new file mode 100644 index 00000000..e3411a90 --- /dev/null +++ b/web-client/src/components/ui/pending-button.tsx @@ -0,0 +1,35 @@ +import * as React from "react" + +import { Button } from "@/components/ui/button" +import { Spinner } from "@/components/ui/spinner" + +interface PendingButtonProps extends React.ComponentProps { + isPending: boolean + pendingLabel: string +} + +function PendingButton({ + isPending, + pendingLabel, + disabled, + children, + ...props +}: PendingButtonProps) { + return ( + + ) +} + +function PendingButtonContent({ pendingLabel }: { pendingLabel: string }) { + return ( + <> + + {pendingLabel} + + ) +} + +export { PendingButton, PendingButtonContent } +export type { PendingButtonProps } diff --git a/web-client/src/components/ui/sonner.tsx b/web-client/src/components/ui/sonner.tsx new file mode 100644 index 00000000..6ef33e1f --- /dev/null +++ b/web-client/src/components/ui/sonner.tsx @@ -0,0 +1,62 @@ +import { Check, Info, TriangleAlert, XCircle } from 'lucide-react' +import { Toaster as Sonner, type ToasterProps } from 'sonner' + +import { useTheme } from '@/app/theme/useTheme' + +// Toasts share the app's crisp, square geometry. A quiet tinted rail and icon tile make the +// status scannable without turning the whole notification into a loud coloured surface. +// +// Sonner already renders its toasts inside an aria-live="polite" region labelled by +// `containerAriaLabel`, so we must NOT wrap it in a live region of our own — nesting two makes +// announcements duplicate or drop. Tests reach toasts through that region (see e2e toastRegion). +export const TOAST_CONTAINER_LABEL = 'Notifications' + +function Toaster({ ...props }: ToasterProps) { + const { theme } = useTheme() + + return ( + , + error: , + warning: , + info: , + }} + toastOptions={{ + unstyled: true, + classNames: { + toast: + 'group relative flex min-h-16 w-[calc(100vw-1.5rem)] max-w-[25rem] items-center gap-3 overflow-hidden border border-border/90 bg-popover/95 py-3 pl-3 pr-4 text-text-primary shadow-[0_14px_40px_-18px_oklch(0.14_0.01_286/0.45)] backdrop-blur-md ' + + 'before:absolute before:inset-y-0 before:left-0 before:w-1 before:bg-border before:content-[""]', + icon: 'flex size-9 shrink-0 items-center justify-center bg-surface-sunken text-text-secondary [&>svg]:size-4.5', + content: 'flex min-w-0 flex-1 flex-col justify-center gap-0.5', + title: 'text-body-sm font-semibold leading-5 tracking-[-0.015em]', + description: 'text-caption leading-4 text-text-secondary', + success: 'before:bg-primary [&_[data-icon]]:bg-primary/12 [&_[data-icon]]:text-[oklch(0.48_0.16_130.85)] dark:[&_[data-icon]]:text-primary', + error: + 'before:bg-destructive [&_[data-icon]]:bg-destructive/10 [&_[data-icon]]:text-destructive', + // No warning token in the theme; use the same amber the StatCard "positive/negative" + // tones use inline, so warnings still read as amber without an unbacked utility. + warning: + 'before:bg-[oklch(0.75_0.15_75)] [&_[data-icon]]:bg-[oklch(0.75_0.15_75/0.12)] [&_[data-icon]]:text-[oklch(0.55_0.14_75)] dark:[&_[data-icon]]:text-[oklch(0.8_0.14_75)]', + info: 'before:bg-accent-foreground [&_[data-icon]]:bg-accent [&_[data-icon]]:text-accent-foreground', + actionButton: + 'shrink-0 border border-primary bg-primary px-3 py-1.5 text-caption font-semibold text-primary-foreground transition-opacity hover:opacity-90', + cancelButton: + 'shrink-0 border border-border bg-surface-sunken px-3 py-1.5 text-caption font-semibold text-text-secondary transition-colors hover:text-text-primary', + }, + }} + {...props} + /> + ) +} + +export { Toaster } diff --git a/web-client/src/components/ui/spinner.tsx b/web-client/src/components/ui/spinner.tsx new file mode 100644 index 00000000..554d9098 --- /dev/null +++ b/web-client/src/components/ui/spinner.tsx @@ -0,0 +1,18 @@ +import { Loader2Icon } from "lucide-react" + +import { cn } from "@/lib/utils" + +// The spinner is only ever shown beside a visible label (e.g. "Saving…") that already announces +// state, so it's decorative here — hiding it from a11y avoids a second, competing status region. +function Spinner({ className, ...props }: React.ComponentProps<"svg">) { + return ( +