Skip to content
This repository was archived by the owner on Nov 29, 2025. It is now read-only.

Commit 875648c

Browse files
committed
feat: add phone number validation and formatting
- Added `libphonenumber-js` dependency for phone number validation and formatting. - Implemented `validatePhoneNumber`, `formatPhoneNumber`, and `formatPhoneForStorage` utility functions. - Integrated phone number validation in account, contact, and lead creation forms. - Enhanced user experience by displaying validation errors for phone numbers in forms. - Updated profile page to validate and format phone numbers on input.
1 parent da0cf64 commit 875648c

File tree

13 files changed

+278
-48
lines changed

13 files changed

+278
-48
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"@prisma/client": "6.5.0",
4747
"axios": "^1.9.0",
4848
"date-fns": "^4.1.0",
49+
"libphonenumber-js": "^1.12.9",
4950
"marked": "^15.0.12",
5051
"svelte-highlight": "^7.8.3",
5152
"svelte-meta-tags": "^4.4.0",

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/utils/phone.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js';
2+
3+
/**
4+
* Validates a phone number and returns validation result
5+
* @param {string} phoneNumber - The phone number to validate
6+
* @param {string} defaultCountry - Default country code (e.g., 'US')
7+
* @returns {{ isValid: boolean, formatted?: string, error?: string }}
8+
*/
9+
export function validatePhoneNumber(phoneNumber, defaultCountry = 'US') {
10+
if (!phoneNumber || phoneNumber.trim() === '') {
11+
return { isValid: true }; // Allow empty phone numbers
12+
}
13+
14+
try {
15+
// @ts-ignore - defaultCountry is a valid CountryCode
16+
const isValid = isValidPhoneNumber(phoneNumber, { defaultCountry });
17+
18+
if (!isValid) {
19+
return {
20+
isValid: false,
21+
error: 'Please enter a valid phone number'
22+
};
23+
}
24+
25+
// Parse and format the phone number
26+
// @ts-ignore - defaultCountry is a valid CountryCode
27+
const parsed = parsePhoneNumber(phoneNumber, { defaultCountry });
28+
return {
29+
isValid: true,
30+
formatted: parsed.formatInternational()
31+
};
32+
} catch (error) {
33+
return {
34+
isValid: false,
35+
error: 'Please enter a valid phone number'
36+
};
37+
}
38+
}
39+
40+
/**
41+
* Formats a phone number for display
42+
* @param {string} phoneNumber - The phone number to format
43+
* @param {string} defaultCountry - Default country code
44+
* @returns {string} Formatted phone number or original if invalid
45+
*/
46+
export function formatPhoneNumber(phoneNumber, defaultCountry = 'US') {
47+
if (!phoneNumber) return '';
48+
49+
try {
50+
// @ts-ignore - defaultCountry is a valid CountryCode
51+
const parsed = parsePhoneNumber(phoneNumber, { defaultCountry });
52+
return parsed.formatInternational();
53+
} catch {
54+
return phoneNumber; // Return original if parsing fails
55+
}
56+
}
57+
58+
/**
59+
* Formats a phone number for storage (E.164 format)
60+
* @param {string} phoneNumber - The phone number to format
61+
* @param {string} defaultCountry - Default country code
62+
* @returns {string} E.164 formatted phone number or original if invalid
63+
*/
64+
export function formatPhoneForStorage(phoneNumber, defaultCountry = 'US') {
65+
if (!phoneNumber) return '';
66+
67+
try {
68+
// @ts-ignore - defaultCountry is a valid CountryCode
69+
const parsed = parsePhoneNumber(phoneNumber, { defaultCountry });
70+
return parsed.format('E.164');
71+
} catch {
72+
return phoneNumber; // Return original if parsing fails
73+
}
74+
}

