Files
IdP/apps/frontend/components/ui/phone-input.tsx
2026-06-24 20:15:19 +03:00

178 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}