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 React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@@ -14,7 +15,7 @@ import {
|
|||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { StarIcon as StarSolid } from '@heroicons/react/24/solid';
|
import { StarIcon as StarSolid } from '@heroicons/react/24/solid';
|
||||||
import { toast } from 'sonner';
|
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 L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
@@ -182,14 +183,35 @@ function StarRating({ rate }: { rate: number }) {
|
|||||||
|
|
||||||
const IRAN_CENTER: [number, number] = [32.4279, 53.6880];
|
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 }) {
|
function MapClickHandler({ onPick }: { onPick: (lat: number, lng: number) => void }) {
|
||||||
useMapEvents({ click: e => onPick(e.latlng.lat, e.latlng.lng) });
|
useMapEvents({ click: e => onPick(e.latlng.lat, e.latlng.lng) });
|
||||||
return null;
|
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;
|
lat: number | null; lng: number | null;
|
||||||
onChange: (lat: number, lng: number) => void;
|
onChange: (lat: number, lng: number) => void;
|
||||||
|
flyTarget?: [number, number] | null;
|
||||||
}) {
|
}) {
|
||||||
const pos: [number, number] | null = lat !== null && lng !== null ? [lat, lng] : null;
|
const pos: [number, number] | null = lat !== null && lng !== null ? [lat, lng] : null;
|
||||||
const center: [number, number] = pos ?? IRAN_CENTER;
|
const center: [number, number] = pos ?? IRAN_CENTER;
|
||||||
@@ -201,12 +223,101 @@ function MapPicker({ lat, lng, onChange }: {
|
|||||||
attribution='© <a href="https://openstreetmap.org">OpenStreetMap</a>'
|
attribution='© <a href="https://openstreetmap.org">OpenStreetMap</a>'
|
||||||
/>
|
/>
|
||||||
<MapClickHandler onPick={onChange} />
|
<MapClickHandler onPick={onChange} />
|
||||||
|
<MapController flyTarget={flyTarget ?? null} />
|
||||||
{pos && <Marker position={pos} />}
|
{pos && <Marker position={pos} />}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</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 ──────────────────────────────────────────
|
// ── Hierarchical Specialty Picker ──────────────────────────────────────────
|
||||||
|
|
||||||
function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
|
function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
|
||||||
@@ -430,10 +541,12 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
|
|||||||
resolver: zodResolver(addrSchema),
|
resolver: zodResolver(addrSchema),
|
||||||
});
|
});
|
||||||
const provinceId = watch('province_id');
|
const provinceId = watch('province_id');
|
||||||
|
const cityId = watch('city_id');
|
||||||
const latStr = watch('latitude');
|
const latStr = watch('latitude');
|
||||||
const lngStr = watch('longitude');
|
const lngStr = watch('longitude');
|
||||||
const lat = latStr ? parseFloat(latStr) : null;
|
const lat = latStr ? parseFloat(latStr) : null;
|
||||||
const lng = lngStr ? parseFloat(lngStr) : null;
|
const lng = lngStr ? parseFloat(lngStr) : null;
|
||||||
|
const [mapFlyTarget, setMapFlyTarget] = useState<[number, number] | null>(null);
|
||||||
|
|
||||||
const provincesQ = useQuery({
|
const provincesQ = useQuery({
|
||||||
queryKey: ['provinces'],
|
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">
|
<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="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>
|
<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')} />
|
<input type="text" className="cp-input" placeholder="مثال: کلینیک مهر" {...register('name')} />
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">تلفن</label>
|
<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')} />
|
<input type="text" dir="ltr" className="cp-input text-left" placeholder="021..." {...register('telephone')} />
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">استان</label>
|
<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 ?? ''}
|
<SearchableSelect
|
||||||
onChange={e => { setValue('province_id', e.target.value ? Number(e.target.value) : null); setValue('city_id', null); }}>
|
options={provinces.map(p => ({ value: p.id, label: p.name }))}
|
||||||
<option value="">انتخاب استان</option>
|
value={provinceId ?? null}
|
||||||
{provinces.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
placeholder="انتخاب استان"
|
||||||
</select>
|
onChange={(val) => {
|
||||||
|
setValue('province_id', val);
|
||||||
|
setValue('city_id', null);
|
||||||
|
setMapFlyTarget(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">شهر</label>
|
<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}
|
<SearchableSelect
|
||||||
{...register('city_id', { setValueAs: v => v ? Number(v) : null })}>
|
options={cities.map(c => ({ value: c.id, label: c.name }))}
|
||||||
<option value="">انتخاب شهر</option>
|
value={cityId ?? null}
|
||||||
{cities.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
placeholder={provinceId ? 'انتخاب شهر' : 'ابتدا استان انتخاب کنید'}
|
||||||
</select>
|
disabled={!provinceId}
|
||||||
|
onChange={(val, label) => {
|
||||||
|
setValue('city_id', val);
|
||||||
|
if (label) {
|
||||||
|
geocodeCityInIran(label).then(coords => {
|
||||||
|
if (coords) setMapFlyTarget(coords);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map picker */}
|
{/* Row 4: Map */}
|
||||||
<div>
|
<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 className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
موقعیت روی نقشه
|
موقعیت روی نقشه
|
||||||
</label>
|
</label>
|
||||||
@@ -538,8 +673,10 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 dark:text-slate-500 mb-2">روی نقشه کلیک کنید تا موقعیت انتخاب شود</p>
|
<p className="text-xs text-slate-400 dark:text-slate-500 mb-2">
|
||||||
<MapPicker lat={lat} lng={lng}
|
برای دقت بیشتر روی نقشه کلیک کنید
|
||||||
|
</p>
|
||||||
|
<MapPicker lat={lat} lng={lng} flyTarget={mapFlyTarget}
|
||||||
onChange={(lt, ln) => { setValue('latitude', String(lt)); setValue('longitude', String(ln)); }} />
|
onChange={(lt, ln) => { setValue('latitude', String(lt)); setValue('longitude', String(ln)); }} />
|
||||||
{lat && lng && (
|
{lat && lng && (
|
||||||
<button type="button"
|
<button type="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user