feat(geo): busca por raio (PostGIS) em Salas e Profissionais
- geocoder de CEP com cache (cep_geo) + rotação: AwesomeAPI → Nominatim(CEP, c/ User-Agent) → ViaCEP+Nominatim(endereço) - colunas geo geography(Point,4326) + índice GiST em salas e usuarios - geocoda ao salvar (POST/PUT salas, PUT perfil) via setGeoByCep - marketplaces aceitam ?cep=&raio= → ST_DWithin + dist_km, ordenado por distância - frontend: filtro Meu CEP + Raio + badge ~X KM nos cards - fix: clinicas.nome inexistente → nome_fantasia (salas/minhas + marketplace) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ interface Profissional {
|
||||
especialidade: string;
|
||||
raio_atuacao_km?: number | null;
|
||||
bio_profissional?: string | null;
|
||||
dist_km?: number | string | null;
|
||||
}
|
||||
|
||||
interface Proposta {
|
||||
@@ -138,6 +139,7 @@ export const ProfissionaisPlugin: React.FC = () => {
|
||||
const [fCidade, setFCidade] = useState('');
|
||||
const [fEspecialidade, setFEspecialidade] = useState('');
|
||||
const [fCep, setFCep] = useState('');
|
||||
const [fRaio, setFRaio] = useState('');
|
||||
// perfil
|
||||
const [perfil, setPerfil] = useState<any>(null);
|
||||
const [savingPerfil, setSavingPerfil] = useState(false);
|
||||
@@ -154,11 +156,12 @@ export const ProfissionaisPlugin: React.FC = () => {
|
||||
if (fCidade) params.set('cidade', fCidade);
|
||||
if (fEspecialidade) params.set('especialidade', fEspecialidade);
|
||||
if (fCep) params.set('cep', fCep);
|
||||
if (fCep && Number(fRaio) > 0) params.set('raio', fRaio);
|
||||
const res = await apiFetch(`/api/profissionais/marketplace?${params}`);
|
||||
if (res.ok) setLista(await res.json());
|
||||
} catch { /* silently fail */ }
|
||||
finally { setLoading(false); }
|
||||
}, [fTipo, fEstado, fCidade, fEspecialidade, fCep]);
|
||||
}, [fTipo, fEstado, fCidade, fEspecialidade, fCep, fRaio]);
|
||||
|
||||
const fetchPerfil = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -288,8 +291,12 @@ export const ProfissionaisPlugin: React.FC = () => {
|
||||
<input className={`${inputCls} uppercase`} value={fEspecialidade} onChange={e => setFEspecialidade(e.target.value.toUpperCase())} placeholder="ORTODONTIA" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>CEP (região)</label>
|
||||
<input className={inputCls} value={fCep} onChange={e => setFCep(e.target.value)} placeholder="79000" />
|
||||
<label className={labelCls}>Meu CEP</label>
|
||||
<input className={inputCls} value={fCep} onChange={e => setFCep(e.target.value)} placeholder="79000-000" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Raio (km)</label>
|
||||
<input type="number" min="1" className={inputCls} value={fRaio} onChange={e => setFRaio(e.target.value)} placeholder="20" />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={fetchLista} disabled={loading}
|
||||
@@ -331,6 +338,7 @@ export const ProfissionaisPlugin: React.FC = () => {
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500 font-medium">
|
||||
<MapPin size={12} className="shrink-0 text-gray-400" />
|
||||
<span>{[p.bairro, p.cidade, p.estado].filter(Boolean).join(' · ')}{p.raio_atuacao_km ? ` · ATENDE EM ATÉ ${p.raio_atuacao_km} KM` : ''}</span>
|
||||
{p.dist_km != null && <span className="ml-auto shrink-0 text-[9px] font-black px-2 py-0.5 rounded-full bg-orange-100 text-orange-700">~{p.dist_km} KM</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500 font-medium">
|
||||
|
||||
@@ -45,6 +45,7 @@ interface Sala {
|
||||
clinica_id?: string | null;
|
||||
clinica_nome?: string | null;
|
||||
reservas_pendentes?: number;
|
||||
dist_km?: number | string | null;
|
||||
}
|
||||
|
||||
interface Reserva {
|
||||
@@ -377,6 +378,7 @@ const SalaCard: React.FC<{ sala: Sala; mine?: boolean; onReserve?: () => void; o
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500 font-medium mb-2">
|
||||
<MapPin size={12} className="shrink-0 text-gray-400" />
|
||||
<span>{[sala.endereco, sala.bairro, sala.cidade, sala.estado].filter(Boolean).join(' · ')}</span>
|
||||
{sala.dist_km != null && <span className="ml-auto shrink-0 text-[9px] font-black px-2 py-0.5 rounded-full" style={{ backgroundColor: `${COLOR}15`, color: COLOR }}>~{sala.dist_km} KM</span>}
|
||||
</div>
|
||||
{sala.equipamentos && (
|
||||
<p className="text-[10px] text-gray-400 font-bold uppercase mb-3">EQUIP.: {sala.equipamentos}</p>
|
||||
@@ -448,6 +450,8 @@ export const SalasPlugin: React.FC = () => {
|
||||
const [fTipo, setFTipo] = useState('');
|
||||
const [fEstado, setFEstado] = useState('');
|
||||
const [fCidade, setFCidade] = useState('');
|
||||
const [fCep, setFCep] = useState('');
|
||||
const [fRaio, setFRaio] = useState('');
|
||||
// minhas
|
||||
const [minhas, setMinhas] = useState<Sala[]>([]);
|
||||
// reservas
|
||||
@@ -463,11 +467,12 @@ export const SalasPlugin: React.FC = () => {
|
||||
if (fTipo) params.set('tipo', fTipo);
|
||||
if (fEstado) params.set('estado', fEstado);
|
||||
if (fCidade) params.set('cidade', fCidade);
|
||||
if (fCep && Number(fRaio) > 0) { params.set('cep', fCep); params.set('raio', fRaio); }
|
||||
const res = await apiFetch(`/api/salas/marketplace?${params}`);
|
||||
if (res.ok) setSalas(await res.json());
|
||||
} catch { /* silently fail */ }
|
||||
finally { setLoading(false); }
|
||||
}, [fTipo, fEstado, fCidade]);
|
||||
}, [fTipo, fEstado, fCidade, fCep, fRaio]);
|
||||
|
||||
const fetchMinhas = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -570,10 +575,18 @@ export const SalasPlugin: React.FC = () => {
|
||||
{UF_LIST.map(uf => <option key={uf} value={uf}>{uf || 'Todos os estados'}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div>
|
||||
<label className={labelCls}>Cidade</label>
|
||||
<input className={`${inputCls} uppercase`} value={fCidade} onChange={e => setFCidade(e.target.value.toUpperCase())} placeholder="CAMPO GRANDE" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Meu CEP (busca por raio)</label>
|
||||
<input className={inputCls} value={fCep} onChange={e => setFCep(e.target.value)} placeholder="00000-000" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Raio (km)</label>
|
||||
<input type="number" min="1" className={inputCls} value={fRaio} onChange={e => setFRaio(e.target.value)} placeholder="10" />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={fetchMarketplace} disabled={loading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-colors disabled:opacity-60 hover:opacity-90" style={{ backgroundColor: COLOR }}>
|
||||
|
||||
Reference in New Issue
Block a user