Files
VPS 4 Builder 5f4460e37d feat(cadastro): card "Protético" na vitrine de onboarding + desenho do Modo 1
- OnboardingChat: adiciona o card de atalho "Protéticos" (perfil profissional → protetico),
  ao lado de Dentistas/Biomédicos. O cadastro de protético já funcionava via "Sou profissional",
  faltava o atalho na vitrine.
- doc/MODO1-LINK-SEGURO-PROTESE: desenho do "Provedor sem conta" — link seguro /p/TOKEN
  (token por OS, revogável; rotas públicas com a mesma blindagem soLab; página pública read+ação;
  CTA de conversão com herança de histórico). Evolução futura, sem refatoração.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:37:41 +02:00

305 lines
18 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import {
Building2, Briefcase, Stethoscope, Sparkles, DoorOpen, FlaskConical,
ArrowRight, ArrowLeft, ChevronLeft, Loader2, Send, Check, CheckCircle2, User as UserIcon
} from 'lucide-react';
const API_URL = (window as any).__API_URL__ || '';
interface Props {
onDone: (result?: { noWorkspace?: boolean; userRole?: string }) => void;
onBackToLogin: () => void;
}
// ── Carrossel de apresentação (clínicas, consultórios, dentistas, biomédicos, salas) ──
const SLIDES: { icon: any; accent: string; title: string; desc: string; cta: string; pre?: Partial<Answers> }[] = [
{ icon: Building2, accent: '#2563eb', title: 'Clínicas', desc: 'Gestão completa: agenda, pacientes, financeiro e equipe — tudo num lugar.', cta: 'Cadastrar minha clínica', pre: { perfil: 'clinica' } },
{ icon: Briefcase, accent: '#0d9488', title: 'Consultórios', desc: 'Para quem voa solo: agenda inteligente e cobrança sem complicação.', cta: 'Cadastrar meu consultório', pre: { perfil: 'consultorio' } },
{ icon: Stethoscope, accent: '#7c3aed', title: 'Dentistas', desc: 'Sua agenda única — sem overbooking, mesmo atendendo em vários lugares.', cta: 'Sou dentista', pre: { perfil: 'profissional', profissao: 'dentista' } },
{ icon: Sparkles, accent: '#db2777', title: 'Biomédicos', desc: 'Harmonização orofacial e estética com prontuário e fotos por procedimento.', cta: 'Sou biomédico(a)', pre: { perfil: 'profissional', profissao: 'biomedico' } },
{ icon: FlaskConical, accent: '#2d6a4f', title: 'Protéticos', desc: 'Laboratório completo: bancada, ordens, etapas, componentes, financeiro e score.', cta: 'Sou protético(a)', pre: { perfil: 'profissional', profissao: 'protetico' } },
{ icon: DoorOpen, accent: '#0d9488', title: 'Salas', desc: 'Alugue ou anuncie salas por hora, dia ou mês. Valor fixo ou negociado.', cta: 'Tenho salas para alugar', pre: { perfil: 'sala' } },
];
// ── Esquema do chat ───────────────────────────────────────────────────────────
type Perfil = 'profissional' | 'clinica' | 'consultorio' | 'paciente' | 'sala';
type Profissao = 'dentista' | 'biomedico' | 'protetico';
interface Answers {
perfil?: Perfil;
profissao?: Profissao;
especialidades?: string[];
nome?: string;
nomeEstab?: string;
email?: string;
senha?: string;
}
const ESPECIALIDADES: Record<Profissao, string[]> = {
dentista: ['Clínico Geral', 'Ortodontia', 'Implantodontia', 'Endodontia', 'Periodontia', 'Odontopediatria', 'Prótese', 'Harmonização Orofacial', 'Cirurgia', 'Estética'],
biomedico: ['Harmonização Orofacial', 'Estética', 'Biomedicina Estética', 'Análises Clínicas'],
protetico: ['Prótese Fixa', 'Prótese Removível', 'Prótese sobre Implante', 'CAD/CAM'],
};
const STEP_ORDER = ['perfil', 'profissao', 'especialidades', 'nome', 'nomeEstab', 'email', 'senha', 'revisao'] as const;
type StepKey = typeof STEP_ORDER[number];
function isRelevant(key: StepKey, a: Answers): boolean {
if (key === 'profissao' || key === 'especialidades') return a.perfil === 'profissional';
if (key === 'nomeEstab') return a.perfil === 'clinica' || a.perfil === 'consultorio';
return true;
}
function isAnswered(key: StepKey, a: Answers): boolean {
if (key === 'especialidades') return Array.isArray(a.especialidades); // pode ser []
if (key === 'revisao') return false;
return (a as any)[key] !== undefined && (a as any)[key] !== '';
}
const PERFIL_LABEL: Record<Perfil, string> = { profissional: 'Sou profissional', clinica: 'Tenho uma clínica', consultorio: 'Tenho um consultório', paciente: 'Sou paciente', sala: 'Tenho salas para alugar' };
const PROF_LABEL: Record<Profissao, string> = { dentista: 'Dentista', biomedico: 'Biomédico(a)', protetico: 'Protético(a)' };
const emailOk = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
const OnboardingChat: React.FC<Props> = ({ onDone, onBackToLogin }) => {
const [phase, setPhase] = useState<'intro' | 'chat'>('intro');
const [slide, setSlide] = useState(0);
const [answers, setAnswers] = useState<Answers>({});
const [text, setText] = useState('');
const [multi, setMulti] = useState<string[]>([]);
const [erro, setErro] = useState('');
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
// passo atual = primeiro passo relevante ainda não respondido
const current: StepKey = STEP_ORDER.find(k => isRelevant(k, answers) && !isAnswered(k, answers)) || 'revisao';
useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); }, [answers, current, phase, erro]);
useEffect(() => { setErro(''); setText(''); setMulti(answers.especialidades || []); }, [current]);
const setAns = (patch: Partial<Answers>) => setAnswers(a => ({ ...a, ...patch }));
// inicia o chat já com o perfil do slide preenchido (ou sem nada, para perguntar)
const iniciar = (pre?: Partial<Answers>) => { setAnswers(pre || {}); setPhase('chat'); };
const voltar = () => {
// remove o último passo relevante já respondido
const answered = STEP_ORDER.filter(k => isRelevant(k, answers) && isAnswered(k, answers));
const last = answered[answered.length - 1];
if (!last) { setPhase('intro'); return; }
setAnswers(a => { const n = { ...a }; delete (n as any)[last]; return n; });
};
const finalizar = async () => {
setLoading(true); setErro('');
const role = answers.perfil === 'clinica' ? 'donoclinica'
: answers.perfil === 'consultorio' ? 'donoconsultorio'
: answers.perfil === 'paciente' ? 'paciente'
: answers.perfil === 'sala' ? 'donosala'
: (answers.profissao || 'dentista');
try {
const res = await fetch(`${API_URL}/api/register`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nome: answers.nome, email: answers.email, senha: answers.senha, role,
workspaceNome: answers.nomeEstab || undefined,
especialidade: answers.especialidades && answers.especialidades.length ? answers.especialidades : undefined,
}),
});
const data = await res.json().catch(() => ({} as any));
if (!res.ok || !data.success) { setErro(data.message || 'Erro ao criar conta.'); setLoading(false); return; }
localStorage.setItem('SCOREODONTO_AUTH_TOKEN', data.token);
localStorage.setItem('SCOREODONTO_USER_DATA', JSON.stringify(data.user));
localStorage.setItem('SCOREODONTO_WORKSPACES', JSON.stringify(data.workspaces || []));
if (data.workspaces?.length) localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(data.workspaces[0]));
setDone(true);
setTimeout(() => onDone(data.noWorkspace ? { noWorkspace: true, userRole: data.userRole } : undefined), 1300);
} catch { setErro('Erro de conexão. Tente novamente.'); setLoading(false); }
};
// ── INTRO (carrossel) ─────────────────────────────────────────────────────
if (phase === 'intro') {
const S = SLIDES[slide];
const Icon = S.icon;
return (
<div className="flex flex-col" style={{ height: 'clamp(320px, calc(100dvh - 240px), 560px)' }}>
<style>{`.ob-noscroll{scrollbar-width:none;-ms-overflow-style:none;}.ob-noscroll::-webkit-scrollbar{display:none;width:0;height:0;}`}</style>
<button onClick={onBackToLogin} className="self-start flex items-center gap-1 text-[11px] font-black uppercase text-gray-400 hover:text-gray-600 mb-2 shrink-0">
<ChevronLeft size={15} /> tenho conta
</button>
<div className="flex-1 min-h-0 overflow-y-auto ob-noscroll flex flex-col items-center justify-center text-center px-2">
<div className="w-16 h-16 sm:w-24 sm:h-24 rounded-[1.5rem] sm:rounded-[2rem] flex items-center justify-center mb-4 sm:mb-6 shrink-0 transition-all duration-300" style={{ backgroundColor: S.accent + '14', color: S.accent }}>
<Icon className="w-8 h-8 sm:w-11 sm:h-11" />
</div>
<h2 className="text-xl sm:text-2xl font-black uppercase tracking-tight text-gray-900">{S.title}</h2>
<p className="text-sm text-gray-500 mt-2 sm:mt-3 max-w-xs leading-relaxed">{S.desc}</p>
<div className="flex gap-1.5 mt-4 sm:mt-7">
{SLIDES.map((_, i) => (
<button key={i} onClick={() => setSlide(i)} className="h-1.5 rounded-full transition-all" style={{ width: i === slide ? 22 : 7, backgroundColor: i === slide ? S.accent : '#e5e7eb' }} />
))}
</div>
</div>
<div className="shrink-0 space-y-2 mt-4">
{/* navegação entre os slides */}
<div className="flex items-center gap-2">
<button onClick={() => setSlide(s => Math.max(0, s - 1))} disabled={slide === 0}
className="p-3 rounded-2xl border-2 border-gray-100 text-gray-500 disabled:opacity-30 hover:border-gray-200 transition-all">
<ArrowLeft size={18} />
</button>
<button onClick={() => setSlide(s => Math.min(SLIDES.length - 1, s + 1))} disabled={slide === SLIDES.length - 1}
className="flex-1 py-3 rounded-2xl border-2 border-gray-100 text-gray-500 text-xs font-black uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-30 hover:border-gray-200 transition-all">
Avançar <ArrowRight size={16} />
</button>
</div>
{/* CTA do slide atual → pré-seleciona o perfil e inicia o chat */}
<button onClick={() => iniciar(S.pre)}
className="w-full py-3.5 rounded-2xl text-white text-xs font-black uppercase tracking-widest flex items-center justify-center gap-2 shadow-lg transition-all hover:brightness-110"
style={{ backgroundColor: S.accent, boxShadow: `0 10px 20px -8px ${S.accent}99` }}>
{S.cta} <ArrowRight size={18} />
</button>
</div>
</div>
);
}
// ── CHAT ──────────────────────────────────────────────────────────────────
// monta as bolhas a partir do histórico respondido
const bubbles: { from: 'bot' | 'user'; text: string }[] = [];
bubbles.push({ from: 'bot', text: 'Oi! Vou te ajudar a criar sua conta em 1 minuto. 😊' });
const pushQA = (botText: string, userText?: string) => {
bubbles.push({ from: 'bot', text: botText });
if (userText) bubbles.push({ from: 'user', text: userText });
};
if (answers.perfil) pushQA('Para começar, o que você é?', PERFIL_LABEL[answers.perfil]);
if (answers.profissao) pushQA('Qual a sua profissão?', PROF_LABEL[answers.profissao]);
if (Array.isArray(answers.especialidades)) pushQA('Quais áreas você atua? (opcional)', answers.especialidades.length ? answers.especialidades.join(', ') : 'Pular');
if (answers.nome) pushQA('Como é o seu nome completo?', answers.nome);
if (answers.nomeEstab) pushQA('Qual o nome do estabelecimento?', answers.nomeEstab);
if (answers.email) pushQA('Seu melhor e-mail?', answers.email);
if (answers.senha) pushQA('Crie uma senha (mín. 6 caracteres).', '••••••');
const stepPrompt: Record<StepKey, string> = {
perfil: 'Para começar, o que você é?',
profissao: 'Qual a sua profissão?',
especialidades: 'Quais áreas você atua? (opcional)',
nome: 'Como é o seu nome completo?',
nomeEstab: answers.perfil === 'clinica' ? 'Qual o nome da clínica?' : 'Qual o nome do consultório?',
email: 'Seu melhor e-mail?',
senha: 'Crie uma senha (mín. 6 caracteres).',
revisao: 'Tudo certo? É só confirmar para criar sua conta.',
};
const Chip: React.FC<{ active?: boolean; onClick: () => void; children: React.ReactNode }> = ({ active, onClick, children }) => (
<button onClick={onClick}
className={`px-3.5 py-2.5 rounded-2xl text-xs font-bold uppercase border-2 transition-all ${active ? 'bg-teal-600 text-white border-teal-600' : 'bg-white text-gray-600 border-gray-200 hover:border-teal-300'}`}>
{children}
</button>
);
return (
<div className="flex flex-col" style={{ height: 'clamp(320px, calc(100dvh - 240px), 560px)' }}>
{/* header */}
<div className="flex items-center gap-2.5 pb-3 border-b border-gray-100 shrink-0">
<button onClick={voltar} className="p-2 -ml-2 text-gray-400 hover:text-gray-700 rounded-lg"><ChevronLeft size={20} /></button>
<div className="w-9 h-9 rounded-xl bg-teal-600 text-white flex items-center justify-center font-black text-sm">S</div>
<div className="min-w-0">
<p className="text-sm font-black uppercase tracking-tight text-gray-800 leading-none">Criar conta</p>
<p className="text-[10px] text-gray-400 font-bold uppercase">ScoreOdonto</p>
</div>
</div>
{/* mensagens */}
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto custom-scrollbar py-4 space-y-2.5">
{bubbles.map((b, i) => (
<div key={i} className={`flex ${b.from === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[80%] px-3.5 py-2.5 text-sm leading-snug ${b.from === 'user'
? 'bg-teal-600 text-white rounded-2xl rounded-br-md'
: 'bg-gray-100 text-gray-700 rounded-2xl rounded-bl-md'}`}>
{b.text}
</div>
</div>
))}
{!done && (
<div className="flex justify-start">
<div className="max-w-[85%] px-3.5 py-2.5 text-sm leading-snug bg-gray-100 text-gray-700 rounded-2xl rounded-bl-md font-medium">
{stepPrompt[current]}
</div>
</div>
)}
{done && (
<div className="flex flex-col items-center justify-center py-8 gap-3">
<div className="w-14 h-14 bg-green-50 rounded-2xl flex items-center justify-center"><CheckCircle2 size={30} className="text-green-500" /></div>
<p className="font-black uppercase text-gray-800">Conta criada!</p>
<Loader2 size={18} className="text-teal-600 animate-spin" />
</div>
)}
{erro && <div className="flex justify-start"><div className="px-3.5 py-2 text-xs font-bold text-red-600 bg-red-50 rounded-xl">{erro}</div></div>}
</div>
{/* entrada por passo */}
{!done && (
<div className="pt-3 border-t border-gray-100 shrink-0">
{current === 'perfil' && (
<div className="grid grid-cols-2 gap-2">
{(['profissional', 'clinica', 'consultorio', 'sala', 'paciente'] as Perfil[]).map(p => (
<Chip key={p} onClick={() => setAns({ perfil: p })}>{PERFIL_LABEL[p]}</Chip>
))}
</div>
)}
{current === 'profissao' && (
<div className="grid grid-cols-3 gap-2">
{(['dentista', 'biomedico', 'protetico'] as Profissao[]).map(p => (
<Chip key={p} onClick={() => setAns({ profissao: p })}>{PROF_LABEL[p]}</Chip>
))}
</div>
)}
{current === 'especialidades' && (
<div className="space-y-3">
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto custom-scrollbar">
{ESPECIALIDADES[answers.profissao || 'dentista'].map(e => (
<Chip key={e} active={multi.includes(e)} onClick={() => setMulti(m => m.includes(e) ? m.filter(x => x !== e) : [...m, e])}>{e}</Chip>
))}
</div>
<div className="flex gap-2">
<button onClick={() => setAns({ especialidades: [] })} className="flex-1 py-3 rounded-2xl border-2 border-gray-200 text-gray-500 text-xs font-black uppercase hover:border-gray-300">Pular</button>
<button onClick={() => setAns({ especialidades: multi })} className="flex-[1.5] py-3 rounded-2xl bg-teal-600 text-white text-xs font-black uppercase flex items-center justify-center gap-2 hover:bg-teal-700"><Check size={16} /> Confirmar</button>
</div>
</div>
)}
{(current === 'nome' || current === 'nomeEstab' || current === 'email' || current === 'senha') && (
<form onSubmit={(e) => {
e.preventDefault();
const v = text.trim();
if (current === 'nome') { if (v.length < 2) { setErro('Digite seu nome.'); return; } setAns({ nome: v }); }
else if (current === 'nomeEstab') { if (v.length < 2) { setErro('Digite o nome.'); return; } setAns({ nomeEstab: v }); }
else if (current === 'email') { if (!emailOk(v)) { setErro('E-mail inválido.'); return; } setAns({ email: v.toLowerCase() }); }
else if (current === 'senha') { if (v.length < 6) { setErro('Mínimo de 6 caracteres.'); return; } setAns({ senha: v }); }
}} className="flex items-center gap-2">
<input
autoFocus
type={current === 'email' ? 'email' : current === 'senha' ? 'password' : 'text'}
value={text}
onChange={e => setText(current === 'nome' || current === 'nomeEstab' ? e.target.value.toUpperCase() : e.target.value)}
placeholder={current === 'nome' ? 'Seu nome completo' : current === 'nomeEstab' ? 'Nome do estabelecimento' : current === 'email' ? 'seu@email.com' : '••••••'}
className="flex-1 px-4 py-3.5 bg-gray-50 border border-gray-200 rounded-2xl text-sm font-medium outline-none focus:border-teal-500 focus:bg-white focus:ring-2 focus:ring-teal-100"
/>
<button type="submit" className="p-3.5 rounded-2xl bg-teal-600 text-white hover:bg-teal-700"><Send size={18} /></button>
</form>
)}
{current === 'revisao' && (
<button onClick={finalizar} disabled={loading}
className="w-full py-4 rounded-2xl bg-teal-600 text-white text-sm font-black uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-teal-700 disabled:opacity-60 shadow-lg shadow-teal-200">
{loading ? <><Loader2 size={18} className="animate-spin" /> Criando...</> : <>Criar minha conta <ArrowRight size={18} /></>}
</button>
)}
</div>
)}
</div>
);
};
export default OnboardingChat;
export { OnboardingChat };