feat(address): implement searchable select for province and city with geocoding support
This commit is contained in:
@@ -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='© <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"
|
||||
|
||||
Reference in New Issue
Block a user