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
+7
View File
@@ -24,3 +24,10 @@ VITE_APP_URL=https://api.yourdomain.com
# Client ID is the public key — safe to expose in the browser bundle. # Client ID is the public key — safe to expose in the browser bundle.
# developer.paypal.com → My Apps & Credentials → Live tab → Client ID # developer.paypal.com → My Apps & Credentials → Live tab → Client ID
VITE_PAYPAL_CLIENT_ID=CHANGE_ME VITE_PAYPAL_CLIENT_ID=CHANGE_ME
# ── Country detection (phone input) ────────────────────────────────────────────
# IP → country lookup used to pre-select the phone input's dialing code.
# Defaults to the free hosted instance; self-host for production via
# https://github.com/lineofflight/country (Docker, needs a free MaxMind
# license key) and point this at your own instance instead.
VITE_COUNTRY_API_URL=https://api.country.is
+1
View File
@@ -39,6 +39,7 @@
"react-hook-form": "^7.75.0", "react-hook-form": "^7.75.0",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-phone-number-input": "^3.4.17",
"react-photo-view": "^1.2.7", "react-photo-view": "^1.2.7",
"react-resizable-panels": "^4.11.0", "react-resizable-panels": "^4.11.0",
"react-router-dom": "^7.14.2", "react-router-dom": "^7.14.2",
+66
View File
@@ -95,6 +95,9 @@ importers:
react-markdown: react-markdown:
specifier: ^10.1.0 specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.5) version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
react-phone-number-input:
specifier: ^3.4.17
version: 3.4.17(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react-photo-view: react-photo-view:
specifier: ^1.2.7 specifier: ^1.2.7
version: 1.2.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) version: 1.2.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -1812,6 +1815,9 @@ packages:
class-variance-authority@0.7.1: class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
cli-cursor@5.0.0: cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1908,6 +1914,9 @@ packages:
typescript: typescript:
optional: true optional: true
country-flag-icons@1.6.20:
resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==}
crc-32@1.2.2: crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@@ -2500,6 +2509,17 @@ packages:
inline-style-parser@0.2.7: inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
input-format@0.3.14:
resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==}
peerDependencies:
react: '>=18.1.0'
react-dom: '>=18.1.0'
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
input-otp@1.4.2: input-otp@1.4.2:
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
peerDependencies: peerDependencies:
@@ -2675,6 +2695,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
libphonenumber-js@1.13.8:
resolution: {integrity: sha512-80xal1m93rADejw2pMp2MSzFhHCPLEspjHxnH2UtqI+DgAmElsbmLMiqk9niwH9NWAfjsRtaJI+qBrOEmRx9nQ==}
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -3169,6 +3192,9 @@ packages:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
property-information@7.2.0: property-information@7.2.0:
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
@@ -3253,6 +3279,9 @@ packages:
peerDependencies: peerDependencies:
react: '*' react: '*'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@19.2.5: react-is@19.2.5:
resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==} resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==}
@@ -3262,6 +3291,12 @@ packages:
'@types/react': '>=18' '@types/react': '>=18'
react: '>=18' react: '>=18'
react-phone-number-input@3.4.17:
resolution: {integrity: sha512-1wcjhBAWHgEBAGLi5/XbeZI7Q3aEHNb2z/dHY6R2Gz70TQvu0ZoOT28NTdwtZf4lyRKXWufnTzVhLPBUD8LfmQ==}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-photo-view@1.2.7: react-photo-view@1.2.7:
resolution: {integrity: sha512-MfOWVPxuibncRLaycZUNxqYU8D9IA+rbGDDaq6GM8RIoGJal592hEJoRAyRSI7ZxyyJNJTLMUWWL3UIXHJJOpw==} resolution: {integrity: sha512-MfOWVPxuibncRLaycZUNxqYU8D9IA+rbGDDaq6GM8RIoGJal592hEJoRAyRSI7ZxyyJNJTLMUWWL3UIXHJJOpw==}
peerDependencies: peerDependencies:
@@ -5507,6 +5542,8 @@ snapshots:
dependencies: dependencies:
clsx: 2.1.1 clsx: 2.1.1
classnames@2.5.1: {}
cli-cursor@5.0.0: cli-cursor@5.0.0:
dependencies: dependencies:
restore-cursor: 5.1.0 restore-cursor: 5.1.0
@@ -5588,6 +5625,8 @@ snapshots:
js-yaml: 4.1.1 js-yaml: 4.1.1
parse-json: 5.2.0 parse-json: 5.2.0
country-flag-icons@1.6.20: {}
crc-32@1.2.2: {} crc-32@1.2.2: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
@@ -6180,6 +6219,13 @@ snapshots:
inline-style-parser@0.2.7: {} inline-style-parser@0.2.7: {}
input-format@0.3.14(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
prop-types: 15.8.1
optionalDependencies:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
input-otp@1.4.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5): input-otp@1.4.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies: dependencies:
react: 19.2.5 react: 19.2.5
@@ -6299,6 +6345,8 @@ snapshots:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-check: 0.4.0 type-check: 0.4.0
libphonenumber-js@1.13.8: {}
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
optional: true optional: true
@@ -6964,6 +7012,12 @@ snapshots:
kleur: 3.0.3 kleur: 3.0.3
sisteransi: 1.0.5 sisteransi: 1.0.5
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
property-information@7.2.0: {} property-information@7.2.0: {}
proxy-addr@2.0.7: proxy-addr@2.0.7:
@@ -7093,6 +7147,8 @@ snapshots:
dependencies: dependencies:
react: 19.2.5 react: 19.2.5
react-is@16.13.1: {}
react-is@19.2.5: {} react-is@19.2.5: {}
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5): react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5):
@@ -7113,6 +7169,16 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
react-phone-number-input@3.4.17(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
classnames: 2.5.1
country-flag-icons: 1.6.20
input-format: 0.3.14(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
libphonenumber-js: 1.13.8
prop-types: 15.8.1
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-photo-view@1.2.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): react-photo-view@1.2.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies: dependencies:
react: 19.2.5 react: 19.2.5
+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
}
+23 -12
View File
@@ -22,11 +22,14 @@ import { useAuth } from '@/contexts/AuthContext'
import { z } from 'zod' import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { format, parseISO, isValid as isValidDate } from 'date-fns' import { format, parseISO, isValid as isValidDate } from 'date-fns'
import { isValidPhoneNumber, parsePhoneNumber } from 'react-phone-number-input'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useDetectedCountry } from '@/hooks/useDetectedCountry'
import { OtpVerifyForm } from './OtpVerifyForm' import { OtpVerifyForm } from './OtpVerifyForm'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { PhoneInput } from '@/components/ui/phone-input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Calendar } from '@/components/ui/calendar' import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@@ -61,7 +64,10 @@ const personalSchema = z.object({
return yearsAgo >= 3 && yearsAgo <= 120 return yearsAgo >= 3 && yearsAgo <= 120
}, { message: 'Must be at least 3 years old' }), }, { message: 'Must be at least 3 years old' }),
occupation: z.string().min(1, 'Occupation is required').max(100), 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 const credentialsSchema = z
@@ -151,6 +157,8 @@ export function RegisterForm({ className, ...props }) {
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')
const [birthdayOpen, setBirthdayOpen] = useState(false) const [birthdayOpen, setBirthdayOpen] = useState(false)
const detectedCountry = useDetectedCountry()
// Accumulated data across steps // Accumulated data across steps
const [personalData, setPersonalData] = useState({}) const [personalData, setPersonalData] = useState({})
@@ -223,14 +231,11 @@ export function RegisterForm({ className, ...props }) {
occupation: personalData.occupation, occupation: personalData.occupation,
phone_number: personalData.phone phone_number: personalData.phone
? (() => { ? (() => {
const digits = personalData.phone.replace(/\D/g, '') const parsed = parsePhoneNumber(personalData.phone)
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...
return [{ return [{
number, number: parsed.nationalNumber,
country_code: '63', country_code: parsed.countryCallingCode,
full_number: `63${number}`, full_number: `${parsed.countryCallingCode}${parsed.nationalNumber}`,
phone_type: 'mobile', phone_type: 'mobile',
}] }]
})() })()
@@ -449,12 +454,18 @@ export function RegisterForm({ className, ...props }) {
<label htmlFor="phone" className="text-sm font-medium"> <label htmlFor="phone" className="text-sm font-medium">
Phone number <span className="text-destructive">*</span> Phone number <span className="text-destructive">*</span>
</label> </label>
<Input <Controller
name="phone"
control={controlPersonal}
render={({ field }) => (
<PhoneInput
{...field}
id="phone" id="phone"
type="tel" international
placeholder="+63 912 345 6789" defaultCountry={detectedCountry}
className="text-sm" className="text-sm"
{...regPersonal('phone')} />
)}
/> />
{errPersonal.phone && ( {errPersonal.phone && (
<p className="text-xs text-destructive">{errPersonal.phone.message}</p> <p className="text-xs text-destructive">{errPersonal.phone.message}</p>