diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b0eba41..10e07c9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -14,10 +14,11 @@ user types we have ## Project Context BottleCRM is a modern CRM application built with: -- **Framework**: SvelteKit 2.21.x, Svelte 5.x, Prisma +- **Framework**: SvelteKit 2.21.x, Svelte 5.1, Prisma - **Styling**: tailwind 4.1.x css - **Database**: postgresql - **Icons**: lucide icons +- **Form Validation**: zod ## Important Notes - We need to ensure access control is strictly enforced based on user roles. diff --git a/package.json b/package.json index 293635b..35cdf35 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@prisma/client": "6.5.0", "axios": "^1.9.0", "date-fns": "^4.1.0", + "libphonenumber-js": "^1.12.9", "marked": "^15.0.12", "svelte-highlight": "^7.8.3", "svelte-meta-tags": "^4.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 638e771..1278876 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + libphonenumber-js: + specifier: ^1.12.9 + version: 1.12.9 marked: specifier: ^15.0.12 version: 15.0.12 @@ -1083,6 +1086,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.12.9: + resolution: {integrity: sha512-VWwAdNeJgN7jFOD+wN4qx83DTPMVPPAUyx9/TUkBXKLiNkuWWk6anV0439tgdtwaJDrEdqkvdN22iA6J4bUCZg==} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -2443,6 +2449,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.12.9: {} + lightningcss-darwin-arm64@1.30.1: optional: true diff --git a/prisma/migrations/20250618024526_/migration.sql b/prisma/migrations/20250618024526_/migration.sql new file mode 100644 index 0000000..0c1386e --- /dev/null +++ b/prisma/migrations/20250618024526_/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `department` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "User" DROP COLUMN "department"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 080afa2..d12c2f0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,7 +22,6 @@ model User { updatedAt DateTime @updatedAt profilePhoto String? phone String? - department String? isActive Boolean @default(true) lastLogin DateTime? accounts Account[] diff --git a/src/lib/utils/phone.js b/src/lib/utils/phone.js new file mode 100644 index 0000000..97ebd1f --- /dev/null +++ b/src/lib/utils/phone.js @@ -0,0 +1,74 @@ +import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js'; + +/** + * Validates a phone number and returns validation result + * @param {string} phoneNumber - The phone number to validate + * @param {string} defaultCountry - Default country code (e.g., 'US') + * @returns {{ isValid: boolean, formatted?: string, error?: string }} + */ +export function validatePhoneNumber(phoneNumber, defaultCountry = 'US') { + if (!phoneNumber || phoneNumber.trim() === '') { + return { isValid: true }; // Allow empty phone numbers + } + + try { + // @ts-ignore - defaultCountry is a valid CountryCode + const isValid = isValidPhoneNumber(phoneNumber, { defaultCountry }); + + if (!isValid) { + return { + isValid: false, + error: 'Please enter a valid phone number' + }; + } + + // Parse and format the phone number + // @ts-ignore - defaultCountry is a valid CountryCode + const parsed = parsePhoneNumber(phoneNumber, { defaultCountry }); + return { + isValid: true, + formatted: parsed.formatInternational() + }; + } catch (error) { + return { + isValid: false, + error: 'Please enter a valid phone number' + }; + } +} + +/** + * Formats a phone number for display + * @param {string} phoneNumber - The phone number to format + * @param {string} defaultCountry - Default country code + * @returns {string} Formatted phone number or original if invalid + */ +export function formatPhoneNumber(phoneNumber, defaultCountry = 'US') { + if (!phoneNumber) return ''; + + try { + // @ts-ignore - defaultCountry is a valid CountryCode + const parsed = parsePhoneNumber(phoneNumber, { defaultCountry }); + return parsed.formatInternational(); + } catch { + return phoneNumber; // Return original if parsing fails + } +} + +/** + * Formats a phone number for storage (E.164 format) + * @param {string} phoneNumber - The phone number to format + * @param {string} defaultCountry - Default country code + * @returns {string} E.164 formatted phone number or original if invalid + */ +export function formatPhoneForStorage(phoneNumber, defaultCountry = 'US') { + if (!phoneNumber) return ''; + + try { + // @ts-ignore - defaultCountry is a valid CountryCode + const parsed = parsePhoneNumber(phoneNumber, { defaultCountry }); + return parsed.format('E.164'); + } catch { + return phoneNumber; // Return original if parsing fails + } +} diff --git a/src/routes/(app)/app/accounts/new/+page.server.js b/src/routes/(app)/app/accounts/new/+page.server.js index 99f69c7..7f33240 100644 --- a/src/routes/(app)/app/accounts/new/+page.server.js +++ b/src/routes/(app)/app/accounts/new/+page.server.js @@ -2,6 +2,7 @@ import { env } from '$env/dynamic/private'; import { redirect } from '@sveltejs/kit'; import prisma from '$lib/prisma'; import { fail } from '@sveltejs/kit'; +import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone.js'; import { industries, accountTypes, @@ -44,13 +45,24 @@ export const actions = { return fail(400, { error: 'Account name is required' }); } + // Validate phone number if provided + let formattedPhone = null; + const phone = formData.get('phone')?.toString(); + if (phone && phone.trim().length > 0) { + const phoneValidation = validatePhoneNumber(phone.trim()); + if (!phoneValidation.isValid) { + return fail(400, { error: phoneValidation.error || 'Please enter a valid phone number' }); + } + formattedPhone = formatPhoneForStorage(phone.trim()); + } + // Extract all form fields const accountData = { name, type: formData.get('type')?.toString() || null, industry: formData.get('industry')?.toString() || null, website: formData.get('website')?.toString() || null, - phone: formData.get('phone')?.toString() || null, + phone: formattedPhone, street: formData.get('street')?.toString() || null, city: formData.get('city')?.toString() || null, state: formData.get('state')?.toString() || null, diff --git a/src/routes/(app)/app/accounts/new/+page.svelte b/src/routes/(app)/app/accounts/new/+page.svelte index 130c215..d624bec 100644 --- a/src/routes/(app)/app/accounts/new/+page.svelte +++ b/src/routes/(app)/app/accounts/new/+page.svelte @@ -164,7 +164,7 @@
{toastMessage}
@@ -241,7 +241,7 @@ name="name" type="text" bind:value={formData.name} - on:input={handleChange} + oninput={handleChange} placeholder="Enter account name" required 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' : ''}" /> @@ -259,7 +259,7 @@ id="type" name="type" bind:value={formData.type} - on:change={handleChange} + onchange={handleChange} 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 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"> {#each data.data.accountTypes as [value, label]} @@ -276,7 +276,7 @@ id="industry" name="industry" bind:value={formData.industry} - on:change={handleChange} + onchange={handleChange} 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 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"> {#each data.data.industries as [value, label]} @@ -293,7 +293,7 @@ id="rating" name="rating" bind:value={formData.rating} - on:change={handleChange} + onchange={handleChange} 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 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"> {#each data.data.ratings as [value, label]} @@ -310,7 +310,7 @@ id="accountOwnership" name="accountOwnership" bind:value={formData.accountOwnership} - on:change={handleChange} + onchange={handleChange} 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 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"> {#each data.data.accountOwnership as [value, label]} @@ -342,7 +342,7 @@ name="website" type="url" bind:value={formData.website} - on:input={handleChange} + oninput={handleChange} placeholder="https://company.com" 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' : ''}" /> {#if errors.website} @@ -361,7 +361,7 @@ name="phone" type="tel" bind:value={formData.phone} - on:input={handleChange} + oninput={handleChange} placeholder="+1 (555) 123-4567" 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' : ''}" /> {#if errors.phone} @@ -392,7 +392,7 @@ name="street" type="text" bind:value={formData.street} - on:input={handleChange} + oninput={handleChange} placeholder="Street address" 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" /> @@ -404,7 +404,7 @@ name="city" type="text" bind:value={formData.city} - on:input={handleChange} + oninput={handleChange} placeholder="City" 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" /> @@ -416,7 +416,7 @@ name="state" type="text" bind:value={formData.state} - on:input={handleChange} + oninput={handleChange} placeholder="State" 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" /> @@ -428,7 +428,7 @@ name="postalCode" type="text" bind:value={formData.postalCode} - on:input={handleChange} + oninput={handleChange} placeholder="Postal code" 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" /> @@ -439,7 +439,7 @@ id="country" name="country" bind:value={formData.country} - on:change={handleChange} + onchange={handleChange} 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 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"> {#each data.data.countries as [value, label]} @@ -472,7 +472,7 @@ type="number" min="0" bind:value={formData.numberOfEmployees} - on:input={handleChange} + oninput={handleChange} placeholder="100" 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' : ''}" /> {#if errors.numberOfEmployees} @@ -493,7 +493,7 @@ min="0" step="0.01" bind:value={formData.annualRevenue} - on:input={handleChange} + oninput={handleChange} placeholder="1000000" 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' : ''}" /> {#if errors.annualRevenue} @@ -512,7 +512,7 @@ name="tickerSymbol" type="text" bind:value={formData.tickerSymbol} - on:input={handleChange} + oninput={handleChange} placeholder="AAPL" 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" /> @@ -527,7 +527,7 @@ name="sicCode" type="text" bind:value={formData.sicCode} - on:input={handleChange} + oninput={handleChange} placeholder="7372" 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" /> @@ -550,7 +550,7 @@ id="description" name="description" bind:value={formData.description} - on:input={handleChange} + oninput={handleChange} placeholder="Additional notes about this account..." rows="4" 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"> @@ -564,7 +564,7 @@This action cannot be undone
++ Are you sure you want to convert {getFullName(lead)} into an account and contact? + This will create new records and mark the lead as converted. +
+ +{toastMessage}
{errors.phone}
{/if} + {#if phoneError} +{phoneError}
+ {/if} @@ -577,7 +603,7 @@ type="email" name="email" bind:value={formData.email} - on:input={handleChange} + oninput={handleChange} placeholder="email@company.com" required 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.email ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> @@ -596,7 +622,7 @@ name="linkedin_url" type="url" bind:value={formData.linkedin_url} - on:input={handleChange} + oninput={handleChange} placeholder="https://linkedin.com/in/username" 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.linkedin_url ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> {#if errors.linkedin_url} @@ -627,7 +653,7 @@ name="address_line" type="text" bind:value={formData.address_line} - on:input={handleChange} + oninput={handleChange} placeholder="Street address" 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" /> @@ -639,7 +665,7 @@ name="city" type="text" bind:value={formData.city} - on:input={handleChange} + oninput={handleChange} placeholder="City" 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" /> @@ -651,7 +677,7 @@ name="state" type="text" bind:value={formData.state} - on:input={handleChange} + oninput={handleChange} placeholder="State" 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" /> @@ -663,7 +689,7 @@ name="postcode" type="text" bind:value={formData.postcode} - on:input={handleChange} + oninput={handleChange} placeholder="Postal code" 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" /> @@ -675,7 +701,7 @@ name="country" type="text" bind:value={formData.country} - on:input={handleChange} + oninput={handleChange} placeholder="Country" 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" /> @@ -698,7 +724,7 @@ id="description" name="description" bind:value={formData.description} - on:input={handleChange} + oninput={handleChange} placeholder="Additional notes about this lead..." rows="3" 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"> @@ -713,7 +739,7 @@ id="pain_points" name="pain_points" bind:value={formData.pain_points} - on:input={handleChange} + oninput={handleChange} placeholder="What challenges is the lead facing?" rows="3" 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"> @@ -730,7 +756,7 @@ name="last_contacted" type="date" bind:value={formData.last_contacted} - on:input={handleChange} + oninput={handleChange} 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 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" /> @@ -743,7 +769,7 @@ name="next_follow_up" type="date" bind:value={formData.next_follow_up} - on:input={handleChange} + oninput={handleChange} 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 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" /> @@ -756,7 +782,7 @@+ Are you sure you want to delete the opportunity "{opportunityToDelete?.name || 'Unknown'}"? + This action cannot be undone and will also delete all associated tasks, events, and comments. +
++ This action cannot be undone. +
++ Warning: Deleting this opportunity will also remove all associated: +
+