feat(marketplace): exigir endereço/CEP da origem antes da busca por proximidade

- Profissionais e Salas: aba de busca bloqueia se a origem não tiver cep+cidade+estado
- clínica/consultório (donoclinica/donoconsultorio/admin/funcionário): checa endereço
  da clínica ativa → CTA p/ Configurações
- dentista/biomédico/protético: checa o próprio perfil → CTA p/ completar perfil
- fail-safe: em erro de checagem não bloqueia

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-14 03:23:32 +02:00
parent bbbe3705d8
commit 17f83e5c86
2 changed files with 93 additions and 3 deletions
+45 -2
View File
@@ -123,6 +123,26 @@ const PropostaModal: React.FC<{ prof: Profissional; onClose: () => void; onSent:
// ─── Main view ─────────────────────────────────────────────────────────────── // ─── Main view ───────────────────────────────────────────────────────────────
type Tab = 'buscar' | 'perfil' | 'propostas'; type Tab = 'buscar' | 'perfil' | 'propostas';
// Bloqueio quando falta endereço/CEP da origem (clínica ou perfil) — a busca é por proximidade.
const GateEndereco: React.FC<{ isClinic: boolean; onCompletarPerfil: () => void }> = ({ isClinic, onCompletarPerfil }) => (
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-8 text-center">
<div className="w-14 h-14 rounded-2xl bg-amber-50 flex items-center justify-center mx-auto mb-4">
<MapPin size={26} className="text-amber-500" />
</div>
<h3 className="text-base font-black uppercase tracking-tight text-gray-900">Complete seu endereço</h3>
<p className="text-sm text-gray-500 mt-2 max-w-md mx-auto leading-relaxed">
A busca de profissionais é por proximidade. Preencha {isClinic
? 'a localização e o CEP da sua clínica/consultório (em Configurações)'
: 'a localização e o CEP do seu perfil'} para continuar.
</p>
<button onClick={() => isClinic ? window.location.assign('/configuracoes') : onCompletarPerfil()}
className="mt-5 inline-flex items-center gap-2 px-5 py-3 rounded-xl text-white text-xs font-black uppercase tracking-widest hover:opacity-90 transition-opacity"
style={{ backgroundColor: COLOR }}>
{isClinic ? 'Ir para Configurações' : 'Completar meu perfil'}
</button>
</div>
);
export const ProfissionaisPlugin: React.FC = () => { export const ProfissionaisPlugin: React.FC = () => {
const toast = useToast(); const toast = useToast();
const role = HybridBackend.getCurrentRole(); const role = HybridBackend.getCurrentRole();
@@ -146,6 +166,26 @@ export const ProfissionaisPlugin: React.FC = () => {
// propostas // propostas
const [propostas, setPropostas] = useState<{ enviadas: Proposta[]; recebidas: Proposta[] }>({ enviadas: [], recebidas: [] }); const [propostas, setPropostas] = useState<{ enviadas: Proposta[]; recebidas: Proposta[] }>({ enviadas: [], recebidas: [] });
const [proposingTo, setProposingTo] = useState<Profissional | null>(null); const [proposingTo, setProposingTo] = useState<Profissional | null>(null);
// null = checando; true/false = endereço da origem (clínica ou perfil) preenchido?
const [enderecoOk, setEnderecoOk] = useState<boolean | null>(null);
const checkEndereco = useCallback(async () => {
try {
if (isProfissional) {
const res = await apiFetch('/api/profissionais/perfil');
const p = res.ok ? await res.json() : {};
setEnderecoOk(!!(p?.cep && p?.cidade && p?.estado));
} else if (ws?.id) {
const r = await HybridBackend.getClinicaProfile(ws.id);
const c: any = r?.clinica || {};
setEnderecoOk(!!(c.cep && c.cidade && c.estado));
} else {
setEnderecoOk(true); // sem contexto claro → não bloqueia
}
} catch { setEnderecoOk(true); } // em erro, não bloqueia a tela
}, [isProfissional, ws?.id]);
useEffect(() => { checkEndereco(); }, [checkEndereco]);
const fetchLista = useCallback(async () => { const fetchLista = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -265,8 +305,11 @@ export const ProfissionaisPlugin: React.FC = () => {
})} })}
</div> </div>
{/* BUSCAR */} {/* BUSCAR — bloqueia se faltar endereço/CEP da origem */}
{tab === 'buscar' && ( {tab === 'buscar' && enderecoOk === false && (
<GateEndereco isClinic={!isProfissional} onCompletarPerfil={() => setTab('perfil')} />
)}
{tab === 'buscar' && enderecoOk !== false && (
<> <>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5"> <div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-4"> <div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-4">
+48 -1
View File
@@ -440,6 +440,26 @@ const ReservaRow: React.FC<{ r: Reserva; recebida?: boolean; onAction: (acao: 'c
// ─── Main view ─────────────────────────────────────────────────────────────── // ─── Main view ───────────────────────────────────────────────────────────────
type Tab = 'marketplace' | 'minhas' | 'reservas'; type Tab = 'marketplace' | 'minhas' | 'reservas';
// Bloqueio quando falta endereço/CEP da origem (clínica ou perfil) — a busca é por proximidade.
const GateEndereco: React.FC<{ isClinic: boolean }> = ({ isClinic }) => (
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm p-8 text-center">
<div className="w-14 h-14 rounded-2xl bg-amber-50 flex items-center justify-center mx-auto mb-4">
<MapPin size={26} className="text-amber-500" />
</div>
<h3 className="text-base font-black uppercase tracking-tight text-gray-900">Complete seu endereço</h3>
<p className="text-sm text-gray-500 mt-2 max-w-md mx-auto leading-relaxed">
A busca de salas é por proximidade. Preencha {isClinic
? 'a localização e o CEP da sua clínica/consultório (em Configurações)'
: 'a localização e o CEP do seu perfil (em Profissionais → Meu Perfil)'} para continuar.
</p>
<button onClick={() => window.location.assign(isClinic ? '/configuracoes' : '/marketplace-profissionais')}
className="mt-5 inline-flex items-center gap-2 px-5 py-3 rounded-xl text-white text-xs font-black uppercase tracking-widest hover:opacity-90 transition-opacity"
style={{ backgroundColor: COLOR }}>
{isClinic ? 'Ir para Configurações' : 'Completar meu perfil'}
</button>
</div>
);
export const SalasPlugin: React.FC = () => { export const SalasPlugin: React.FC = () => {
const toast = useToast(); const toast = useToast();
const [tab, setTab] = useState<Tab>('marketplace'); const [tab, setTab] = useState<Tab>('marketplace');
@@ -460,6 +480,30 @@ export const SalasPlugin: React.FC = () => {
const [editingSala, setEditingSala] = useState<Sala | null | 'new'>(null); const [editingSala, setEditingSala] = useState<Sala | null | 'new'>(null);
const [reservingSala, setReservingSala] = useState<Sala | null>(null); const [reservingSala, setReservingSala] = useState<Sala | null>(null);
// Endereço da origem (clínica ou perfil) preenchido? null = checando.
const role = HybridBackend.getCurrentRole();
const ws = HybridBackend.getActiveWorkspace();
const isProfissional = ['dentista', 'biomedico', 'protetico'].includes(role);
const [enderecoOk, setEnderecoOk] = useState<boolean | null>(null);
const checkEndereco = useCallback(async () => {
try {
if (isProfissional) {
const res = await apiFetch('/api/profissionais/perfil');
const p = res.ok ? await res.json() : {};
setEnderecoOk(!!(p?.cep && p?.cidade && p?.estado));
} else if (ws?.id) {
const r = await HybridBackend.getClinicaProfile(ws.id);
const c: any = r?.clinica || {};
setEnderecoOk(!!(c.cep && c.cidade && c.estado));
} else {
setEnderecoOk(true);
}
} catch { setEnderecoOk(true); }
}, [isProfissional, ws?.id]);
useEffect(() => { checkEndereco(); }, [checkEndereco]);
const fetchMarketplace = useCallback(async () => { const fetchMarketplace = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -560,7 +604,10 @@ export const SalasPlugin: React.FC = () => {
</div> </div>
{/* MARKETPLACE */} {/* MARKETPLACE */}
{tab === 'marketplace' && ( {tab === 'marketplace' && enderecoOk === false && (
<GateEndereco isClinic={!isProfissional} />
)}
{tab === 'marketplace' && enderecoOk !== false && (
<> <>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5"> <div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">