From dc992c7f775641d605246b254d89ddaf3f31a21e Mon Sep 17 00:00:00 2001 From: rgrgogu Date: Thu, 16 Jul 2026 03:15:03 +0800 Subject: [PATCH] add: revised phone nubmer input UI --- .env.example | 7 + package.json | 1 + pnpm-lock.yaml | 66 +++++++ src/components/ui/phone-input.jsx | 177 +++++++++++++++++++ src/hooks/useDetectedCountry.js | 33 ++++ src/modules/auth/components/RegisterForm.jsx | 39 ++-- 6 files changed, 309 insertions(+), 14 deletions(-) create mode 100644 src/components/ui/phone-input.jsx create mode 100644 src/hooks/useDetectedCountry.js diff --git a/.env.example b/.env.example index da12a44..8ffa7e4 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/package.json b/package.json index 8f489c3..aa8060e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78c9da8..ba6ef0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/components/ui/phone-input.jsx b/src/components/ui/phone-input.jsx new file mode 100644 index 0000000..8ce2e03 --- /dev/null +++ b/src/components/ui/phone-input.jsx @@ -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 ( + onChange?.(value || "")} + {...props} + /> + ) + }, +) +PhoneInput.displayName = "PhoneInput" + +const InputComponent = React.forwardRef(({ className, ...props }, 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 ( + { + setIsOpen(open) + open && setSearchValue("") + }} + > + + + + + + { + 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..." + /> + + + No country found. + + {countryList.map(({ value, label }) => + value ? ( + setIsOpen(false)} + /> + ) : null, + )} + + + + + + + ) +} + +const CountrySelectOption = ({ + country, + countryName, + selectedCountry, + onChange, + onSelectComplete, +}) => { + const handleSelect = () => { + onChange(country) + onSelectComplete() + } + + return ( + +
+ + {countryName} +
+
+ {`+${RPNInput.getCountryCallingCode(country)}`} + +
+
+ ) +} + +const FlagComponent = ({ country, countryName }) => { + const Flag = flags[country] + + return ( + + {Flag && } + + ) +} + +export { PhoneInput } diff --git a/src/hooks/useDetectedCountry.js b/src/hooks/useDetectedCountry.js new file mode 100644 index 0000000..9ab15ef --- /dev/null +++ b/src/hooks/useDetectedCountry.js @@ -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 +} diff --git a/src/modules/auth/components/RegisterForm.jsx b/src/modules/auth/components/RegisterForm.jsx index 07e88b9..f0806e1 100644 --- a/src/modules/auth/components/RegisterForm.jsx +++ b/src/modules/auth/components/RegisterForm.jsx @@ -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 }) { - ( + + )} /> {errPersonal.phone && (

{errPersonal.phone.message}