add: revised phone nubmer input UI

This commit is contained in:
rgrgogu
2026-07-16 03:15:03 +08:00
parent ec8ea14ed2
commit dc992c7f77
6 changed files with 309 additions and 14 deletions
+177
View File
@@ -0,0 +1,177 @@
import * as React from "react"
import { CheckIcon, ChevronsUpDown } from "lucide-react"
import * as RPNInput from "react-phone-number-input"
import flags from "react-phone-number-input/flags"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils"
const PhoneInput = React.forwardRef(
({ className, onChange, value, ...props }, ref) => {
return (
<RPNInput.default
ref={ref}
className={cn("flex", className)}
flagComponent={FlagComponent}
countrySelectComponent={CountrySelect}
inputComponent={InputComponent}
smartCaret={false}
value={value || undefined}
/**
* react-phone-number-input fires onChange as undefined when the
* field is emptied — coerce to "" so react-hook-form's required
* check still fires correctly.
*/
onChange={(value) => onChange?.(value || "")}
{...props}
/>
)
},
)
PhoneInput.displayName = "PhoneInput"
const InputComponent = React.forwardRef(({ className, ...props }, ref) => (
<Input
className={cn("rounded-e-lg rounded-s-none", className)}
{...props}
ref={ref}
/>
))
InputComponent.displayName = "InputComponent"
const CountrySelect = ({
disabled,
value: selectedCountry,
options: countryList,
onChange,
}) => {
const scrollAreaRef = React.useRef(null)
const [searchValue, setSearchValue] = React.useState("")
const [isOpen, setIsOpen] = React.useState(false)
return (
<Popover
open={isOpen}
modal
onOpenChange={(open) => {
setIsOpen(open)
open && setSearchValue("")
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="flex gap-1 rounded-e-none rounded-s-lg border-r-0 px-3 focus:z-10"
disabled={disabled}
>
<FlagComponent
country={selectedCountry}
countryName={selectedCountry}
/>
<ChevronsUpDown
className={cn(
"-mr-2 size-4 opacity-50",
disabled ? "hidden" : "opacity-100",
)}
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0">
<Command>
<CommandInput
value={searchValue}
onValueChange={(value) => {
setSearchValue(value)
setTimeout(() => {
if (scrollAreaRef.current) {
const viewportElement = scrollAreaRef.current.querySelector(
"[data-radix-scroll-area-viewport]",
)
if (viewportElement) {
viewportElement.scrollTop = 0
}
}
}, 0)
}}
placeholder="Search country..."
/>
<CommandList>
<ScrollArea ref={scrollAreaRef} className="h-72">
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{countryList.map(({ value, label }) =>
value ? (
<CountrySelectOption
key={value}
country={value}
countryName={label}
selectedCountry={selectedCountry}
onChange={onChange}
onSelectComplete={() => setIsOpen(false)}
/>
) : null,
)}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
const CountrySelectOption = ({
country,
countryName,
selectedCountry,
onChange,
onSelectComplete,
}) => {
const handleSelect = () => {
onChange(country)
onSelectComplete()
}
return (
<CommandItem className="flex items-center justify-between gap-2" onSelect={handleSelect}>
<div className="flex items-center flex-1 gap-2">
<FlagComponent country={country} countryName={countryName} />
<span className="text-sm truncate">{countryName}</span>
</div>
<div className="whitespace-nowrap flex min-w-0">
<span className="text-sm text-foreground/50">{`+${RPNInput.getCountryCallingCode(country)}`}</span>
<CheckIcon
className={`size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`}
/>
</div>
</CommandItem>
)
}
const FlagComponent = ({ country, countryName }) => {
const Flag = flags[country]
return (
<span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20 [&_svg:not([class*='size-'])]:size-full">
{Flag && <Flag title={countryName} />}
</span>
)
}
export { PhoneInput }
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react'
const COUNTRY_API_URL = import.meta.env.VITE_COUNTRY_API_URL
const FALLBACK_COUNTRY = 'PH'
/**
* Detects the caller's ISO 3166-1 alpha-2 country code via VITE_COUNTRY_API_URL
* (defaults to the free hosted https://api.country.is — self-hostable at
* https://github.com/lineofflight/country). Falls back to FALLBACK_COUNTRY
* if the endpoint is unset, unreachable, or returns no country.
*/
export function useDetectedCountry() {
const [country, setCountry] = useState(FALLBACK_COUNTRY)
useEffect(() => {
if (!COUNTRY_API_URL) return
let cancelled = false
fetch(COUNTRY_API_URL)
.then((res) => (res.ok ? res.json() : Promise.reject(res.status)))
.then((data) => {
if (!cancelled && data?.country) setCountry(data.country)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
return country
}
+25 -14
View File
@@ -22,11 +22,14 @@ import { useAuth } from '@/contexts/AuthContext'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { format, parseISO, isValid as isValidDate } from 'date-fns'
import { isValidPhoneNumber, parsePhoneNumber } from 'react-phone-number-input'
import { cn } from '@/lib/utils'
import { useDetectedCountry } from '@/hooks/useDetectedCountry'
import { OtpVerifyForm } from './OtpVerifyForm'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { PhoneInput } from '@/components/ui/phone-input'
import { Badge } from '@/components/ui/badge'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@@ -61,7 +64,10 @@ const personalSchema = z.object({
return yearsAgo >= 3 && yearsAgo <= 120
}, { message: 'Must be at least 3 years old' }),
occupation: z.string().min(1, 'Occupation is required').max(100),
phone: z.string().min(1, 'Phone number is required').regex(/^\+?[0-9\s\-()]{7,20}$/, 'Invalid phone number'),
phone: z
.string()
.min(1, 'Phone number is required')
.refine((v) => isValidPhoneNumber(v), { message: 'Invalid phone number' }),
})
const credentialsSchema = z
@@ -151,6 +157,8 @@ export function RegisterForm({ className, ...props }) {
const [errorMessage, setErrorMessage] = useState('')
const [birthdayOpen, setBirthdayOpen] = useState(false)
const detectedCountry = useDetectedCountry()
// Accumulated data across steps
const [personalData, setPersonalData] = useState({})
@@ -223,14 +231,11 @@ export function RegisterForm({ className, ...props }) {
occupation: personalData.occupation,
phone_number: personalData.phone
? (() => {
const digits = personalData.phone.replace(/\D/g, '')
const number = digits.startsWith('63')
? digits.slice(2) // strip country code if user typed +63...
: digits.replace(/^0/, '') // strip leading 0 if user typed 09...
const parsed = parsePhoneNumber(personalData.phone)
return [{
number,
country_code: '63',
full_number: `63${number}`,
number: parsed.nationalNumber,
country_code: parsed.countryCallingCode,
full_number: `${parsed.countryCallingCode}${parsed.nationalNumber}`,
phone_type: 'mobile',
}]
})()
@@ -449,12 +454,18 @@ export function RegisterForm({ className, ...props }) {
<label htmlFor="phone" className="text-sm font-medium">
Phone number <span className="text-destructive">*</span>
</label>
<Input
id="phone"
type="tel"
placeholder="+63 912 345 6789"
className="text-sm"
{...regPersonal('phone')}
<Controller
name="phone"
control={controlPersonal}
render={({ field }) => (
<PhoneInput
{...field}
id="phone"
international
defaultCountry={detectedCountry}
className="text-sm"
/>
)}
/>
{errPersonal.phone && (
<p className="text-xs text-destructive">{errPersonal.phone.message}</p>