Files
IdP/apps/frontend/components/addresses/address-form-dialog.tsx
2026-06-24 14:37:15 +03:00

300 lines
11 KiB
TypeScript
Raw Permalink 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 dynamic from 'next/dynamic';
import { useEffect, useRef, useState } from 'react';
import { Loader2, MapPin, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useToast } from '@/components/id/toast-provider';
import { ADDRESS_LABELS, type AddressLabel } from '@/lib/address-catalog';
import { forwardGeocode, reverseGeocode } from '@/lib/geocoding';
import { deleteUserAddress, getApiErrorMessage, upsertUserAddress, UserAddress } from '@/lib/api';
import { cn } from '@/lib/utils';
const AddressMapPicker = dynamic(
() => import('@/components/addresses/address-map-picker').then((module) => module.AddressMapPicker),
{
ssr: false,
loading: () => (
<div className="flex h-[280px] items-center justify-center rounded-2xl border border-[#eceef4] bg-[#fafbfd] text-sm text-[#667085]">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Загрузка карты...
</div>
)
}
);
function FieldLabel({ children }: { children: React.ReactNode }) {
return <label className="block text-sm font-medium text-[#1f2430]">{children}</label>;
}
export function AddressFormDialog({
open,
onOpenChange,
label,
userId,
token,
existing,
onSaved
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
label: AddressLabel;
userId: string;
token?: string | null;
existing: UserAddress | null;
onSaved: () => void;
}) {
const { showToast } = useToast();
const [mode, setMode] = useState<'map' | 'manual'>('map');
const [city, setCity] = useState('');
const [street, setStreet] = useState('');
const [house, setHouse] = useState('');
const [apartment, setApartment] = useState('');
const [comment, setComment] = useState('');
const [fullAddress, setFullAddress] = useState('');
const [latitude, setLatitude] = useState<number | undefined>();
const [longitude, setLongitude] = useState<number | undefined>();
const [isGeocoding, setIsGeocoding] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const geocodeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!open) return;
setMode(existing?.latitude != null && existing?.longitude != null ? 'map' : existing ? 'manual' : 'map');
setCity(existing?.city ?? '');
setStreet(existing?.street ?? '');
setHouse(existing?.house ?? '');
setApartment(existing?.apartment ?? '');
setComment(existing?.comment ?? '');
setFullAddress(existing?.fullAddress ?? '');
setLatitude(existing?.latitude);
setLongitude(existing?.longitude);
}, [existing, open]);
useEffect(() => {
return () => {
if (geocodeTimer.current) clearTimeout(geocodeTimer.current);
};
}, []);
async function handleMapPick(lat: number, lng: number) {
setLatitude(lat);
setLongitude(lng);
if (geocodeTimer.current) clearTimeout(geocodeTimer.current);
geocodeTimer.current = setTimeout(async () => {
setIsGeocoding(true);
try {
const parsed = await reverseGeocode(lat, lng);
if (parsed) {
setCity(parsed.city);
setStreet(parsed.street);
setHouse(parsed.house);
setFullAddress(parsed.fullAddress);
}
} finally {
setIsGeocoding(false);
}
}, 450);
}
async function locateManualAddress() {
const query = fullAddress.trim() || [`г. ${city}`, `${street}, ${house}`].filter(Boolean).join(', ');
if (!query.trim()) {
showToast('Сначала укажите адрес для поиска');
return;
}
setIsGeocoding(true);
try {
const result = await forwardGeocode(query);
if (!result) {
showToast('Адрес не найден на карте');
return;
}
setLatitude(result.lat);
setLongitude(result.lng);
setCity(result.parsed.city || city);
setStreet(result.parsed.street || street);
setHouse(result.parsed.house || house);
setFullAddress(result.parsed.fullAddress);
setMode('map');
} finally {
setIsGeocoding(false);
}
}
async function saveAddress() {
setIsSaving(true);
try {
await upsertUserAddress(
userId,
{
addressId: existing?.id,
label,
city: city.trim(),
street: street.trim(),
house: house.trim(),
apartment: apartment.trim() || undefined,
comment: comment.trim() || undefined,
latitude,
longitude,
fullAddress: fullAddress.trim() || undefined
},
token
);
showToast(existing ? 'Адрес обновлён' : 'Адрес добавлен');
onSaved();
onOpenChange(false);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось сохранить адрес');
if (message) showToast(message);
} finally {
setIsSaving(false);
}
}
async function removeAddress() {
if (!existing) return;
setIsDeleting(true);
try {
await deleteUserAddress(userId, existing.id, token);
showToast('Адрес удалён');
onSaved();
onOpenChange(false);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось удалить адрес');
if (message) showToast(message);
} finally {
setIsDeleting(false);
}
}
const title = existing
? `Редактировать: ${ADDRESS_LABELS[label].title}`
: `Добавить: ${ADDRESS_LABELS[label].title}`;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[92vh] overflow-y-auto rounded-[28px] sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<Tabs value={mode} onValueChange={(value) => setMode(value as 'map' | 'manual')}>
<TabsList className="grid w-full grid-cols-2 bg-[#f4f5f8] p-1">
<TabsTrigger
value="map"
className={cn(
'rounded-xl px-4 py-2 text-sm data-[state=active]:bg-white data-[state=active]:text-[#1f2430] data-[state=active]:shadow-sm',
'text-[#667085]'
)}
>
На карте
</TabsTrigger>
<TabsTrigger
value="manual"
className={cn(
'rounded-xl px-4 py-2 text-sm data-[state=active]:bg-white data-[state=active]:text-[#1f2430] data-[state=active]:shadow-sm',
'text-[#667085]'
)}
>
Вручную
</TabsTrigger>
</TabsList>
<TabsContent value="map" className="space-y-4">
<AddressMapPicker latitude={latitude} longitude={longitude} onPick={handleMapPick} />
{isGeocoding ? (
<div className="flex items-center gap-2 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Определяем адрес...
</div>
) : fullAddress ? (
<div className="flex items-start gap-2 rounded-2xl bg-[#f4f5f8] px-4 py-3 text-sm">
<MapPin className="mt-0.5 h-4 w-4 shrink-0 text-[#667085]" />
<span>{fullAddress}</span>
</div>
) : (
<p className="text-sm text-[#667085]">Выберите точку на карте адрес подставится автоматически</p>
)}
</TabsContent>
<TabsContent value="manual" className="space-y-4">
<div className="space-y-2">
<FieldLabel>Полный адрес</FieldLabel>
<Input
placeholder="Например: г. Москва, ул. Тверская, 1"
value={fullAddress}
onChange={(event) => setFullAddress(event.target.value)}
/>
</div>
<Button type="button" variant="secondary" className="w-full" onClick={() => void locateManualAddress()} disabled={isGeocoding}>
{isGeocoding ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Ищем на карте...
</>
) : (
'Найти на карте'
)}
</Button>
</TabsContent>
</Tabs>
<div className="mt-2 grid gap-3 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<FieldLabel>Город</FieldLabel>
<Input placeholder="Москва" value={city} onChange={(event) => setCity(event.target.value)} />
</div>
<div className="space-y-2">
<FieldLabel>Улица</FieldLabel>
<Input placeholder="Тверская" value={street} onChange={(event) => setStreet(event.target.value)} />
</div>
<div className="space-y-2">
<FieldLabel>Дом</FieldLabel>
<Input placeholder="1" value={house} onChange={(event) => setHouse(event.target.value)} />
</div>
<div className="space-y-2">
<FieldLabel>Квартира</FieldLabel>
<Input placeholder="Необязательно" value={apartment} onChange={(event) => setApartment(event.target.value)} />
</div>
<div className="space-y-2">
<FieldLabel>Комментарий</FieldLabel>
<Input placeholder="Подъезд, домофон..." value={comment} onChange={(event) => setComment(event.target.value)} />
</div>
</div>
<div className="mt-6 flex gap-3">
{existing ? (
<Button
type="button"
variant="secondary"
className="text-red-600 hover:text-red-700"
onClick={() => void removeAddress()}
disabled={isSaving || isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
) : null}
<Button type="button" variant="secondary" className="flex-1" onClick={() => onOpenChange(false)} disabled={isSaving || isDeleting}>
Отмена
</Button>
<Button type="button" className="flex-1" onClick={() => void saveAddress()} disabled={isSaving || isDeleting || isGeocoding}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Сохраняем...
</>
) : (
'Сохранить'
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}