mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
add: revised phone nubmer input UI
This commit is contained in:
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user