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.
|
# 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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+66
@@ -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
|
||||||
|
|||||||
@@ -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 { 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
|
||||||
id="phone"
|
name="phone"
|
||||||
type="tel"
|
control={controlPersonal}
|
||||||
placeholder="+63 912 345 6789"
|
render={({ field }) => (
|
||||||
className="text-sm"
|
<PhoneInput
|
||||||
{...regPersonal('phone')}
|
{...field}
|
||||||
|
id="phone"
|
||||||
|
international
|
||||||
|
defaultCountry={detectedCountry}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{errPersonal.phone && (
|
{errPersonal.phone && (
|
||||||
<p className="text-xs text-destructive">{errPersonal.phone.message}</p>
|
<p className="text-xs text-destructive">{errPersonal.phone.message}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user