300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
'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>
|
||
);
|
||
}
|