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:
VPS 4 Builder
2026-06-13 23:18:49 +02:00
parent cb00d9f383
commit 503359ee97
3 changed files with 148 additions and 14 deletions
+11 -3
View File
@@ -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">