first commit
This commit is contained in:
299
apps/frontend/components/addresses/address-form-dialog.tsx
Normal file
299
apps/frontend/components/addresses/address-form-dialog.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
102
apps/frontend/components/addresses/address-map-picker.tsx
Normal file
102
apps/frontend/components/addresses/address-map-picker.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
|
||||
const DEFAULT_CENTER = { lat: 55.7558, lng: 37.6173 };
|
||||
|
||||
let iconsFixed = false;
|
||||
|
||||
function fixLeafletIcons() {
|
||||
if (iconsFixed || typeof window === 'undefined') return;
|
||||
iconsFixed = true;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41]
|
||||
});
|
||||
}
|
||||
|
||||
export function AddressMapPicker({
|
||||
latitude,
|
||||
longitude,
|
||||
onPick
|
||||
}: {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
onPick: (lat: number, lng: number) => void;
|
||||
}) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstance = useRef<L.Map | null>(null);
|
||||
const markerRef = useRef<L.Marker | null>(null);
|
||||
const onPickRef = useRef(onPick);
|
||||
|
||||
useEffect(() => {
|
||||
onPickRef.current = onPick;
|
||||
}, [onPick]);
|
||||
|
||||
useEffect(() => {
|
||||
fixLeafletIcons();
|
||||
if (!mapRef.current || mapInstance.current) return;
|
||||
|
||||
const lat = latitude ?? DEFAULT_CENTER.lat;
|
||||
const lng = longitude ?? DEFAULT_CENTER.lng;
|
||||
const map = L.map(mapRef.current, { zoomControl: true }).setView([lat, lng], latitude != null ? 16 : 11);
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(map);
|
||||
|
||||
const marker = L.marker([lat, lng], { draggable: true }).addTo(map);
|
||||
|
||||
const emitPick = (nextLat: number, nextLng: number) => {
|
||||
onPickRef.current(nextLat, nextLng);
|
||||
};
|
||||
|
||||
marker.on('dragend', () => {
|
||||
const position = marker.getLatLng();
|
||||
emitPick(position.lat, position.lng);
|
||||
});
|
||||
|
||||
map.on('click', (event) => {
|
||||
marker.setLatLng(event.latlng);
|
||||
emitPick(event.latlng.lat, event.latlng.lng);
|
||||
});
|
||||
|
||||
mapInstance.current = map;
|
||||
markerRef.current = marker;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
map.invalidateSize();
|
||||
});
|
||||
resizeObserver.observe(mapRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
map.remove();
|
||||
mapInstance.current = null;
|
||||
markerRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!markerRef.current || !mapInstance.current) return;
|
||||
if (latitude == null || longitude == null) return;
|
||||
const latLng = L.latLng(latitude, longitude);
|
||||
markerRef.current.setLatLng(latLng);
|
||||
mapInstance.current.setView(latLng, Math.max(mapInstance.current.getZoom(), 15));
|
||||
}, [latitude, longitude]);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl border border-[#eceef4]">
|
||||
<div ref={mapRef} className="h-[280px] w-full" />
|
||||
<p className="border-t border-[#eceef4] bg-[#fafbfd] px-4 py-2 text-xs text-[#667085]">
|
||||
Нажмите на карту или перетащите маркер, чтобы указать адрес
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
apps/frontend/components/addresses/address-quick-section.tsx
Normal file
154
apps/frontend/components/addresses/address-quick-section.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronRight, Home, MapPin } from 'lucide-react';
|
||||
import { AddressFormDialog } from '@/components/addresses/address-form-dialog';
|
||||
import { ActionTile, SectionTitle } from '@/components/id/action-tile';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import {
|
||||
ADDRESS_LABELS,
|
||||
formatAddressLine,
|
||||
getOtherAddresses,
|
||||
getPrimaryAddress,
|
||||
type AddressLabel
|
||||
} from '@/lib/address-catalog';
|
||||
import { fetchUserAddresses, getApiErrorMessage, UserAddress } from '@/lib/api';
|
||||
|
||||
function AddressRow({ title, text, onClick }: { title: string; text: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center gap-4 border-b border-[#eceef4] px-4 py-4 text-left transition last:border-b-0 hover:bg-[#fafbfd]"
|
||||
>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white">
|
||||
<MapPin className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="truncate text-sm text-[#667085]">{text}</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 shrink-0 text-[#a8adbc]" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddressQuickSection({
|
||||
layout,
|
||||
isPinLocked = false,
|
||||
isReady = true
|
||||
}: {
|
||||
layout: 'home' | 'data';
|
||||
isPinLocked?: boolean;
|
||||
isReady?: boolean;
|
||||
}) {
|
||||
const { user, token } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const [addresses, setAddresses] = useState<UserAddress[]>([]);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [activeLabel, setActiveLabel] = useState<AddressLabel>('HOME');
|
||||
const [editingAddress, setEditingAddress] = useState<UserAddress | null>(null);
|
||||
|
||||
const loadAddresses = useCallback(async () => {
|
||||
if (!user || !token || isPinLocked) return;
|
||||
try {
|
||||
const response = await fetchUserAddresses(user.id, token);
|
||||
setAddresses(response.addresses ?? []);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, 'Не удалось загрузить адреса');
|
||||
if (message) showToast(message);
|
||||
}
|
||||
}, [isPinLocked, showToast, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady && user && !isPinLocked) void loadAddresses();
|
||||
}, [isPinLocked, isReady, loadAddresses, user]);
|
||||
|
||||
const homeAddress = useMemo(() => getPrimaryAddress(addresses, 'HOME') as UserAddress | null, [addresses]);
|
||||
const workAddress = useMemo(() => getPrimaryAddress(addresses, 'WORK') as UserAddress | null, [addresses]);
|
||||
const otherAddresses = useMemo(() => getOtherAddresses(addresses), [addresses]);
|
||||
|
||||
function openAddress(label: AddressLabel, existing: UserAddress | null = null) {
|
||||
setActiveLabel(label);
|
||||
if (label === 'HOME') setEditingAddress(existing ?? homeAddress);
|
||||
else if (label === 'WORK') setEditingAddress(existing ?? workAddress);
|
||||
else setEditingAddress(existing);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
const tiles = (
|
||||
<div className={layout === 'home' ? 'grid grid-cols-3 gap-4' : 'mt-4 grid grid-cols-3 gap-3'}>
|
||||
<ActionTile
|
||||
icon={Home}
|
||||
label={ADDRESS_LABELS.HOME.title}
|
||||
description={homeAddress ? formatAddressLine(homeAddress) : layout === 'data' ? 'Не указан' : undefined}
|
||||
className={layout === 'home' ? 'min-h-[76px]' : 'min-h-[76px]'}
|
||||
onClick={() => openAddress('HOME')}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={MapPin}
|
||||
label={ADDRESS_LABELS.WORK.title}
|
||||
description={workAddress ? formatAddressLine(workAddress) : layout === 'data' ? 'Не указан' : undefined}
|
||||
className="min-h-[76px]"
|
||||
onClick={() => openAddress('WORK')}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={MapPin}
|
||||
label={ADDRESS_LABELS.OTHER.shortTitle}
|
||||
description={
|
||||
otherAddresses.length > 0
|
||||
? `${otherAddresses.length} ${otherAddresses.length === 1 ? 'адрес' : otherAddresses.length < 5 ? 'адреса' : 'адресов'}`
|
||||
: layout === 'data'
|
||||
? 'Добавить'
|
||||
: undefined
|
||||
}
|
||||
className="min-h-[76px]"
|
||||
onClick={() => openAddress('OTHER')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{layout === 'home' ? (
|
||||
<>
|
||||
<SectionTitle>Адреса</SectionTitle>
|
||||
{tiles}
|
||||
</>
|
||||
) : (
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-medium">Адреса</h2>
|
||||
<p className="mt-1 text-sm text-[#667085]">Укажите адреса на карте или введите их вручную.</p>
|
||||
{tiles}
|
||||
<div className="mt-4 overflow-hidden rounded-[24px] bg-[#f4f5f8]">
|
||||
{addresses.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-sm text-[#667085]">Адреса пока не добавлены</p>
|
||||
) : (
|
||||
addresses.map((address) => (
|
||||
<AddressRow
|
||||
key={address.id}
|
||||
title={ADDRESS_LABELS[address.label as AddressLabel]?.title ?? address.label}
|
||||
text={formatAddressLine(address)}
|
||||
onClick={() => openAddress(address.label as AddressLabel, address)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{user ? (
|
||||
<AddressFormDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
label={activeLabel}
|
||||
userId={user.id}
|
||||
token={token}
|
||||
existing={editingAddress}
|
||||
onSaved={loadAddresses}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user