178 lines
6.4 KiB
TypeScript
178 lines
6.4 KiB
TypeScript
'use client';
|
||
|
||
import { ChevronDown, X } from 'lucide-react';
|
||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
export interface CountryOption {
|
||
id: string;
|
||
code: string;
|
||
label: string;
|
||
maxDigits: number;
|
||
/** Path to the country flag asset inside the public directory. */
|
||
flagSrc: string;
|
||
}
|
||
|
||
export const FLAG_WIDTH = 24;
|
||
export const FLAG_HEIGHT = 18;
|
||
|
||
export const phoneCountries: CountryOption[] = [
|
||
{ id: 'RU', code: '+7', label: 'Россия', maxDigits: 10, flagSrc: '/flags/ru.svg' },
|
||
{ id: 'BY', code: '+375', label: 'Беларусь', maxDigits: 9, flagSrc: '/flags/by.svg' },
|
||
{ id: 'KZ', code: '+7', label: 'Казахстан', maxDigits: 10, flagSrc: '/flags/kz.svg' }
|
||
];
|
||
|
||
function FlagIcon({ src, className }: { src: string; className?: string }) {
|
||
return (
|
||
<span
|
||
aria-hidden="true"
|
||
className={cn('inline-block shrink-0 overflow-hidden rounded-[3px] bg-center bg-no-repeat shadow-[inset_0_0_0_1px_rgba(0,0,0,0.08)]', className)}
|
||
style={{
|
||
width: `${FLAG_WIDTH}px`,
|
||
height: `${FLAG_HEIGHT}px`,
|
||
backgroundImage: `url('${src}')`,
|
||
backgroundSize: 'cover'
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function formatPhoneNumber(digits: string, country: CountryOption) {
|
||
const clean = digits.replace(/\D/g, '').slice(0, country.maxDigits);
|
||
if (country.code === '+375') {
|
||
const part1 = clean.slice(0, 2);
|
||
const part2 = clean.slice(2, 5);
|
||
const part3 = clean.slice(5, 7);
|
||
const part4 = clean.slice(7, 9);
|
||
if (clean.length <= 2) return part1 ? `(${part1}` : '';
|
||
if (clean.length <= 5) return `(${part1}) ${part2}`;
|
||
if (clean.length <= 7) return `(${part1}) ${part2}-${part3}`;
|
||
return `(${part1}) ${part2}-${part3}-${part4}`;
|
||
}
|
||
|
||
const part1 = clean.slice(0, 3);
|
||
const part2 = clean.slice(3, 6);
|
||
const part3 = clean.slice(6, 8);
|
||
const part4 = clean.slice(8, 10);
|
||
if (clean.length <= 3) return part1 ? `(${part1}` : '';
|
||
if (clean.length <= 6) return `(${part1}) ${part2}`;
|
||
if (clean.length <= 8) return `(${part1}) ${part2}-${part3}`;
|
||
return `(${part1}) ${part2}-${part3}-${part4}`;
|
||
}
|
||
|
||
export function toE164(country: CountryOption, digits: string) {
|
||
return `${country.code}${digits.replace(/\D/g, '').slice(0, country.maxDigits)}`;
|
||
}
|
||
|
||
export function parseE164Phone(e164: string): { country: CountryOption; digits: string } {
|
||
if (!e164) {
|
||
return { country: phoneCountries[0], digits: '' };
|
||
}
|
||
const sorted = [...phoneCountries].sort((a, b) => b.code.length - a.code.length);
|
||
for (const country of sorted) {
|
||
if (e164.startsWith(country.code)) {
|
||
return { country, digits: e164.slice(country.code.length).replace(/\D/g, '') };
|
||
}
|
||
}
|
||
return { country: phoneCountries[0], digits: e164.replace(/\D/g, '') };
|
||
}
|
||
|
||
const phoneThemes = {
|
||
dark: {
|
||
shell: 'border-[#555762] bg-transparent text-white focus-within:border-[#7b7f8f] focus-within:ring-white/10',
|
||
trigger: 'text-white hover:bg-[#2a2c36]',
|
||
chevron: 'text-[#8f92a0]',
|
||
input: 'text-white placeholder:text-[#8f92a0]',
|
||
clear: 'text-[#8f92a0] hover:bg-[#2a2c36] hover:text-white'
|
||
},
|
||
light: {
|
||
shell: 'border-[#eceef4] bg-white text-[#111827] focus-within:border-[#c7cad6] focus-within:ring-[#eceef4]',
|
||
trigger: 'text-[#111827] hover:bg-[#f4f5f8]',
|
||
chevron: 'text-[#667085]',
|
||
input: 'text-[#111827] placeholder:text-[#667085]',
|
||
clear: 'text-[#667085] hover:bg-[#f4f5f8] hover:text-[#111827]'
|
||
}
|
||
} as const;
|
||
|
||
export function PhoneInput({
|
||
country,
|
||
value,
|
||
onCountryChange,
|
||
onValueChange,
|
||
className,
|
||
variant = 'dark',
|
||
required = true
|
||
}: {
|
||
country: CountryOption;
|
||
value: string;
|
||
onCountryChange: (country: CountryOption) => void;
|
||
onValueChange: (digits: string) => void;
|
||
className?: string;
|
||
variant?: keyof typeof phoneThemes;
|
||
required?: boolean;
|
||
}) {
|
||
const maskedValue = formatPhoneNumber(value, country);
|
||
const theme = phoneThemes[variant];
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'flex h-[58px] w-full items-center rounded-2xl border transition focus-within:ring-4',
|
||
variant === 'light' ? 'h-11' : '',
|
||
theme.shell,
|
||
className
|
||
)}
|
||
>
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<button type="button" className={cn('flex h-full shrink-0 items-center gap-2 rounded-l-2xl px-4 outline-none', theme.trigger)} aria-label="Выбрать страну">
|
||
<FlagIcon src={country.flagSrc} />
|
||
<span className="text-base font-semibold">{country.code}</span>
|
||
<ChevronDown className={cn('h-4 w-4', theme.chevron)} />
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-80 p-1">
|
||
<Command>
|
||
<CommandInput placeholder="Страна или код" />
|
||
<CommandList>
|
||
<CommandEmpty>Страна не найдена</CommandEmpty>
|
||
<CommandGroup>
|
||
{phoneCountries.map((item) => (
|
||
<CommandItem
|
||
key={item.id}
|
||
value={`${item.label} ${item.code}`}
|
||
onSelect={() => {
|
||
onCountryChange(item);
|
||
onValueChange(value.replace(/\D/g, '').slice(0, item.maxDigits));
|
||
}}
|
||
>
|
||
<FlagIcon src={item.flagSrc} />
|
||
<span className="flex-1">{item.label}</span>
|
||
<span className="font-semibold text-[#667085]">{item.code}</span>
|
||
</CommandItem>
|
||
))}
|
||
</CommandGroup>
|
||
</CommandList>
|
||
</Command>
|
||
</PopoverContent>
|
||
</Popover>
|
||
|
||
<input
|
||
className={cn('h-full min-w-0 flex-1 bg-transparent px-1 text-lg outline-none', variant === 'light' ? 'text-base' : '', theme.input)}
|
||
inputMode="tel"
|
||
placeholder={country.code === '+375' ? '(29) 123-45-67' : '(999) 123-45-67'}
|
||
value={maskedValue}
|
||
onChange={(event) => onValueChange(event.target.value.replace(/\D/g, '').slice(0, country.maxDigits))}
|
||
required={required}
|
||
/>
|
||
|
||
{value ? (
|
||
<button type="button" className={cn('mr-3 flex h-8 w-8 shrink-0 items-center justify-center rounded-full', theme.clear)} onClick={() => onValueChange('')} aria-label="Очистить телефон">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|