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:
@@ -24,3 +24,10 @@ VITE_APP_URL=https://api.yourdomain.com
|
||||
# Client ID is the public key — safe to expose in the browser bundle.
|
||||
# developer.paypal.com → My Apps & Credentials → Live tab → Client ID
|
||||
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
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-phone-number-input": "^3.4.17",
|
||||
"react-photo-view": "^1.2.7",
|
||||
"react-resizable-panels": "^4.11.0",
|
||||
"react-router-dom": "^7.14.2",
|
||||
|
||||
Generated
+66
@@ -95,6 +95,9 @@ importers:
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
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:
|
||||
specifier: ^1.2.7
|
||||
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:
|
||||
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:
|
||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1908,6 +1914,9 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
country-flag-icons@1.6.20:
|
||||
resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==}
|
||||
|
||||
crc-32@1.2.2:
|
||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
@@ -2500,6 +2509,17 @@ packages:
|
||||
inline-style-parser@0.2.7:
|
||||
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:
|
||||
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
|
||||
peerDependencies:
|
||||
@@ -2675,6 +2695,9 @@ packages:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
libphonenumber-js@1.13.8:
|
||||
resolution: {integrity: sha512-80xal1m93rADejw2pMp2MSzFhHCPLEspjHxnH2UtqI+DgAmElsbmLMiqk9niwH9NWAfjsRtaJI+qBrOEmRx9nQ==}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -3169,6 +3192,9 @@ packages:
|
||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
property-information@7.2.0:
|
||||
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
|
||||
|
||||
@@ -3253,6 +3279,9 @@ packages:
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-is@19.2.5:
|
||||
resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==}
|
||||
|
||||
@@ -3262,6 +3291,12 @@ packages:
|
||||
'@types/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:
|
||||
resolution: {integrity: sha512-MfOWVPxuibncRLaycZUNxqYU8D9IA+rbGDDaq6GM8RIoGJal592hEJoRAyRSI7ZxyyJNJTLMUWWL3UIXHJJOpw==}
|
||||
peerDependencies:
|
||||
@@ -5507,6 +5542,8 @@ snapshots:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
classnames@2.5.1: {}
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
dependencies:
|
||||
restore-cursor: 5.1.0
|
||||
@@ -5588,6 +5625,8 @@ snapshots:
|
||||
js-yaml: 4.1.1
|
||||
parse-json: 5.2.0
|
||||
|
||||
country-flag-icons@1.6.20: {}
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
@@ -6180,6 +6219,13 @@ snapshots:
|
||||
|
||||
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):
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
@@ -6299,6 +6345,8 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
libphonenumber-js@1.13.8: {}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
optional: true
|
||||
|
||||
@@ -6964,6 +7012,12 @@ snapshots:
|
||||
kleur: 3.0.3
|
||||
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: {}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
@@ -7093,6 +7147,8 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-is@19.2.5: {}
|
||||
|
||||
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5):
|
||||
@@ -7113,6 +7169,16 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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):
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
||||
@@ -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
|
||||
<Controller
|
||||
name="phone"
|
||||
control={controlPersonal}
|
||||
render={({ field }) => (
|
||||
<PhoneInput
|
||||
{...field}
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="+63 912 345 6789"
|
||||
international
|
||||
defaultCountry={detectedCountry}
|
||||
className="text-sm"
|
||||
{...regPersonal('phone')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errPersonal.phone && (
|
||||
<p className="text-xs text-destructive">{errPersonal.phone.message}</p>
|
||||
|
||||
Reference in New Issue
Block a user