feat(address): implement searchable select for province and city with geocoding support

This commit is contained in:
hamed
2026-06-10 17:10:24 +03:30
parent 25701c09b7
commit a5876b9324
+158 -21
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
@@ -14,7 +15,7 @@ import {
} from '@heroicons/react/24/outline';
import { StarIcon as StarSolid } from '@heroicons/react/24/solid';
import { toast } from 'sonner';
import { MapContainer, TileLayer, Marker, useMapEvents } from 'react-leaflet';
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { api } from '../lib/api';
@@ -182,14 +183,35 @@ function StarRating({ rate }: { rate: number }) {
const IRAN_CENTER: [number, number] = [32.4279, 53.6880];
async function geocodeCityInIran(cityName: string): Promise<[number, number] | null> {
try {
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(cityName + ',ایران')}&format=json&countrycodes=ir&limit=1`;
const res = await fetch(url, { headers: { 'Accept-Language': 'fa' } });
const data = await res.json();
if (data?.[0]) return [parseFloat(data[0].lat), parseFloat(data[0].lon)];
return null;
} catch {
return null;
}
}
function MapClickHandler({ onPick }: { onPick: (lat: number, lng: number) => void }) {
useMapEvents({ click: e => onPick(e.latlng.lat, e.latlng.lng) });
return null;
}
function MapPicker({ lat, lng, onChange }: {
function MapController({ flyTarget }: { flyTarget: [number, number] | null }) {
const map = useMap();
useEffect(() => {
if (flyTarget) map.flyTo(flyTarget, 12, { duration: 1.2 });
}, [flyTarget, map]);
return null;
}
function MapPicker({ lat, lng, onChange, flyTarget }: {
lat: number | null; lng: number | null;
onChange: (lat: number, lng: number) => void;
flyTarget?: [number, number] | null;
}) {
const pos: [number, number] | null = lat !== null && lng !== null ? [lat, lng] : null;
const center: [number, number] = pos ?? IRAN_CENTER;
@@ -201,12 +223,101 @@ function MapPicker({ lat, lng, onChange }: {
attribution='&copy; <a href="https://openstreetmap.org">OpenStreetMap</a>'
/>
<MapClickHandler onPick={onChange} />
<MapController flyTarget={flyTarget ?? null} />
{pos && <Marker position={pos} />}
</MapContainer>
</div>
);
}
// ── Searchable Select ──────────────────────────────────────────────────────
function SearchableSelect({ options, value, onChange, placeholder, disabled = false }: {
options: { value: number; label: string }[];
value: number | null;
onChange: (value: number | null, label: string | null) => void;
placeholder?: string;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState('');
const [rect, setRect] = useState<DOMRect | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const dropRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(
() => q ? options.filter(o => o.label.includes(q)) : options,
[options, q]
);
const selected = useMemo(() => options.find(o => o.value === value) ?? null, [options, value]);
const openDropdown = () => {
if (disabled || !btnRef.current) return;
setRect(btnRef.current.getBoundingClientRect());
setOpen(v => !v);
setQ('');
};
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (
dropRef.current && !dropRef.current.contains(e.target as Node) &&
btnRef.current && !btnRef.current.contains(e.target as Node)
) setOpen(false);
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, [open]);
const dropStyle: React.CSSProperties = rect
? { position: 'fixed', top: rect.bottom + 4, left: rect.left, width: rect.width, zIndex: 9999 }
: {};
return (
<div>
<button
ref={btnRef}
type="button"
disabled={disabled}
onClick={openDropdown}
className={`cp-input h-11 flex items-center justify-between text-right w-full ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'} ${!selected ? 'text-slate-400 dark:text-slate-500' : ''}`}
>
<span className="truncate flex-1">{selected?.label ?? placeholder ?? 'انتخاب کنید'}</span>
<ChevronDownIcon className={`w-4 h-4 shrink-0 mr-2 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && createPortal(
<div ref={dropRef} style={dropStyle}
className="bg-white dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-xl shadow-2xl overflow-hidden">
<div className="p-2 border-b border-slate-100 dark:border-gray-700">
<input autoFocus type="text" value={q} onChange={e => setQ(e.target.value)}
placeholder="جستجو..." className="cp-input text-sm h-8" />
</div>
<div className="max-h-52 overflow-y-auto">
<button type="button"
onClick={() => { onChange(null, null); setOpen(false); setQ(''); }}
className="w-full text-right px-3 py-2 text-sm text-slate-400 hover:bg-slate-50 dark:hover:bg-gray-800">
{placeholder ?? 'انتخاب کنید'}
</button>
{filtered.map(o => (
<button key={o.value} type="button"
onClick={() => { onChange(o.value, o.label); setOpen(false); setQ(''); }}
className={`w-full text-right px-3 py-2 text-sm hover:bg-slate-50 dark:hover:bg-gray-800 ${value === o.value ? 'text-primary-600 dark:text-primary-400 font-medium bg-primary-50 dark:bg-primary-500/10' : 'text-slate-700 dark:text-slate-300'}`}>
{o.label}
</button>
))}
{filtered.length === 0 && (
<p className="text-xs text-slate-400 text-center py-3">نتیجهای یافت نشد</p>
)}
</div>
</div>,
document.body
)}
</div>
);
}
// ── Hierarchical Specialty Picker ──────────────────────────────────────────
function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
@@ -430,10 +541,12 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
resolver: zodResolver(addrSchema),
});
const provinceId = watch('province_id');
const cityId = watch('city_id');
const latStr = watch('latitude');
const lngStr = watch('longitude');
const lat = latStr ? parseFloat(latStr) : null;
const lng = lngStr ? parseFloat(lngStr) : null;
const [mapFlyTarget, setMapFlyTarget] = useState<[number, number] | null>(null);
const provincesQ = useQuery({
queryKey: ['provinces'],
@@ -495,40 +608,62 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
}
>
<form id="addr-form" onSubmit={handleSubmit(v => saveMut.mutate(v))} className="space-y-4">
{/* Row 1: Name + Phone */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">نام مطب / کلینیک</label>
<input type="text" className="cp-input" placeholder="مثال: کلینیک مهر" {...register('name')} />
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">آدرس کامل</label>
<textarea rows={2} className="cp-input resize-none" placeholder="خیابان، کوچه، پلاک..." {...register('address')} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">تلفن</label>
<input type="text" dir="ltr" className="cp-input text-left" placeholder="021..." {...register('telephone')} />
</div>
</div>
{/* Row 2: Full address */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">آدرس کامل</label>
<textarea rows={2} className="cp-input resize-none" placeholder="خیابان، کوچه، پلاک..." {...register('address')} />
</div>
{/* Row 3: Province + City side by side */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">استان</label>
<select className="cp-select h-11" value={provinceId ?? ''}
onChange={e => { setValue('province_id', e.target.value ? Number(e.target.value) : null); setValue('city_id', null); }}>
<option value="">انتخاب استان</option>
{provinces.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<SearchableSelect
options={provinces.map(p => ({ value: p.id, label: p.name }))}
value={provinceId ?? null}
placeholder="انتخاب استان"
onChange={(val) => {
setValue('province_id', val);
setValue('city_id', null);
setMapFlyTarget(null);
}}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">شهر</label>
<select className="cp-select h-11" disabled={!provinceId}
{...register('city_id', { setValueAs: v => v ? Number(v) : null })}>
<option value="">انتخاب شهر</option>
{cities.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<SearchableSelect
options={cities.map(c => ({ value: c.id, label: c.name }))}
value={cityId ?? null}
placeholder={provinceId ? 'انتخاب شهر' : 'ابتدا استان انتخاب کنید'}
disabled={!provinceId}
onChange={(val, label) => {
setValue('city_id', val);
if (label) {
geocodeCityInIran(label).then(coords => {
if (coords) setMapFlyTarget(coords);
});
}
}}
/>
</div>
</div>
{/* Map picker */}
{/* Row 4: Map */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center justify-between mb-1.5">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
موقعیت روی نقشه
</label>
@@ -538,8 +673,10 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
</span>
)}
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-2">روی نقشه کلیک کنید تا موقعیت انتخاب شود</p>
<MapPicker lat={lat} lng={lng}
<p className="text-xs text-slate-400 dark:text-slate-500 mb-2">
برای دقت بیشتر روی نقشه کلیک کنید
</p>
<MapPicker lat={lat} lng={lng} flyTarget={mapFlyTarget}
onChange={(lt, ln) => { setValue('latitude', String(lt)); setValue('longitude', String(ln)); }} />
{lat && lng && (
<button type="button"