Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 | 2x 2x 2x 2x 2x 2x 2x 16x 1x 15x 15x 13x 4x 9x 17x 1x 16x 13x 13x 6x 14x 1x 13x 13x 8x 2x 6x 6x 2x 4x | // Client-side profile-field validators for the marketplace Edit Profile form.
//
// IMPORTANT: the API enforces the same rules via Zod `.refine()` /
// `freeTextSchema` in `apps/api/src/routes/homeowner/profile.routes.ts`.
//
// Two patterns coexist here:
// - displayName + phone predate the shared `@interioring/utils` package and
// are still hand-duplicated against the API's Zod regexes (see #382). If
// you edit those regexes, the digit minimum, or the accepted-empty
// semantics, update the Zod schema too.
// - Newer free-text fields (locality, …) consume the shared
// `validateFreeText` helper directly, which is the same building block the
// API's `freeTextSchema` wraps. Drift is impossible by construction —
// prefer this pattern when adding fields.
import {
freeTextErrorMessage,
validateFreeText,
} from "@interioring/utils/validation/free-text";
/** Allowed phone characters (optional `+`, digits, spaces, hyphens, parens). */
const PHONE_ALLOWED = /^\+?[\d\s()-]+$/;
/** Minimum digit count for a phone to be considered a real number. */
const PHONE_MIN_DIGITS = 7;
/** Maximum raw-string length (matches the existing Zod `.max(15)` on the API). */
const PHONE_MAX_LENGTH = 15;
/** Display-name maximum length (matches the existing Zod `.max(100)`). */
const DISPLAY_NAME_MAX_LENGTH = 100;
/** Locality limits — match `freeTextSchema({minLen:2,requireLetter:true,maxLen:100})`
* applied server-side in `apps/api/src/routes/homeowner/profile.routes.ts`. */
const LOCALITY_OPTS = { minLen: 2, requireLetter: true } as const;
const LOCALITY_MAX_LENGTH = 100;
export const PROFILE_VALIDATION_MESSAGES = {
displayName:
"Please enter a valid name (letters are required, not just digits or symbols).",
phone:
"Enter a valid phone number — digits only, with an optional leading + and spaces or hyphens.",
} as const;
/**
* Returns an error string if the display name is invalid, or null if valid.
* Empty string is treated as "cleared" and is accepted (the field is optional).
*/
export function validateDisplayName(value: string): string | null {
// Zod's `.max()` runs on the raw value — match its semantics here so the
// client and server agree on boundary-length input.
if (value.length > DISPLAY_NAME_MAX_LENGTH) {
return `Name must be ${DISPLAY_NAME_MAX_LENGTH} characters or fewer.`;
}
const trimmed = value.trim();
if (trimmed === "") return null;
if (!/\p{L}/u.test(trimmed)) {
return PROFILE_VALIDATION_MESSAGES.displayName;
}
return null;
}
/**
* Returns an error string if the locality is invalid, or null if valid.
* Empty / whitespace-only is treated as "cleared" and is accepted (the field
* is optional). Mirrors the server-side `freeTextSchema` rule from
* `apps/api/src/routes/homeowner/profile.routes.ts` so users see the same
* rejection inline before submit instead of as a generic "Failed to save"
* banner after a 400 response.
*/
export function validateLocality(value: string): string | null {
if (value.length > LOCALITY_MAX_LENGTH) {
return `Must be ${LOCALITY_MAX_LENGTH} characters or fewer.`;
}
if (value.trim() === "") return null;
const error = validateFreeText(value, LOCALITY_OPTS);
// Shared helper returns un-punctuated messages; append a period so this
// reads consistently next to displayName/phone errors in the same form.
if (error) return `${freeTextErrorMessage(error, LOCALITY_OPTS)}.`;
return null;
}
/**
* Returns an error string if the phone is invalid, or null if valid.
* Empty string is treated as "cleared" and is accepted.
*/
export function validatePhone(value: string): string | null {
// Zod's `.max()` runs on the raw value — match its semantics here.
if (value.length > PHONE_MAX_LENGTH) {
return `Phone must be ${PHONE_MAX_LENGTH} characters or fewer.`;
}
const trimmed = value.trim();
if (trimmed === "") return null;
if (!PHONE_ALLOWED.test(trimmed)) {
return PROFILE_VALIDATION_MESSAGES.phone;
}
const digits = trimmed.replace(/\D/g, "");
if (digits.length < PHONE_MIN_DIGITS) {
return PROFILE_VALIDATION_MESSAGES.phone;
}
return null;
}
|