src/routes/(app)/app/accounts/new/+page.server.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { env } from '$env/dynamic/private';
22
import { redirect } from '@sveltejs/kit';
33
import prisma from '$lib/prisma';
44
import { fail } from '@sveltejs/kit';
5+
import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone.js';
56
import {
67
industries,
78
accountTypes,
@@ -44,13 +45,24 @@ export const actions = {
4445
return fail(400, { error: 'Account name is required' });
4546
}
4647

48+
// Validate phone number if provided
49+
let formattedPhone = null;
50+
const phone = formData.get('phone')?.toString();
51+
if (phone && phone.trim().length > 0) {
52+
const phoneValidation = validatePhoneNumber(phone.trim());
53+
if (!phoneValidation.isValid) {
54+
return fail(400, { error: phoneValidation.error || 'Please enter a valid phone number' });
55+
}
56+
formattedPhone = formatPhoneForStorage(phone.trim());
57+
}
58+
4759
// Extract all form fields
4860
const accountData = {
4961
name,
5062
type: formData.get('type')?.toString() || null,
5163
industry: formData.get('industry')?.toString() || null,
5264
website: formData.get('website')?.toString() || null,
53-
phone: formData.get('phone')?.toString() || null,
65+
phone: formattedPhone,
5466
street: formData.get('street')?.toString() || null,
5567
city: formData.get('city')?.toString() || null,
5668
state: formData.get('state')?.toString() || null,

src/routes/(app)/app/accounts/new/+page.svelte

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@
241241
name="name"
242242
type="text"
243243
bind:value={formData.name}
244-
on:input={handleChange}
244+
oninput={handleChange}
245245
placeholder="Enter account name"
246246
required
247247
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.name ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" />
@@ -342,7 +342,7 @@
342342
name="website"
343343
type="url"
344344
bind:value={formData.website}
345-
on:input={handleChange}
345+
oninput={handleChange}
346346
placeholder="https://company.com"
347347
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.website ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" />
348348
{#if errors.website}
@@ -361,7 +361,7 @@
361361
name="phone"
362362
type="tel"
363363
bind:value={formData.phone}
364-
on:input={handleChange}
364+
oninput={handleChange}
365365
placeholder="+1 (555) 123-4567"
366366
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.phone ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" />
367367
{#if errors.phone}
@@ -392,7 +392,7 @@
392392
name="street"
393393
type="text"
394394
bind:value={formData.street}
395-
on:input={handleChange}
395+
oninput={handleChange}
396396
placeholder="Street address"
397397
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" />
398398
</div>
@@ -404,7 +404,7 @@
404404
name="city"
405405
type="text"
406406
bind:value={formData.city}
407-
on:input={handleChange}
407+
oninput={handleChange}
408408
placeholder="City"
409409
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" />
410410
</div>
@@ -416,7 +416,7 @@
416416
name="state"
417417
type="text"
418418
bind:value={formData.state}
419-
on:input={handleChange}
419+
oninput={handleChange}
420420
placeholder="State"
421421
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" />
422422
</div>
@@ -428,7 +428,7 @@
428428
name="postalCode"
429429
type="text"
430430
bind:value={formData.postalCode}
431-
on:input={handleChange}
431+
oninput={handleChange}
432432
placeholder="Postal code"
433433
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" />
434434
</div>
@@ -472,7 +472,7 @@
472472
type="number"
473473
min="0"
474474
bind:value={formData.numberOfEmployees}
475-
on:input={handleChange}
475+
oninput={handleChange}
476476
placeholder="100"
477477
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.numberOfEmployees ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" />
478478
{#if errors.numberOfEmployees}
@@ -493,7 +493,7 @@
493493
min="0"
494494
step="0.01"
495495
bind:value={formData.annualRevenue}
496-
on:input={handleChange}
496+
oninput={handleChange}
497497
placeholder="1000000"
498498
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.annualRevenue ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" />
499499
{#if errors.annualRevenue}
@@ -512,7 +512,7 @@
512512
name="tickerSymbol"
513513
type="text"
514514
bind:value={formData.tickerSymbol}
515-
on:input={handleChange}
515+
oninput={handleChange}
516516
placeholder="AAPL"
517517
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" />
518518
</div>
@@ -527,7 +527,7 @@
527527
name="sicCode"
528528
type="text"
529529
bind:value={formData.sicCode}
530-
on:input={handleChange}
530+
oninput={handleChange}
531531
placeholder="7372"
532532
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" />
533533
</div>
@@ -550,7 +550,7 @@
550550
id="description"
551551
name="description"
552552
bind:value={formData.description}
553-
on:input={handleChange}
553+
oninput={handleChange}
554554
placeholder="Additional notes about this account..."
555555
rows="4"
556556
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-vertical"></textarea>

src/routes/(app)/app/contacts/[contactId]/edit/+page.server.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import prisma from '$lib/prisma';
22
import { fail, redirect } from '@sveltejs/kit';
3+
import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone.js';
34

45
export async function load({ params, locals }) {
56
const org = locals.org;
@@ -47,6 +48,16 @@ export const actions = {
4748
return fail(400, { message: 'First and last name are required.' });
4849
}
4950

51+
// Validate phone number if provided
52+
let formattedPhone = null;
53+
if (phone && phone.length > 0) {
54+
const phoneValidation = validatePhoneNumber(phone);
55+
if (!phoneValidation.isValid) {
56+
return fail(400, { message: phoneValidation.error || 'Please enter a valid phone number' });
57+
}
58+
formattedPhone = formatPhoneForStorage(phone);
59+
}
60+
5061
const contact = await prisma.contact.findUnique({
5162
where: { id: params.contactId, organizationId: org.id }
5263
});
@@ -61,7 +72,7 @@ export const actions = {
6172
firstName,
6273
lastName,
6374
email,
64-
phone,
75+
phone: formattedPhone,
6576
title,
6677
department,
6778
street,

src/routes/(app)/app/contacts/[contactId]/edit/+page.svelte

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import { onMount } from 'svelte';
55
import { invalidateAll } from '$app/navigation';
66
import { User, Mail, Phone, Building, MapPin, FileText, Star, Save, X, ArrowLeft } from '@lucide/svelte';
7+
import { validatePhoneNumber } from '$lib/utils/phone.js';
8+
79
export let data;
810
911
let contact = data.contact;
@@ -25,6 +27,22 @@
2527
let description = contact.description || '';
2628
let submitting = false;
2729
let errorMsg = '';
30+
let phoneError = '';
31+
32+
// Validate phone number on input
33+
function validatePhone() {
34+
if (!phone.trim()) {
35+
phoneError = '';
36+
return;
37+
}
38+
39+
const validation = validatePhoneNumber(phone);
40+
if (!validation.isValid) {
41+
phoneError = validation.error || 'Invalid phone number';
42+
} else {
43+
phoneError = '';
44+
}
45+
}
2846
2947
async function handleSubmit(e) {
3048
e.preventDefault();
@@ -185,8 +203,12 @@
185203
class="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400"
186204
bind:value={phone}
187205
placeholder="+1 (555) 123-4567"
206+
oninput={validatePhone}
188207
/>
189208
</div>
209+
{#if phoneError}
210+
<p class="mt-2 text-sm text-red-600 dark:text-red-400">{phoneError}</p>
211+
{/if}
190212
</div>
191213
</div>
192214
</div>

src/routes/(app)/app/contacts/new/+page.server.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { redirect, fail } from '@sveltejs/kit';
22
import prisma from '$lib/prisma';
3+
import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone.js';
34

45
/** @type {import('./$types').PageServerLoad} */
56
export async function load({ locals, url }) {
@@ -94,6 +95,17 @@ export const actions = {
9495
errors.email = 'Please enter a valid email address';
9596
}
9697

98+
// Validate phone number if provided
99+
let formattedPhone = null;
100+
if (phone && phone.length > 0) {
101+
const phoneValidation = validatePhoneNumber(phone);
102+
if (!phoneValidation.isValid) {
103+
errors.phone = phoneValidation.error || 'Please enter a valid phone number';
104+
} else {
105+
formattedPhone = formatPhoneForStorage(phone);
106+
}
107+
}
108+
97109
if (Object.keys(errors).length > 0) {
98110
return fail(400, {
99111
errors,
@@ -199,7 +211,7 @@ export const actions = {
199211
firstName,
200212
lastName,
201213
email: email || null,
202-
phone: phone || null,
214+
phone: formattedPhone,
203215
title: title || null,
204216
department: department || null,
205217
street: street || null,

0 commit comments

Comments
 (0)