Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions components/settings/ProfileSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<ProfileFormValidationResult["errors"]>({});
const [phoneError, setPhoneError] = useState<string | null>(null);

const onSave = useCallback(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -119,9 +127,9 @@ export function ProfileSection() {
onChange={handlePhoneChange}
placeholderKey="settings.profile.phone_placeholder"
/>
{errors.phone && (
<p className="mt-1 text-xs text-red-500" role="alert">
{t(`errors.${errors.phone}`)}
{phoneError && (
<p role="alert" className="mt-1.5 text-xs text-red-600 dark:text-red-400">
{phoneError}
</p>
)}
</FieldRow>
Expand Down
4 changes: 2 additions & 2 deletions lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
}
}
}
4 changes: 2 additions & 2 deletions lib/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
}
}
}
22 changes: 22 additions & 0 deletions lib/validation/phone.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
47 changes: 47 additions & 0 deletions tests/unit/settings/ProfileSection.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ToastProvider>
<ProfileSection />
</ToastProvider>
);
}

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();
});
});
26 changes: 26 additions & 0 deletions tests/unit/validation/phone.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading