feat(forms): add PhoneInput and unify module form validation UX

This commit is contained in:
2026-06-23 16:28:28 +05:30
parent 27cf5c9d3f
commit 066374bebc
30 changed files with 1415 additions and 229 deletions

7
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

740
public/flags/flags.css Normal file
View File

@@ -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%;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -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<ClientFormValues>;
control: Control<ClientFormValues>;
errors: FieldErrors<ClientFormValues>;
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<HTMLDivElement | null>(null);
const firstNameErrorMessage = isSubmitted
? errors.first_name?.message
: undefined;
@@ -201,11 +209,19 @@ export function ClientSheet({
required
error={contactPhoneErrorMessage}
>
<Input
id="contact-phone-number"
<Controller
control={control}
name="contact_phone_number"
render={({ field }) => (
<PhoneInput
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Enter contact phone"
aria-invalid={!!contactPhoneErrorMessage}
{...register('contact_phone_number')}
portalContainer={dropdownPortalRef}
/>
)}
/>
</FormField>
@@ -244,6 +260,8 @@ export function ClientSheet({
</Button>
</DialogFooter>
</form>
<div ref={dropdownPortalRef} />
</DialogContent>
</Dialog>
);

View File

@@ -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,

View File

@@ -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}

View File

@@ -74,7 +74,7 @@ export function PackageDialog({
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<form noValidate onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
<section className="space-y-4">
<div className="space-y-1">

View File

@@ -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);

View File

@@ -77,7 +77,7 @@ export function PlanSheet({
</SheetDescription>
</SheetHeader>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<form noValidate onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField

View File

@@ -8,7 +8,6 @@ import {
import type { PermissionTreeItem, Plan } from '@/types';
import { useCallback } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { useSavePlanMutation } from './usePlanMutations';
@@ -104,8 +103,7 @@ export function usePlanForm({
price.trim().length > 0 &&
permissionIds.length > 0;
const onSubmit = handleSubmit(
(values) => {
const onSubmit = handleSubmit((values) => {
saveMutation.mutate({
...values,
name: values.name.trim(),
@@ -118,17 +116,7 @@ export function usePlanForm({
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 openCreate = useCallback(() => {
reset({

View File

@@ -66,7 +66,7 @@ export function ProjectDialog({
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<form noValidate onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="max-h-[58vh] space-y-6 overflow-y-auto px-6 py-4">
<section className="space-y-4">
<div className="space-y-1">

View File

@@ -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);

View File

@@ -73,7 +73,7 @@ export function RoleSheet({
</SheetDescription>
</SheetHeader>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<form noValidate onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField

View File

@@ -89,22 +89,7 @@ export function useRoleForm({
},
});
const onSubmit = handleSubmit(
(values) => 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({

View File

@@ -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<TenantFormValues>;
control: Control<TenantFormValues>;
errors: FieldErrors<TenantFormValues>;
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<HTMLDivElement | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl">
@@ -85,7 +109,11 @@ export function TenantSheet({
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div className="max-h-[50vh] space-y-5 overflow-y-auto px-6 py-4">
<div className="space-y-1">
<p>Basic Information</p>
@@ -129,34 +157,23 @@ export function TenantSheet({
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="tenant-client"
label="Client"
required
error={fieldError('client_id')}
>
<Select
<SelectPopover
options={clientOptions}
value={clientId}
onValueChange={onClientChange}
disabled={isLookupsLoading}
>
<SelectTrigger>
<SelectValue
portalContainer={dropdownPortalRef}
placeholder={
isLookupsLoading
? 'Loading clients...'
: 'Select client'
isLookupsLoading ? 'Loading clients...' : 'Select client'
}
searchPlaceholder="Search clients"
emptyMessage="No clients found."
/>
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={String(client.id)}>
{[client.first_name, client.last_name]
.filter(Boolean)
.join(' ')}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="hidden"
{...register('client_id')}
@@ -166,30 +183,23 @@ export function TenantSheet({
</FormField>
<FormField
id="tenant-plan"
label="Subscription Plan"
required
error={fieldError('plan_id')}
>
<Select
<SelectPopover
options={planOptions}
value={planId}
onValueChange={onPlanChange}
disabled={isLookupsLoading}
>
<SelectTrigger>
<SelectValue
portalContainer={dropdownPortalRef}
placeholder={
isLookupsLoading ? 'Loading plans...' : 'Select plan'
}
searchPlaceholder="Search plans"
emptyMessage="No plans found."
/>
</SelectTrigger>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.id} value={String(plan.id)}>
{plan.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="hidden"
{...register('plan_id')}
@@ -242,6 +252,7 @@ export function TenantSheet({
id="admin-first-name"
placeholder="Enter first name"
aria-invalid={!!fieldError('admin_first_name')}
readOnly={adminFieldLocks.admin_first_name}
{...register('admin_first_name')}
/>
</FormField>
@@ -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')}
/>
</FormField>
@@ -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')}
/>
</FormField>
@@ -282,11 +295,20 @@ export function TenantSheet({
label="Phone Number"
error={fieldError('admin_phone_number')}
>
<Input
id="admin-phone"
placeholder="+91-9000011111"
<Controller
control={control}
name="admin_phone_number"
render={({ field }) => (
<PhoneInput
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Enter phone number"
aria-invalid={!!fieldError('admin_phone_number')}
{...register('admin_phone_number')}
disabled={adminFieldLocks.admin_phone_number}
portalContainer={dropdownPortalRef}
/>
)}
/>
</FormField>
</div>
@@ -322,6 +344,8 @@ export function TenantSheet({
</Button>
</DialogFooter>
</form>
<div ref={dropdownPortalRef} />
</DialogContent>
</Dialog>
);

View File

@@ -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<TenantAdminFieldLocks>(
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,
};

View File

@@ -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}
/>
</>
);

View File

@@ -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<UserFormValues>;
control: Control<UserFormValues>;
errors: FieldErrors<UserFormValues>;
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({
</DialogHeader>
<form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
@@ -126,16 +134,29 @@ export function UserSheet({
label="Phone Number"
error={phoneErrorMessage}
>
<Input
id="phone-number"
placeholder="+919876543210"
<Controller
control={control}
name="phone_number"
render={({ field }) => (
<PhoneInput
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Enter phone number"
aria-invalid={!!phoneErrorMessage}
{...register('phone_number')}
portalContainer={roleComboboxPortalRef}
/>
)}
/>
</FormField>
</div>
<FormField label="Role" required error={roleErrorMessage}>
<FormField
id="role"
label="Role"
required
error={roleErrorMessage}
>
<RoleSelect
value={roleId}
onValueChange={onRoleChange}

View File

@@ -1,28 +1,28 @@
'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 { AdministrationUser } from '@/types';
import { useSaveUserMutation } from './useUserMutations';
const optionalPhoneSchema = z
.string()
.trim()
.refine((value) => 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<typeof userFormSchema>;
@@ -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,

View File

@@ -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}

View File

@@ -32,9 +32,10 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${poppins.variable} ${geistMono.variable} antialiased`}
>
<head>
<link rel="stylesheet" href="/flags/flags.css" />
</head>
<body className={`${poppins.variable} ${geistMono.variable} antialiased`}>
<ThemeProvider
attribute="class"
defaultTheme="system"

View File

@@ -37,7 +37,7 @@ export function FormField({
</div>
{children}
{error ? (
<p className={cn('-mt-1 text-sm text-destructive', errorClassName)}>
<p className={cn('-mt-1 text-sm text-destructive normal-case', errorClassName)}>
{error}
</p>
) : null}

View File

@@ -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';

View File

@@ -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<HTMLDivElement, Element>;
interface CountryComboboxProps {
value: CountryCode;
onValueChange: (country: CountryCode) => void;
disabled?: boolean;
portalContainer?: RefObject<HTMLDivElement | null>;
}
export function CountryCombobox({
value,
onValueChange,
disabled = false,
portalContainer,
}: CountryComboboxProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const virtualizerRef = useRef<VirtualizerHandle | null>(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 (
<Combobox
virtualized
items={COUNTRY_OPTIONS}
value={selectedOption}
onValueChange={handleValueChange}
open={open}
onOpenChange={handleOpenChange}
inputValue={search}
onInputValueChange={setSearch}
filter={filterCountryOption}
disabled={disabled}
itemToStringLabel={(item) => item.label}
isItemEqualToValue={(item, currentValue) =>
item.value === currentValue.value
}
onItemHighlighted={handleItemHighlighted}
>
<ComboboxTrigger
render={
<Button
type="button"
variant="ghost"
disabled={disabled}
className="h-9 shrink-0 gap-1 rounded-none border-r px-2.5 shadow-none hover:bg-transparent"
>
<CountryFlag country={value} />
</Button>
}
/>
<ComboboxContent
container={portalContainer}
align="start"
side="bottom"
sideOffset={4}
className="w-72 min-w-72 max-w-72"
>
<ComboboxInput
showTrigger={false}
placeholder="Search country"
disabled={disabled}
/>
<ComboboxEmpty>No countries found.</ComboboxEmpty>
<ComboboxList className="p-0">
<VirtualCountryList open={open} virtualizerRef={virtualizerRef} />
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}

View File

@@ -0,0 +1,15 @@
import type { CountryCode } from 'libphonenumber-js';
import { cn } from '@/lib/utils';
export function CountryFlag({ country }: { country: CountryCode }) {
return (
<div
aria-hidden
className={cn(
'flag inline-block h-3.75 w-5.5 shrink-0 rounded-[2px] bg-size-[100%_auto]',
`flag-${country.toLowerCase()}`,
)}
/>
);
}

View File

@@ -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<HTMLDivElement | null>;
}
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<CountryCode>(
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 (
<div
className={cn(
'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30',
ariaInvalid &&
'border-destructive focus-within:border-destructive focus-within:ring-destructive/20',
disabled && 'cursor-not-allowed opacity-50',
)}
>
<CountryCombobox
value={country}
onValueChange={handleCountryChange}
disabled={disabled}
portalContainer={portalContainer}
/>
<Input
type="tel"
value={nationalValue}
onChange={(event) => 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"
/>
</div>
);
}

View File

@@ -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<HTMLDivElement, Element>;
interface VirtualCountryListProps {
open: boolean;
virtualizerRef: RefObject<VirtualizerHandle | null>;
estimateSize?: number;
}
export function VirtualCountryList({
open,
virtualizerRef,
estimateSize = 36,
}: VirtualCountryListProps) {
const filteredItems = Combobox.useFilteredItems<CountryOption>();
const scrollElementRef = useRef<HTMLDivElement | null>(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 (
<div
ref={handleScrollElementRef}
className={cn(
'max-h-[min(21.75rem,calc(var(--available-height)-2.25rem))] overflow-y-auto overscroll-contain p-1',
)}
style={{ height: Math.min(totalSize + 8, 348) }}
>
<div className="relative w-full" style={{ height: totalSize }}>
{virtualItems.map((virtualItem) => {
const item = filteredItems[virtualItem.index];
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ComboboxItem
value={item}
index={virtualItem.index}
aria-setsize={filteredItems.length}
aria-posinset={virtualItem.index + 1}
className="gap-2"
>
<CountryFlag country={item.value} />
<span className="min-w-0 flex-1 truncate">{item.label}</span>
<span className="mr-4 shrink-0 tabular-nums text-muted-foreground">
+{item.callingCode}
</span>
</ComboboxItem>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -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}` : '';
}

View File

@@ -0,0 +1,7 @@
import type { CountryCode } from 'libphonenumber-js';
export type CountryOption = {
value: CountryCode;
label: string;
callingCode: string;
};