diff --git a/package-lock.json b/package-lock.json index 242c2db..98a5d3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "leaflet": "^1.9.4", + "libphonenumber-js": "^1.13.7", "lucide-react": "^0.548.0", "motion": "^12.38.0", "next": "16.0.10", @@ -6870,6 +6871,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.7.tgz", + "integrity": "sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", diff --git a/package.json b/package.json index e8b6598..ae27b91 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "leaflet": "^1.9.4", + "libphonenumber-js": "^1.13.7", "lucide-react": "^0.548.0", "motion": "^12.38.0", "next": "16.0.10", diff --git a/public/flags/flags.css b/public/flags/flags.css new file mode 100644 index 0000000..6a65a08 --- /dev/null +++ b/public/flags/flags.css @@ -0,0 +1,740 @@ +span.flag { + width: 44px; + height: 30px; + display: inline-block; +} +img.flag { + width: 30px; +} +.flag { + background: url(./flags_responsive.png) no-repeat; + background-size: 100%; + vertical-align: middle; +} +.flag-ad { + background-position: 0 0.413223%; +} +.flag-ae { + background-position: 0 0.826446%; +} +.flag-af { + background-position: 0 1.239669%; +} +.flag-ag { + background-position: 0 1.652893%; +} +.flag-ai { + background-position: 0 2.066116%; +} +.flag-al { + background-position: 0 2.479339%; +} +.flag-am { + background-position: 0 2.892562%; +} +.flag-an { + background-position: 0 3.305785%; +} +.flag-ao { + background-position: 0 3.719008%; +} +.flag-aq { + background-position: 0 4.132231%; +} +.flag-ar { + background-position: 0 4.545455%; +} +.flag-as { + background-position: 0 4.958678%; +} +.flag-at { + background-position: 0 5.371901%; +} +.flag-au { + background-position: 0 5.785124%; +} +.flag-aw { + background-position: 0 6.198347%; +} +.flag-az { + background-position: 0 6.61157%; +} +.flag-ba { + background-position: 0 7.024793%; +} +.flag-bb { + background-position: 0 7.438017%; +} +.flag-bd { + background-position: 0 7.85124%; +} +.flag-be { + background-position: 0 8.264463%; +} +.flag-bf { + background-position: 0 8.677686%; +} +.flag-bg { + background-position: 0 9.090909%; +} +.flag-bh { + background-position: 0 9.504132%; +} +.flag-bi { + background-position: 0 9.917355%; +} +.flag-bj { + background-position: 0 10.330579%; +} +.flag-bm { + background-position: 0 10.743802%; +} +.flag-bn { + background-position: 0 11.157025%; +} +.flag-bo { + background-position: 0 11.570248%; +} +.flag-br { + background-position: 0 11.983471%; +} +.flag-bs { + background-position: 0 12.396694%; +} +.flag-bt { + background-position: 0 12.809917%; +} +.flag-bv { + background-position: 0 13.22314%; +} +.flag-bw { + background-position: 0 13.636364%; +} +.flag-by { + background-position: 0 14.049587%; +} +.flag-bz { + background-position: 0 14.46281%; +} +.flag-ca { + background-position: 0 14.876033%; +} +.flag-cc { + background-position: 0 15.289256%; +} +.flag-cd { + background-position: 0 15.702479%; +} +.flag-cf { + background-position: 0 16.115702%; +} +.flag-cg { + background-position: 0 16.528926%; +} +.flag-ch { + background-position: 0 16.942149%; +} +.flag-ci { + background-position: 0 17.355372%; +} +.flag-ck { + background-position: 0 17.768595%; +} +.flag-cl { + background-position: 0 18.181818%; +} +.flag-cm { + background-position: 0 18.595041%; +} +.flag-cn { + background-position: 0 19.008264%; +} +.flag-co { + background-position: 0 19.421488%; +} +.flag-cr { + background-position: 0 19.834711%; +} +.flag-cu { + background-position: 0 20.247934%; +} +.flag-cv { + background-position: 0 20.661157%; +} +.flag-cx { + background-position: 0 21.07438%; +} +.flag-cy { + background-position: 0 21.487603%; +} +.flag-cz { + background-position: 0 21.900826%; +} +.flag-de { + background-position: 0 22.31405%; +} +.flag-dj { + background-position: 0 22.727273%; +} +.flag-dk { + background-position: 0 23.140496%; +} +.flag-dm { + background-position: 0 23.553719%; +} +.flag-do { + background-position: 0 23.966942%; +} +.flag-dz { + background-position: 0 24.380165%; +} +.flag-ec { + background-position: 0 24.793388%; +} +.flag-ee { + background-position: 0 25.206612%; +} +.flag-eg { + background-position: 0 25.619835%; +} +.flag-eh { + background-position: 0 26.033058%; +} +.flag-er { + background-position: 0 26.446281%; +} +.flag-es { + background-position: 0 26.859504%; +} +.flag-et { + background-position: 0 27.272727%; +} +.flag-fi { + background-position: 0 27.68595%; +} +.flag-fj { + background-position: 0 28.099174%; +} +.flag-fk { + background-position: 0 28.512397%; +} +.flag-fm { + background-position: 0 28.92562%; +} +.flag-fo { + background-position: 0 29.338843%; +} +.flag-fr { + background-position: 0 29.752066%; +} +.flag-ga { + background-position: 0 30.165289%; +} +.flag-gd { + background-position: 0 30.578512%; +} +.flag-ge { + background-position: 0 30.991736%; +} +.flag-gf { + background-position: 0 31.404959%; +} +.flag-gh { + background-position: 0 31.818182%; +} +.flag-gi { + background-position: 0 32.231405%; +} +.flag-gl { + background-position: 0 32.644628%; +} +.flag-gm { + background-position: 0 33.057851%; +} +.flag-gn { + background-position: 0 33.471074%; +} +.flag-gp { + background-position: 0 33.884298%; +} +.flag-gq { + background-position: 0 34.297521%; +} +.flag-gr { + background-position: 0 34.710744%; +} +.flag-gs { + background-position: 0 35.123967%; +} +.flag-gt { + background-position: 0 35.53719%; +} +.flag-gu { + background-position: 0 35.950413%; +} +.flag-gw { + background-position: 0 36.363636%; +} +.flag-gy { + background-position: 0 36.77686%; +} +.flag-hk { + background-position: 0 37.190083%; +} +.flag-hm { + background-position: 0 37.603306%; +} +.flag-hn { + background-position: 0 38.016529%; +} +.flag-hr { + background-position: 0 38.429752%; +} +.flag-ht { + background-position: 0 38.842975%; +} +.flag-hu { + background-position: 0 39.256198%; +} +.flag-id { + background-position: 0 39.669421%; +} +.flag-ie { + background-position: 0 40.082645%; +} +.flag-il { + background-position: 0 40.495868%; +} +.flag-in { + background-position: 0 40.909091%; +} +.flag-io { + background-position: 0 41.322314%; +} +.flag-iq { + background-position: 0 41.735537%; +} +.flag-ir { + background-position: 0 42.14876%; +} +.flag-is { + background-position: 0 42.561983%; +} +.flag-it { + background-position: 0 42.975207%; +} +.flag-jm { + background-position: 0 43.38843%; +} +.flag-jo { + background-position: 0 43.801653%; +} +.flag-jp { + background-position: 0 44.214876%; +} +.flag-ke { + background-position: 0 44.628099%; +} +.flag-kg { + background-position: 0 45.041322%; +} +.flag-kh { + background-position: 0 45.454545%; +} +.flag-ki { + background-position: 0 45.867769%; +} +.flag-km { + background-position: 0 46.280992%; +} +.flag-kn { + background-position: 0 46.694215%; +} +.flag-kp { + background-position: 0 47.107438%; +} +.flag-kr { + background-position: 0 47.520661%; +} +.flag-kw { + background-position: 0 47.933884%; +} +.flag-ky { + background-position: 0 48.347107%; +} +.flag-kz { + background-position: 0 48.760331%; +} +.flag-la { + background-position: 0 49.173554%; +} +.flag-lb { + background-position: 0 49.586777%; +} +.flag-lc { + background-position: 0 50%; +} +.flag-li { + background-position: 0 50.413223%; +} +.flag-lk { + background-position: 0 50.826446%; +} +.flag-lr { + background-position: 0 51.239669%; +} +.flag-ls { + background-position: 0 51.652893%; +} +.flag-lt { + background-position: 0 52.066116%; +} +.flag-lu { + background-position: 0 52.479339%; +} +.flag-lv { + background-position: 0 52.892562%; +} +.flag-ly { + background-position: 0 53.305785%; +} +.flag-ma { + background-position: 0 53.719008%; +} +.flag-mc { + background-position: 0 54.132231%; +} +.flag-md { + background-position: 0 54.545455%; +} +.flag-me { + background-position: 0 54.958678%; +} +.flag-mg { + background-position: 0 55.371901%; +} +.flag-mh { + background-position: 0 55.785124%; +} +.flag-mk { + background-position: 0 56.198347%; +} +.flag-ml { + background-position: 0 56.61157%; +} +.flag-mm { + background-position: 0 57.024793%; +} +.flag-mn { + background-position: 0 57.438017%; +} +.flag-mo { + background-position: 0 57.85124%; +} +.flag-mp { + background-position: 0 58.264463%; +} +.flag-mq { + background-position: 0 58.677686%; +} +.flag-mr { + background-position: 0 59.090909%; +} +.flag-ms { + background-position: 0 59.504132%; +} +.flag-mt { + background-position: 0 59.917355%; +} +.flag-mu { + background-position: 0 60.330579%; +} +.flag-mv { + background-position: 0 60.743802%; +} +.flag-mw { + background-position: 0 61.157025%; +} +.flag-mx { + background-position: 0 61.570248%; +} +.flag-my { + background-position: 0 61.983471%; +} +.flag-mz { + background-position: 0 62.396694%; +} +.flag-na { + background-position: 0 62.809917%; +} +.flag-nc { + background-position: 0 63.22314%; +} +.flag-ne { + background-position: 0 63.636364%; +} +.flag-nf { + background-position: 0 64.049587%; +} +.flag-ng { + background-position: 0 64.46281%; +} +.flag-ni { + background-position: 0 64.876033%; +} +.flag-nl { + background-position: 0 65.289256%; +} +.flag-no { + background-position: 0 65.702479%; +} +.flag-np { + background-position: 0 66.115702%; +} +.flag-nr { + background-position: 0 66.528926%; +} +.flag-nu { + background-position: 0 66.942149%; +} +.flag-nz { + background-position: 0 67.355372%; +} +.flag-om { + background-position: 0 67.768595%; +} +.flag-pa { + background-position: 0 68.181818%; +} +.flag-pe { + background-position: 0 68.595041%; +} +.flag-pf { + background-position: 0 69.008264%; +} +.flag-pg { + background-position: 0 69.421488%; +} +.flag-ph { + background-position: 0 69.834711%; +} +.flag-pk { + background-position: 0 70.247934%; +} +.flag-pl { + background-position: 0 70.661157%; +} +.flag-pm { + background-position: 0 71.07438%; +} +.flag-pn { + background-position: 0 71.487603%; +} +.flag-pr { + background-position: 0 71.900826%; +} +.flag-pt { + background-position: 0 72.31405%; +} +.flag-pw { + background-position: 0 72.727273%; +} +.flag-py { + background-position: 0 73.140496%; +} +.flag-qa { + background-position: 0 73.553719%; +} +.flag-re { + background-position: 0 73.966942%; +} +.flag-ro { + background-position: 0 74.380165%; +} +.flag-rs { + background-position: 0 74.793388%; +} +.flag-ru { + background-position: 0 75.206612%; +} +.flag-rw { + background-position: 0 75.619835%; +} +.flag-sa { + background-position: 0 76.033058%; +} +.flag-sb { + background-position: 0 76.446281%; +} +.flag-sc { + background-position: 0 76.859504%; +} +.flag-sd { + background-position: 0 77.272727%; +} +.flag-se { + background-position: 0 77.68595%; +} +.flag-sg { + background-position: 0 78.099174%; +} +.flag-sh { + background-position: 0 78.512397%; +} +.flag-si { + background-position: 0 78.92562%; +} +.flag-sj { + background-position: 0 79.338843%; +} +.flag-sk { + background-position: 0 79.752066%; +} +.flag-sl { + background-position: 0 80.165289%; +} +.flag-sm { + background-position: 0 80.578512%; +} +.flag-sn { + background-position: 0 80.991736%; +} +.flag-so { + background-position: 0 81.404959%; +} +.flag-sr { + background-position: 0 81.818182%; +} +.flag-ss { + background-position: 0 82.231405%; +} +.flag-st { + background-position: 0 82.644628%; +} +.flag-sv { + background-position: 0 83.057851%; +} +.flag-sy { + background-position: 0 83.471074%; +} +.flag-sz { + background-position: 0 83.884298%; +} +.flag-tc { + background-position: 0 84.297521%; +} +.flag-td { + background-position: 0 84.710744%; +} +.flag-tf { + background-position: 0 85.123967%; +} +.flag-tg { + background-position: 0 85.53719%; +} +.flag-th { + background-position: 0 85.950413%; +} +.flag-tj { + background-position: 0 86.363636%; +} +.flag-tk { + background-position: 0 86.77686%; +} +.flag-tl { + background-position: 0 87.190083%; +} +.flag-tm { + background-position: 0 87.603306%; +} +.flag-tn { + background-position: 0 88.016529%; +} +.flag-to { + background-position: 0 88.429752%; +} +.flag-tp { + background-position: 0 88.842975%; +} +.flag-tr { + background-position: 0 89.256198%; +} +.flag-tt { + background-position: 0 89.669421%; +} +.flag-tv { + background-position: 0 90.082645%; +} +.flag-tw { + background-position: 0 90.495868%; +} +.flag-ty { + background-position: 0 90.909091%; +} +.flag-tz { + background-position: 0 91.322314%; +} +.flag-ua { + background-position: 0 91.735537%; +} +.flag-ug { + background-position: 0 92.14876%; +} +.flag-gb, +.flag-uk { + background-position: 0 92.561983%; +} +.flag-um { + background-position: 0 92.975207%; +} +.flag-us { + background-position: 0 93.38843%; +} +.flag-uy { + background-position: 0 93.801653%; +} +.flag-uz { + background-position: 0 94.214876%; +} +.flag-va { + background-position: 0 94.628099%; +} +.flag-vc { + background-position: 0 95.041322%; +} +.flag-ve { + background-position: 0 95.454545%; +} +.flag-vg { + background-position: 0 95.867769%; +} +.flag-vi { + background-position: 0 96.280992%; +} +.flag-vn { + background-position: 0 96.694215%; +} +.flag-vu { + background-position: 0 97.107438%; +} +.flag-wf { + background-position: 0 97.520661%; +} +.flag-ws { + background-position: 0 97.933884%; +} +.flag-ye { + background-position: 0 98.347107%; +} +.flag-za { + background-position: 0 98.760331%; +} +.flag-zm { + background-position: 0 99.173554%; +} +.flag-zr { + background-position: 0 99.586777%; +} +.flag-zw { + background-position: 0 100%; +} diff --git a/public/flags/flags_responsive.png b/public/flags/flags_responsive.png new file mode 100644 index 0000000..c27ce21 Binary files /dev/null and b/public/flags/flags_responsive.png differ diff --git a/src/app/(modules)/clients/components/ClientSheet.tsx b/src/app/(modules)/clients/components/ClientSheet.tsx index 82a5254..06e645f 100644 --- a/src/app/(modules)/clients/components/ClientSheet.tsx +++ b/src/app/(modules)/clients/components/ClientSheet.tsx @@ -1,10 +1,15 @@ 'use client'; -import type { ComponentProps } from 'react'; -import type { FieldErrors, UseFormRegister } from 'react-hook-form'; +import { useRef, type ComponentProps } from 'react'; +import { + Controller, + type Control, + type FieldErrors, + type UseFormRegister, +} from 'react-hook-form'; import { Loader2 } from 'lucide-react'; -import { FormField } from '@/components/form'; +import { FormField, PhoneInput } from '@/components/form'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -22,6 +27,7 @@ interface ClientSheetProps { onOpenChange: (open: boolean) => void; clientId?: number; register: UseFormRegister; + control: Control; errors: FieldErrors; isSubmitted: boolean; onSubmit: ComponentProps<'form'>['onSubmit']; @@ -34,12 +40,14 @@ export function ClientSheet({ onOpenChange, clientId, register, + control, errors, isSubmitted, onSubmit, canSubmit, isSaving, }: ClientSheetProps) { + const dropdownPortalRef = useRef(null); const firstNameErrorMessage = isSubmitted ? errors.first_name?.message : undefined; @@ -201,11 +209,19 @@ export function ClientSheet({ required error={contactPhoneErrorMessage} > - ( + + )} /> @@ -244,6 +260,8 @@ export function ClientSheet({ + +
); diff --git a/src/app/(modules)/clients/hooks/useClientForm.ts b/src/app/(modules)/clients/hooks/useClientForm.ts index 080c2fd..525335d 100644 --- a/src/app/(modules)/clients/hooks/useClientForm.ts +++ b/src/app/(modules)/clients/hooks/useClientForm.ts @@ -1,9 +1,9 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; +import { isValidPhoneNumber } from 'libphonenumber-js'; import { useCallback } from 'react'; import { useForm, useWatch } from 'react-hook-form'; -import { toast } from 'sonner'; import { z } from 'zod'; import type { Client } from '@/types'; @@ -23,7 +23,10 @@ const clientFormSchema = z.object({ pan: optionalTaxIdSchema, tan: optionalTaxIdSchema, contact_name: requiredText('Contact name is required'), - contact_phone_number: requiredText('Contact phone number is required'), + contact_phone_number: requiredText('Contact phone number is required').refine( + (value) => isValidPhoneNumber(value), + 'Enter a valid phone number', + ), contact_email: requiredText('Contact email is required').email( 'Invalid contact email', ), @@ -87,18 +90,7 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) { contactPhoneNumber.trim().length > 0 && contactEmail.trim().length > 0; - const handleSubmit = submitForm( - (values) => saveMutation.mutate(values), - (formErrors) => { - const firstError = Object.values(formErrors).find( - (error) => error?.message, - ); - - if (firstError?.message) { - toast.error(firstError.message); - } - }, - ); + const handleSubmit = submitForm((values) => saveMutation.mutate(values)); const openCreate = useCallback(() => { reset(defaultValues); @@ -127,6 +119,7 @@ export function useClientForm({ onSaved }: { onSaved: () => void }) { return { register, + control, handleSubmit, reset, openCreate, diff --git a/src/app/(modules)/clients/page.tsx b/src/app/(modules)/clients/page.tsx index 427120d..9021134 100644 --- a/src/app/(modules)/clients/page.tsx +++ b/src/app/(modules)/clients/page.tsx @@ -48,6 +48,7 @@ export default function ClientsPage() { clientForm; const { register, + control, handleSubmit, clientId, errors, @@ -134,6 +135,7 @@ export default function ClientsPage() { onOpenChange={setIsSheetOpen} clientId={clientId} register={register} + control={control} errors={errors} isSubmitted={isSubmitted} onSubmit={handleSubmit} diff --git a/src/app/(modules)/package/components/PackageDialog.tsx b/src/app/(modules)/package/components/PackageDialog.tsx index b56154a..eba7b77 100644 --- a/src/app/(modules)/package/components/PackageDialog.tsx +++ b/src/app/(modules)/package/components/PackageDialog.tsx @@ -74,7 +74,7 @@ export function PackageDialog({ -
+
diff --git a/src/app/(modules)/package/hooks/usePackageForm.ts b/src/app/(modules)/package/hooks/usePackageForm.ts index 35e0d44..37ecdcc 100644 --- a/src/app/(modules)/package/hooks/usePackageForm.ts +++ b/src/app/(modules)/package/hooks/usePackageForm.ts @@ -103,18 +103,7 @@ export function usePackageForm({ onSaved }: { onSaved: () => void }) { }, }); - const onSubmit = handleSubmit( - (values) => saveMutation.mutate(values), - (formErrors) => { - if (formErrors.project_id?.message) { - toast.error(formErrors.project_id.message); - return; - } - if (formErrors.name?.message) { - toast.error(formErrors.name.message); - } - }, - ); + const onSubmit = handleSubmit((values) => saveMutation.mutate(values)); const openCreate = useCallback(() => { reset(defaultValues); diff --git a/src/app/(modules)/plans/components/PlanSheet.tsx b/src/app/(modules)/plans/components/PlanSheet.tsx index f4353ff..b56945a 100644 --- a/src/app/(modules)/plans/components/PlanSheet.tsx +++ b/src/app/(modules)/plans/components/PlanSheet.tsx @@ -77,7 +77,7 @@ export function PlanSheet({ - +
0 && permissionIds.length > 0; - const onSubmit = handleSubmit( - (values) => { - saveMutation.mutate({ - ...values, - name: values.name.trim(), - slug: values.slug.trim(), - description: values.description.trim(), - price: values.price.trim(), - trial_days: toNumber(values.trial_days), - max_projects: toNumber(values.max_projects), - max_organizations: toNumber(values.max_organizations), - max_users: toNumber(values.max_users), - max_roles: toNumber(values.max_roles), - }); - }, - (formErrors) => { - const firstMessage = Object.values(formErrors).find( - (error) => error?.message, - )?.message; - - if (firstMessage) { - toast.error(String(firstMessage)); - } - }, - ); + const onSubmit = handleSubmit((values) => { + saveMutation.mutate({ + ...values, + name: values.name.trim(), + slug: values.slug.trim(), + description: values.description.trim(), + price: values.price.trim(), + trial_days: toNumber(values.trial_days), + max_projects: toNumber(values.max_projects), + max_organizations: toNumber(values.max_organizations), + max_users: toNumber(values.max_users), + max_roles: toNumber(values.max_roles), + }); + }); const openCreate = useCallback(() => { reset({ diff --git a/src/app/(modules)/project/components/ProjectDialog.tsx b/src/app/(modules)/project/components/ProjectDialog.tsx index 6ba7de5..eaa48b7 100644 --- a/src/app/(modules)/project/components/ProjectDialog.tsx +++ b/src/app/(modules)/project/components/ProjectDialog.tsx @@ -66,7 +66,7 @@ export function ProjectDialog({ - +
diff --git a/src/app/(modules)/project/hooks/useProjectForm.ts b/src/app/(modules)/project/hooks/useProjectForm.ts index 5ca8985..d6ae48d 100644 --- a/src/app/(modules)/project/hooks/useProjectForm.ts +++ b/src/app/(modules)/project/hooks/useProjectForm.ts @@ -106,14 +106,7 @@ export function useProjectForm({ onSaved }: { onSaved: () => void }) { }, }); - const onSubmit = handleSubmit( - (values) => saveMutation.mutate(values), - (formErrors) => { - if (formErrors.name?.message) { - toast.error(formErrors.name.message); - } - }, - ); + const onSubmit = handleSubmit((values) => saveMutation.mutate(values)); const openCreate = useCallback(() => { reset(defaultValues); diff --git a/src/app/(modules)/roles/components/RoleSheet.tsx b/src/app/(modules)/roles/components/RoleSheet.tsx index 4182cb5..70868b7 100644 --- a/src/app/(modules)/roles/components/RoleSheet.tsx +++ b/src/app/(modules)/roles/components/RoleSheet.tsx @@ -73,7 +73,7 @@ export function RoleSheet({ - +
saveMutation.mutate(values), - (formErrors) => { - if (formErrors.name?.message) { - toast.error(formErrors.name.message); - return; - } - if (formErrors.display_name?.message) { - toast.error(formErrors.display_name.message); - return; - } - if (formErrors.permission_ids?.message) { - toast.error(formErrors.permission_ids.message); - } - }, - ); + const onSubmit = handleSubmit((values) => saveMutation.mutate(values)); const openCreate = useCallback(() => { reset({ diff --git a/src/app/(modules)/tenants/components/TenantSheet.tsx b/src/app/(modules)/tenants/components/TenantSheet.tsx index 63461af..ac10983 100644 --- a/src/app/(modules)/tenants/components/TenantSheet.tsx +++ b/src/app/(modules)/tenants/components/TenantSheet.tsx @@ -1,10 +1,15 @@ 'use client'; -import type { ComponentProps } from 'react'; -import type { FieldErrors, UseFormRegister } from 'react-hook-form'; +import { useMemo, useRef, type ComponentProps } from 'react'; +import { + Controller, + type Control, + type FieldErrors, + type UseFormRegister, +} from 'react-hook-form'; import { Loader2 } from 'lucide-react'; -import { FormField } from '@/components/form'; +import { FormField, PhoneInput, SelectPopover } from '@/components/form'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -15,16 +20,12 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import type { Client, Plan } from '@/types'; -import type { TenantFormValues } from '../hooks/useTenantForm'; +import type { + TenantAdminFieldLocks, + TenantFormValues, +} from '../hooks/useTenantForm'; interface TenantSheetProps { open: boolean; @@ -32,6 +33,7 @@ interface TenantSheetProps { tenantId?: number; isEditMode: boolean; register: UseFormRegister; + control: Control; errors: FieldErrors; isSubmitted: boolean; onSubmit: ComponentProps<'form'>['onSubmit']; @@ -46,6 +48,7 @@ interface TenantSheetProps { isLookupsLoading: boolean; isSaving: boolean; adminEmail?: string; + adminFieldLocks: TenantAdminFieldLocks; } export function TenantSheet({ @@ -54,6 +57,7 @@ export function TenantSheet({ tenantId, isEditMode, register, + control, errors, isSubmitted, onSubmit, @@ -68,10 +72,30 @@ export function TenantSheet({ isLookupsLoading, isSaving, adminEmail, + adminFieldLocks, }: TenantSheetProps) { + const dropdownPortalRef = useRef(null); const fieldError = (field: keyof TenantFormValues) => isSubmitted ? errors[field]?.message : undefined; + const clientOptions = useMemo( + () => + clients.map((client) => ({ + value: String(client.id), + label: [client.first_name, client.last_name].filter(Boolean).join(' '), + })), + [clients], + ); + + const planOptions = useMemo( + () => + plans.map((plan) => ({ + value: String(plan.id), + label: plan.name, + })), + [plans], + ); + return ( @@ -85,7 +109,11 @@ export function TenantSheet({ - +

Basic Information

@@ -129,34 +157,23 @@ export function TenantSheet({
- + portalContainer={dropdownPortalRef} + placeholder={ + isLookupsLoading ? 'Loading clients...' : 'Select client' + } + searchPlaceholder="Search clients" + emptyMessage="No clients found." + /> - + portalContainer={dropdownPortalRef} + placeholder={ + isLookupsLoading ? 'Loading plans...' : 'Select plan' + } + searchPlaceholder="Search plans" + emptyMessage="No plans found." + /> @@ -256,6 +267,7 @@ export function TenantSheet({ id="admin-last-name" placeholder="Enter last name" aria-invalid={!!fieldError('admin_last_name')} + readOnly={adminFieldLocks.admin_last_name} {...register('admin_last_name')} /> @@ -273,6 +285,7 @@ export function TenantSheet({ type="email" placeholder="admin@example.com" aria-invalid={!!fieldError('admin_email')} + readOnly={adminFieldLocks.admin_email} {...register('admin_email')} /> @@ -282,11 +295,20 @@ export function TenantSheet({ label="Phone Number" error={fieldError('admin_phone_number')} > - ( + + )} />
@@ -322,6 +344,8 @@ export function TenantSheet({ + +
); diff --git a/src/app/(modules)/tenants/hooks/useTenantForm.ts b/src/app/(modules)/tenants/hooks/useTenantForm.ts index 6cb9d31..398adbf 100644 --- a/src/app/(modules)/tenants/hooks/useTenantForm.ts +++ b/src/app/(modules)/tenants/hooks/useTenantForm.ts @@ -1,35 +1,35 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useCallback } from 'react'; +import { isValidPhoneNumber } from 'libphonenumber-js'; +import { useCallback, useState } from 'react'; import { useForm, useWatch } from 'react-hook-form'; -import { toast } from 'sonner'; import { z } from 'zod'; import type { Tenant } from '@/types'; import { useSaveTenantMutation } from './useTenantMutations'; -const optionalPhoneSchema = z - .string() - .trim() - .refine((value) => value === '' || /^\+(?:[0-9] ?|-){6,18}[0-9]$/.test(value), { - message: 'Please enter a valid phone number', - }); +const requiredText = (message: string) => z.string().trim().min(1, message); const tenantFormSchema = z .object({ id: z.number().optional(), - name: z.string().trim().min(1, 'Tenant name is required'), - slug: z.string().trim().min(1, 'Slug is required'), - client_id: z.string().trim().min(1, 'Client is required'), - plan_id: z.string().trim().min(1, 'Plan is required'), + name: requiredText('Tenant name is required'), + slug: requiredText('Slug is required'), + client_id: requiredText('Client is required'), + plan_id: requiredText('Plan is required'), domain: z.string().trim(), description: z.string().trim(), admin_first_name: z.string().trim(), admin_last_name: z.string().trim(), admin_email: z.string().trim(), - admin_phone_number: optionalPhoneSchema, + admin_phone_number: z + .string() + .trim() + .refine((value) => value === '' || isValidPhoneNumber(value), { + message: 'Enter a valid phone number', + }), }) .superRefine((values, ctx) => { if (values.id) return; @@ -38,7 +38,7 @@ const tenantFormSchema = z ctx.addIssue({ code: 'custom', path: ['admin_first_name'], - message: 'Admin first name is required', + message: 'First name is required', }); } @@ -46,7 +46,7 @@ const tenantFormSchema = z ctx.addIssue({ code: 'custom', path: ['admin_last_name'], - message: 'Admin last name is required', + message: 'Last name is required', }); } @@ -98,6 +98,20 @@ function splitContactName(contactName: string) { return { firstName: parts[0], lastName: parts.slice(1).join(' ') }; } +export type TenantAdminFieldLocks = { + admin_first_name: boolean; + admin_last_name: boolean; + admin_email: boolean; + admin_phone_number: boolean; +}; + +const defaultAdminFieldLocks: TenantAdminFieldLocks = { + admin_first_name: false, + admin_last_name: false, + admin_email: false, + admin_phone_number: false, +}; + export function useTenantForm({ onSaved }: { onSaved: () => void }) { const { register, @@ -115,10 +129,15 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) { const saveMutation = useSaveTenantMutation({ onSaved: () => { reset(defaultValues); + setAdminFieldLocks(defaultAdminFieldLocks); onSaved(); }, }); + const [adminFieldLocks, setAdminFieldLocks] = useState( + defaultAdminFieldLocks, + ); + const tenantId = useWatch({ control, name: 'id' }); const clientId = useWatch({ control, name: 'client_id' }) || ''; const planId = useWatch({ control, name: 'plan_id' }) || ''; @@ -138,21 +157,11 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) { adminLastName.trim().length > 0 && adminEmail.trim().length > 0)); - const handleSubmit = submitForm( - (values) => saveMutation.mutate(values), - (formErrors) => { - const firstMessage = Object.values(formErrors).find( - (error) => error?.message, - )?.message; - - if (firstMessage) { - toast.error(String(firstMessage)); - } - }, - ); + const handleSubmit = submitForm((values) => saveMutation.mutate(values)); const openCreate = useCallback(() => { reset(defaultValues); + setAdminFieldLocks(defaultAdminFieldLocks); }, [reset]); const openEdit = useCallback( @@ -170,19 +179,37 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) { admin_email: tenant.admin_email || '', admin_phone_number: '', }); + setAdminFieldLocks(defaultAdminFieldLocks); }, [reset], ); + const clearAdminFields = useCallback(() => { + setValue('admin_first_name', '', { shouldDirty: true }); + setValue('admin_last_name', '', { shouldDirty: true }); + setValue('admin_email', '', { shouldDirty: true }); + setValue('admin_phone_number', '', { shouldDirty: true }); + setAdminFieldLocks(defaultAdminFieldLocks); + }, [setValue]); + const applyClientAdminDefaults = useCallback( (contactName: string, contactEmail: string, contactPhone: string) => { if (isEditMode) return; const { firstName, lastName } = splitContactName(contactName); + const trimmedEmail = contactEmail.trim(); + const trimmedPhone = contactPhone.trim(); + setValue('admin_first_name', firstName, { shouldDirty: true }); setValue('admin_last_name', lastName, { shouldDirty: true }); - setValue('admin_email', contactEmail, { shouldDirty: true }); - setValue('admin_phone_number', contactPhone, { shouldDirty: true }); + setValue('admin_email', trimmedEmail, { shouldDirty: true }); + setValue('admin_phone_number', trimmedPhone, { shouldDirty: true }); + setAdminFieldLocks({ + admin_first_name: firstName.trim().length > 0, + admin_last_name: lastName.trim().length > 0, + admin_email: trimmedEmail.length > 0, + admin_phone_number: trimmedPhone.length > 0, + }); }, [isEditMode, setValue], ); @@ -197,9 +224,9 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) { return { register, + control, handleSubmit, reset, - control, setValue, openCreate, openEdit, @@ -211,7 +238,9 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) { errors, isSubmitted, canSubmit, + adminFieldLocks, applyClientAdminDefaults, + clearAdminFields, syncSlugFromName, isSaving: isSubmitting || saveMutation.isPending, }; diff --git a/src/app/(modules)/tenants/page.tsx b/src/app/(modules)/tenants/page.tsx index 194248f..1a1deea 100644 --- a/src/app/(modules)/tenants/page.tsx +++ b/src/app/(modules)/tenants/page.tsx @@ -54,6 +54,7 @@ export default function TenantsPage() { }); const { register, + control, handleSubmit, openCreate: prepareCreateTenant, openEdit: prepareEditTenant, @@ -65,7 +66,9 @@ export default function TenantsPage() { errors, isSubmitted, canSubmit, + adminFieldLocks, applyClientAdminDefaults, + clearAdminFields, syncSlugFromName, setValue, isSaving, @@ -77,7 +80,11 @@ export default function TenantsPage() { (value: string) => { setValue('client_id', value, { shouldDirty: true, shouldValidate: true }); - if (!value || isEditMode) return; + if (isEditMode) return; + + clearAdminFields(); + + if (!value) return; clientService.getClientById(Number(value)).then((client) => { applyClientAdminDefaults( @@ -87,7 +94,7 @@ export default function TenantsPage() { ); }); }, - [applyClientAdminDefaults, isEditMode, setValue], + [applyClientAdminDefaults, clearAdminFields, isEditMode, setValue], ); const handlePlanChange = useCallback( @@ -183,6 +190,7 @@ export default function TenantsPage() { tenantId={tenantId} isEditMode={isEditMode} register={register} + control={control} errors={errors} isSubmitted={isSubmitted} onSubmit={handleSubmit} @@ -197,6 +205,7 @@ export default function TenantsPage() { isLookupsLoading={isLookupsLoading} isSaving={isSaving} adminEmail={adminEmail} + adminFieldLocks={adminFieldLocks} /> ); diff --git a/src/app/(modules)/users/components/UserSheet.tsx b/src/app/(modules)/users/components/UserSheet.tsx index 71e1653..601c17b 100644 --- a/src/app/(modules)/users/components/UserSheet.tsx +++ b/src/app/(modules)/users/components/UserSheet.tsx @@ -1,10 +1,15 @@ 'use client'; import { useRef, type ComponentProps } from 'react'; -import type { FieldErrors, UseFormRegister } from 'react-hook-form'; +import { + Controller, + type Control, + type FieldErrors, + type UseFormRegister, +} from 'react-hook-form'; import { Loader2 } from 'lucide-react'; -import { FormField } from '@/components/form'; +import { FormField, PhoneInput } from '@/components/form'; import { RoleSelect } from '@/components/lookups/RoleSelect'; import { Button } from '@/components/ui/button'; import { @@ -23,6 +28,7 @@ interface UserSheetProps { onOpenChange: (open: boolean) => void; userId?: number; register: UseFormRegister; + control: Control; errors: FieldErrors; isSubmitted: boolean; onSubmit: ComponentProps<'form'>['onSubmit']; @@ -37,6 +43,7 @@ export function UserSheet({ onOpenChange, userId, register, + control, errors, isSubmitted, onSubmit, @@ -72,6 +79,7 @@ export function UserSheet({
@@ -126,16 +134,29 @@ export function UserSheet({ label="Phone Number" error={phoneErrorMessage} > - ( + + )} />
- + value === '' || /^\+(?:[0-9] ?){6,14}[0-9]$/.test(value), { - message: 'Please enter a valid phone number', - }); +const requiredText = (message: string) => z.string().trim().min(1, message); const userFormSchema = z.object({ id: z.number().optional(), - first_name: z.string().trim().min(1, 'First name is required'), - last_name: z.string().trim().min(1, 'Last name is required'), - email: z.string().trim().min(1, 'Email is required').email('Invalid email'), - phone_number: optionalPhoneSchema, - role_id: z.string().trim().min(1, 'Role is required'), + first_name: requiredText('First name is required'), + last_name: requiredText('Last name is required'), + email: requiredText('Email is required').email('Invalid email'), + phone_number: z + .string() + .trim() + .refine((value) => value === '' || isValidPhoneNumber(value), { + message: 'Enter a valid phone number', + }), + role_id: requiredText('Role is required'), }); export type UserFormValues = z.infer; @@ -61,41 +61,13 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) { const firstName = useWatch({ control, name: 'first_name' }) || ''; const lastName = useWatch({ control, name: 'last_name' }) || ''; const email = useWatch({ control, name: 'email' }) || ''; - const phoneNumber = useWatch({ control, name: 'phone_number' }) || ''; - const isPhoneValid = - phoneNumber.trim() === '' || - /^\+(?:[0-9] ?){6,14}[0-9]$/.test(phoneNumber.trim()); const canSubmit = firstName.trim().length > 0 && lastName.trim().length > 0 && email.trim().length > 0 && - roleId.trim().length > 0 && - isPhoneValid; + roleId.trim().length > 0; - const handleSubmit = submitForm( - (values) => saveMutation.mutate(values), - (formErrors) => { - if (formErrors.first_name?.message) { - toast.error(formErrors.first_name.message); - return; - } - if (formErrors.last_name?.message) { - toast.error(formErrors.last_name.message); - return; - } - if (formErrors.email?.message) { - toast.error(formErrors.email.message); - return; - } - if (formErrors.phone_number?.message) { - toast.error(formErrors.phone_number.message); - return; - } - if (formErrors.role_id?.message) { - toast.error(formErrors.role_id.message); - } - }, - ); + const handleSubmit = submitForm((values) => saveMutation.mutate(values)); const openCreate = useCallback(() => { reset(defaultValues); @@ -125,6 +97,7 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) { return { register, + control, handleSubmit, reset, openCreate, diff --git a/src/app/(modules)/users/page.tsx b/src/app/(modules)/users/page.tsx index 11e1d2e..cd809ee 100644 --- a/src/app/(modules)/users/page.tsx +++ b/src/app/(modules)/users/page.tsx @@ -49,6 +49,7 @@ export default function UsersPage() { openCreate: prepareCreateUser, openEdit: prepareEditUser, register, + control, handleSubmit, userId, roleId, @@ -144,6 +145,7 @@ export default function UsersPage() { onOpenChange={setIsSheetOpen} userId={userId} register={register} + control={control} errors={errors} isSubmitted={isSubmitted} onSubmit={handleSubmit} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ffd07ba..edf5867 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -32,9 +32,10 @@ export default function RootLayout({ }>) { return ( - + + + + {children} {error ? ( -

+

{error}

) : null} diff --git a/src/components/form/index.ts b/src/components/form/index.ts index 4d58867..f048e60 100644 --- a/src/components/form/index.ts +++ b/src/components/form/index.ts @@ -1,6 +1,7 @@ export { FormField } from './FormField'; export { MultiSelectPopover } from './MultiSelectPopover'; export type { MultiSelectOption } from './MultiSelectPopover'; +export { PhoneInput } from './phone-input/PhoneInput'; export { SelectPopover } from './SelectPopover'; export type { SelectPopoverOption } from './SelectPopover'; export { PasswordField } from './PasswordField'; diff --git a/src/components/form/phone-input/CountryCombobox.tsx b/src/components/form/phone-input/CountryCombobox.tsx new file mode 100644 index 0000000..26eb91d --- /dev/null +++ b/src/components/form/phone-input/CountryCombobox.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { useCallback, useRef, useState, type RefObject } from 'react'; +import type { CountryCode } from 'libphonenumber-js'; +import type { Virtualizer } from '@tanstack/react-virtual'; + +import { Button } from '@/components/ui/button'; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxList, + ComboboxTrigger, +} from '@/components/ui/combobox'; + +import { + COUNTRY_OPTIONS, + filterCountryOption, +} from './countries'; +import { CountryFlag } from './CountryFlag'; +import type { CountryOption } from './types'; +import { VirtualCountryList } from './VirtualCountryList'; + +type VirtualizerHandle = Virtualizer; + +interface CountryComboboxProps { + value: CountryCode; + onValueChange: (country: CountryCode) => void; + disabled?: boolean; + portalContainer?: RefObject; +} + +export function CountryCombobox({ + value, + onValueChange, + disabled = false, + portalContainer, +}: CountryComboboxProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const virtualizerRef = useRef(null); + + const selectedOption = + COUNTRY_OPTIONS.find((option) => option.value === value) ?? null; + + const handleOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setSearch(''); + } + }, []); + + const handleValueChange = useCallback( + (item: CountryOption | null) => { + if (item?.value) { + onValueChange(item.value); + } + }, + [onValueChange], + ); + + const handleItemHighlighted = useCallback( + ( + _item: CountryOption | undefined, + event: { reason: string; index: number }, + ) => { + const virtualizer = virtualizerRef.current; + if (!virtualizer) { + return; + } + + const { reason, index } = event; + const isStart = index === 0; + const isEnd = index === virtualizer.options.count - 1; + const shouldScroll = + reason === 'none' || (reason === 'keyboard' && (isStart || isEnd)); + + if (shouldScroll) { + queueMicrotask(() => { + virtualizer.scrollToIndex(index, { align: isEnd ? 'start' : 'end' }); + }); + } + }, + [], + ); + + return ( + item.label} + isItemEqualToValue={(item, currentValue) => + item.value === currentValue.value + } + onItemHighlighted={handleItemHighlighted} + > + + + + } + /> + + + No countries found. + + + + + + ); +} diff --git a/src/components/form/phone-input/CountryFlag.tsx b/src/components/form/phone-input/CountryFlag.tsx new file mode 100644 index 0000000..fb251dd --- /dev/null +++ b/src/components/form/phone-input/CountryFlag.tsx @@ -0,0 +1,15 @@ +import type { CountryCode } from 'libphonenumber-js'; + +import { cn } from '@/lib/utils'; + +export function CountryFlag({ country }: { country: CountryCode }) { + return ( +
+ ); +} diff --git a/src/components/form/phone-input/PhoneInput.tsx b/src/components/form/phone-input/PhoneInput.tsx new file mode 100644 index 0000000..7450f4e --- /dev/null +++ b/src/components/form/phone-input/PhoneInput.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useEffect, useState, type RefObject } from 'react'; +import { + parsePhoneNumberFromString, + type CountryCode, +} from 'libphonenumber-js'; + +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; + +import { buildPhoneValue, getNationalValue } from './countries'; +import { CountryCombobox } from './CountryCombobox'; + +interface PhoneInputProps { + value?: string; + onChange: (value: string) => void; + onBlur?: () => void; + disabled?: boolean; + 'aria-invalid'?: boolean; + placeholder?: string; + defaultCountry?: CountryCode; + portalContainer?: RefObject; +} + +export function PhoneInput({ + value = '', + onChange, + onBlur, + disabled = false, + 'aria-invalid': ariaInvalid, + placeholder = 'Enter phone number', + defaultCountry = 'IN', + portalContainer, +}: PhoneInputProps) { + const parsedCountry = parsePhoneNumberFromString(value)?.country; + const [country, setCountry] = useState( + parsedCountry ?? defaultCountry, + ); + + useEffect(() => { + const parsed = parsePhoneNumberFromString(value); + if (parsed?.country) { + setCountry(parsed.country); + } + }, [value]); + + const nationalValue = getNationalValue(value, country); + + function handleCountryChange(nextCountry: CountryCode) { + setCountry(nextCountry); + onChange(buildPhoneValue(nextCountry, nationalValue)); + } + + function handleNationalChange(nextNationalValue: string) { + onChange(buildPhoneValue(country, nextNationalValue)); + } + + return ( +
+ + + handleNationalChange(event.target.value)} + onBlur={onBlur} + disabled={disabled} + aria-invalid={ariaInvalid} + placeholder={placeholder} + className="h-9 flex-1 rounded-none border-0 bg-transparent px-3 py-1 shadow-none focus-visible:ring-0" + /> +
+ ); +} diff --git a/src/components/form/phone-input/VirtualCountryList.tsx b/src/components/form/phone-input/VirtualCountryList.tsx new file mode 100644 index 0000000..94b3e4e --- /dev/null +++ b/src/components/form/phone-input/VirtualCountryList.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { + useCallback, + useImperativeHandle, + useRef, + type RefObject, +} from 'react'; +import { Combobox } from '@base-ui/react/combobox'; +import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'; + +import { ComboboxItem } from '@/components/ui/combobox'; +import { cn } from '@/lib/utils'; + +import { CountryFlag } from './CountryFlag'; +import type { CountryOption } from './types'; + +type VirtualizerHandle = Virtualizer; + +interface VirtualCountryListProps { + open: boolean; + virtualizerRef: RefObject; + estimateSize?: number; +} + +export function VirtualCountryList({ + open, + virtualizerRef, + estimateSize = 36, +}: VirtualCountryListProps) { + const filteredItems = Combobox.useFilteredItems(); + const scrollElementRef = useRef(null); + + const virtualizer = useVirtualizer({ + enabled: open, + count: filteredItems.length, + getScrollElement: () => scrollElementRef.current, + estimateSize: () => estimateSize, + overscan: 8, + paddingStart: 4, + paddingEnd: 4, + }); + + useImperativeHandle(virtualizerRef, () => virtualizer, [virtualizer]); + + const handleScrollElementRef = useCallback( + (element: HTMLDivElement | null) => { + scrollElementRef.current = element; + if (element) { + virtualizer.measure(); + } + }, + [virtualizer], + ); + + const virtualItems = virtualizer.getVirtualItems(); + const totalSize = virtualizer.getTotalSize(); + + if (!filteredItems.length) { + return null; + } + + return ( +
+
+ {virtualItems.map((virtualItem) => { + const item = filteredItems[virtualItem.index]; + + return ( +
+ + + {item.label} + + +{item.callingCode} + + +
+ ); + })} +
+
+ ); +} diff --git a/src/components/form/phone-input/countries.ts b/src/components/form/phone-input/countries.ts new file mode 100644 index 0000000..2ab8ee9 --- /dev/null +++ b/src/components/form/phone-input/countries.ts @@ -0,0 +1,59 @@ +import { + getCountries, + getCountryCallingCode, + parsePhoneNumberFromString, + type CountryCode, +} from 'libphonenumber-js'; + +import type { CountryOption } from './types'; + +const regionNames = + typeof Intl !== 'undefined' + ? new Intl.DisplayNames(['en'], { type: 'region' }) + : null; + +function getCountryName(country: CountryCode) { + return regionNames?.of(country) ?? country; +} + +export const COUNTRY_OPTIONS: CountryOption[] = getCountries() + .map((code) => ({ + value: code, + label: getCountryName(code), + callingCode: getCountryCallingCode(code), + })) + .sort((a, b) => a.label.localeCompare(b.label)); + +export function filterCountryOption(item: CountryOption, query: string) { + const term = query.trim().toLowerCase(); + if (!term) return true; + + return ( + item.label.toLowerCase().includes(term) || + item.value.toLowerCase().includes(term) || + item.callingCode.includes(term.replace(/^\+/, '')) + ); +} + +export function getNationalValue( + value: string | undefined, + country: CountryCode, +) { + if (!value) return ''; + + const parsed = parsePhoneNumberFromString(value); + if (parsed?.country === country) { + return parsed.nationalNumber; + } + + const callingCode = getCountryCallingCode(country); + return value.replace(new RegExp(`^\\+${callingCode}`), '').replace(/\D/g, ''); +} + +export function buildPhoneValue( + country: CountryCode, + nationalValue: string, +) { + const digits = nationalValue.replace(/\D/g, ''); + return digits ? `+${getCountryCallingCode(country)}${digits}` : ''; +} diff --git a/src/components/form/phone-input/types.ts b/src/components/form/phone-input/types.ts new file mode 100644 index 0000000..75a3873 --- /dev/null +++ b/src/components/form/phone-input/types.ts @@ -0,0 +1,7 @@ +import type { CountryCode } from 'libphonenumber-js'; + +export type CountryOption = { + value: CountryCode; + label: string; + callingCode: string; +};