diff --git a/components/settings/ProfileSection.tsx b/components/settings/ProfileSection.tsx index 5766b09e..b1c10b13 100644 --- a/components/settings/ProfileSection.tsx +++ b/components/settings/ProfileSection.tsx @@ -4,7 +4,7 @@ import { useState, useCallback } from "react"; import { User } from "lucide-react"; import { useClientTranslator } from "@/lib/i18n/client"; import { useAutosave } from "@/lib/hooks/useAutosave"; -import { validateProfileForm, type ProfileFormValidationResult } from "@/lib/validation/profile"; +import { isValidProfilePhone } from "@/lib/validation/phone"; import { SectionCard, SectionHeader, @@ -18,7 +18,7 @@ export function ProfileSection() { const [name, setName] = useState("Amara Osei"); const [email, setEmail] = useState("amara@example.com"); const [phone, setPhone] = useState("+234 801 234 5678"); - const [errors, setErrors] = useState({}); + const [phoneError, setPhoneError] = useState(null); const onSave = useCallback(async () => { await new Promise((resolve) => setTimeout(resolve, 300)); @@ -50,7 +50,15 @@ export function ProfileSection() { const handlePhoneChange = (value: string) => { setPhone(value); - revalidateAndSave({ name, email, phone: value }); + + // Reject saving an invalid number, but don't nag the user while + // they're still mid-edit of an otherwise-valid international number. + if (!isValidProfilePhone(value)) { + setPhoneError(t("settings.profile.phone_invalid")); + return; + } + setPhoneError(null); + triggerSave(); }; return ( @@ -119,9 +127,9 @@ export function ProfileSection() { onChange={handlePhoneChange} placeholderKey="settings.profile.phone_placeholder" /> - {errors.phone && ( -

- {t(`errors.${errors.phone}`)} + {phoneError && ( +

+ {phoneError}

)} diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index 0c3ee2eb..d57dc71f 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -117,8 +117,8 @@ } }, "settings": { - "wallet": { - "payout_iban_invalid": "Enter a valid IBAN (e.g. DE89 3704 0044 0532 0130 00)." + "profile": { + "phone_invalid": "Enter a valid phone number, including country code (e.g. +1 555 123 4567)." } } } diff --git a/lib/i18n/locales/es.json b/lib/i18n/locales/es.json index 51864f58..636b3707 100644 --- a/lib/i18n/locales/es.json +++ b/lib/i18n/locales/es.json @@ -117,8 +117,8 @@ } }, "settings": { - "wallet": { - "payout_iban_invalid": "Introduce un IBAN válido (p. ej. DE89 3704 0044 0532 0130 00)." + "profile": { + "phone_invalid": "Introduce un número de teléfono válido, incluyendo el código de país (p. ej. +1 555 123 4567)." } } } diff --git a/lib/validation/phone.ts b/lib/validation/phone.ts new file mode 100644 index 00000000..c7fe14f9 --- /dev/null +++ b/lib/validation/phone.ts @@ -0,0 +1,22 @@ +import { isValidPhoneNumber } from "libphonenumber-js"; + +/** + * Validates a phone number via `libphonenumber-js`. Requires an + * international, `+`-prefixed number (e.g. `"+234 801 234 5678"`) since + * there is no country selector alongside this field to supply a default + * region -- `libphonenumber-js` can't validate a bare national number + * without one. + * + * An empty/whitespace-only value is treated as valid: this field isn't + * required, and "not filled in yet" shouldn't be reported as "invalid". + */ +export function isValidProfilePhone(value: string): boolean { + const trimmed = value.trim(); + if (trimmed.length === 0) return true; + + try { + return isValidPhoneNumber(trimmed); + } catch { + return false; + } +} diff --git a/package-lock.json b/package-lock.json index 4d882b17..f3a64bc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "fast-check": "^4.8.0", "i18next": "^25.10.9", "iron-session": "^8.0.4", + "libphonenumber-js": "^1.13.9", "lru-cache": "^11.2.6", "lucide-react": "^0.575.0", "next": "^16.1.6", @@ -21507,6 +21508,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.13.9", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.9.tgz", + "integrity": "sha512-VNS5vWMM7r0P66BYv+TQJATxExEgLxN+34hfHDVhDkUsGAE4cRg0shCNSLTXNKm7nIUscC7AfB51TjxEeF7msQ==", + "license": "MIT" + }, "node_modules/lighthouse": { "version": "11.7.1", "resolved": "https://registry.npmjs.org/lighthouse/-/lighthouse-11.7.1.tgz", diff --git a/package.json b/package.json index daa2c354..84d5da46 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "fast-check": "^4.8.0", "i18next": "^25.10.9", "iron-session": "^8.0.4", + "libphonenumber-js": "^1.13.9", "lru-cache": "^11.2.6", "lucide-react": "^0.575.0", "next": "^16.1.6", @@ -57,7 +58,6 @@ "zod": "^4.3.6" }, "devDependencies": { - "@axe-core/playwright": "^4.12.1", "@playwright/test": "^1.58.2", "@storybook/nextjs": "^10.5.5", "@storybook/react": "^10.5.5", diff --git a/tests/unit/settings/ProfileSection.test.tsx b/tests/unit/settings/ProfileSection.test.tsx new file mode 100644 index 00000000..42ff9cca --- /dev/null +++ b/tests/unit/settings/ProfileSection.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ProfileSection } from "@/components/settings/ProfileSection"; +import { ToastProvider } from "@/lib/context/ToastContext"; + +function renderProfileSection() { + return render( + + + + ); +} + +describe("ProfileSection phone validation", () => { + it("shows a validation error for an invalid phone number and does not clear it while still invalid", async () => { + const user = userEvent.setup(); + renderProfileSection(); + + const phoneInput = screen.getByDisplayValue("+234 801 234 5678"); + await user.clear(phoneInput); + await user.type(phoneInput, "not a phone number"); + + expect(await screen.findByRole("alert")).toHaveTextContent(/valid phone number/i); + }); + + it("clears the error once a valid international number is entered", async () => { + const user = userEvent.setup(); + renderProfileSection(); + + const phoneInput = screen.getByDisplayValue("+234 801 234 5678"); + await user.clear(phoneInput); + await user.type(phoneInput, "invalid"); + expect(await screen.findByRole("alert")).toBeInTheDocument(); + + await user.clear(phoneInput); + await user.type(phoneInput, "+1 415 555 2671"); + + await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); + }); + + it("does not show an error for the initial valid value", () => { + renderProfileSection(); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/tests/unit/validation/phone.test.ts b/tests/unit/validation/phone.test.ts new file mode 100644 index 00000000..ee823254 --- /dev/null +++ b/tests/unit/validation/phone.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { isValidProfilePhone } from "@/lib/validation/phone"; + +describe("isValidProfilePhone", () => { + it("accepts a valid international number", () => { + expect(isValidProfilePhone("+234 801 234 5678")).toBe(true); + expect(isValidProfilePhone("+1 415 555 2671")).toBe(true); + }); + + it("treats an empty or whitespace-only value as valid (field is optional)", () => { + expect(isValidProfilePhone("")).toBe(true); + expect(isValidProfilePhone(" ")).toBe(true); + }); + + it("rejects a number missing the country code", () => { + expect(isValidProfilePhone("801 234 5678")).toBe(false); + }); + + it("rejects garbage input", () => { + expect(isValidProfilePhone("not a phone number")).toBe(false); + }); + + it("rejects a too-short number", () => { + expect(isValidProfilePhone("+1 555")).toBe(false); + }); +});