a991f1fe2c
- buildInfo.ts: hook useAppVersion() consome GET /api/version (fonte única = backend),
com fallback ao carimbo do bundle; deixa de depender do APP_VERSION manual.
- Versão exibida em Configurações ("Sobre o sistema") + telas públicas (Login/WaitingInvite);
removida do rodapé do Sidebar.
- compose.prod/deploy-prod.sh: injetam APP_VERSION (=tag) e APP_ENV=PROD em runtime,
desacoplando a versão semântica do build (commit/build seguem da imagem).
- constants.ts marcado legado + bump V1.0.15 (transitório até o Passo 5; evita colisão de tag).
- DEPLOY-PRODUCAO.md reescrito com status ✅/🧪/🎯 + nota de fase transitória.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
348 lines
24 KiB
TypeScript
348 lines
24 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Mail, Lock, ArrowRight, ShieldCheck, Loader2, Eye, EyeOff, CheckCircle2,
|
|
Stethoscope, Calendar, BarChart3, ChevronLeft, KeyRound, RotateCcw,
|
|
UserSearch, DoorOpen, ScanLine, Sparkles, MapPin, Building2, Users, FlaskConical
|
|
} from 'lucide-react';
|
|
import { HybridBackend } from '../services/backend.ts';
|
|
import { useAppVersion } from '../buildInfo.ts';
|
|
import { OnboardingChat } from './OnboardingChat.tsx';
|
|
|
|
interface LoginViewProps {
|
|
onLoginSuccess: (result?: { noWorkspace?: boolean; userRole?: string }) => void;
|
|
}
|
|
|
|
const API_URL = (window as any).__API_URL__ || '';
|
|
|
|
// Funcionalidades atuais da plataforma (destaque no painel da esquerda).
|
|
const FEATURES = [
|
|
{ icon: UserSearch, label: 'Marketplace de Profissionais', desc: 'Encontre dentistas, biomédicos e protéticos por proximidade' },
|
|
{ icon: DoorOpen, label: 'Locação de Salas', desc: 'Alugue ou disponibilize salas com reserva anti-conflito' },
|
|
{ icon: Calendar, label: 'Agenda Anti-overbooking', desc: 'Horários do profissional sincronizados entre unidades' },
|
|
{ icon: BarChart3, label: 'Financeiro & GTO', desc: 'Faturamento, guias e orçamentos num lugar só' },
|
|
{ icon: ScanLine, label: 'RX / Radiografias', desc: 'Imagens integradas ao prontuário do paciente' },
|
|
{ icon: MapPin, label: 'Busca por Proximidade', desc: 'Profissionais e salas mais perto de você primeiro' },
|
|
];
|
|
|
|
// Perfis suportados (chips informativos — o cadastro real é guiado no chat).
|
|
const ROLE_CHIPS = [
|
|
{ icon: Building2, label: 'Clínica' },
|
|
{ icon: Stethoscope, label: 'Consultório' },
|
|
{ icon: Stethoscope, label: 'Dentista' },
|
|
{ icon: Sparkles, label: 'Biomédico(a)' },
|
|
{ icon: FlaskConical, label: 'Protético' },
|
|
{ icon: Users, label: 'Paciente' },
|
|
];
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
|
|
<div className="relative">
|
|
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5 ml-1">{label}</label>
|
|
<div className="relative">{children}</div>
|
|
</div>
|
|
);
|
|
const InputIcon: React.FC<{ icon: React.ReactNode }> = ({ icon }) => (
|
|
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none z-10">{icon}</span>
|
|
);
|
|
const EyeToggle: React.FC<{ show: boolean; onToggle: () => void }> = ({ show, onToggle }) => (
|
|
<button type="button" onClick={onToggle}
|
|
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors z-10">
|
|
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
</button>
|
|
);
|
|
const ErrorBanner: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
<div className="p-3 bg-red-50 border border-red-100 rounded-xl text-red-600 text-xs font-bold text-center uppercase tracking-wide">{children}</div>
|
|
);
|
|
const InputCls = 'w-full pl-10 pr-4 py-3.5 bg-gray-50 border border-gray-200 rounded-xl text-sm font-medium text-gray-900 placeholder:text-gray-400 outline-none focus:border-teal-500 focus:bg-white focus:ring-2 focus:ring-teal-100 transition-all';
|
|
|
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|
export const LoginView: React.FC<LoginViewProps> = ({ onLoginSuccess }) => {
|
|
const ver = useAppVersion(); // versão oficial via /api/version (público), fallback bundle
|
|
const [tab, setTab] = useState<'login' | 'cadastro'>('login');
|
|
const [authMode, setAuthMode] = useState<'login' | 'forgot' | 'reset'>('login');
|
|
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [showPass, setShowPass] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [success, setSuccess] = useState('');
|
|
const [initializing, setInit] = useState(true);
|
|
const [leaving, setLeaving] = useState(false);
|
|
|
|
// forgot/reset
|
|
const [forgotEmail, setForgotEmail] = useState('');
|
|
const [resetToken, setResetToken] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [confirmNew, setConfirmNew] = useState('');
|
|
const [showNewPass, setShowNewPass] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const t = setTimeout(() => setInit(false), 1200);
|
|
const params = new URLSearchParams(window.location.search);
|
|
const token = params.get('reset_token');
|
|
if (token) { setResetToken(token); setAuthMode('reset'); }
|
|
return () => clearTimeout(t);
|
|
}, []);
|
|
|
|
const switchTab = (next: 'login' | 'cadastro') => {
|
|
if (next === tab || leaving) return;
|
|
setLeaving(true); setError(''); setSuccess('');
|
|
setTimeout(() => { setTab(next); setLeaving(false); }, 200);
|
|
};
|
|
|
|
const handleLogin = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setLoading(true); setError('');
|
|
try {
|
|
const result = await HybridBackend.login(email, password);
|
|
if (result.ok) onLoginSuccess(result.noWorkspace ? { noWorkspace: true, userRole: result.userRole } : undefined);
|
|
else setError('Credenciais inválidas. Verifique e-mail e senha.');
|
|
} catch { setError('Erro de conexão. Tente novamente.'); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
const handleForgotPassword = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setLoading(true); setError(''); setSuccess('');
|
|
try {
|
|
const res = await fetch(`${API_URL}/api/auth/forgot-password`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: forgotEmail }),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) setSuccess(data.message || 'Instruções enviadas para o seu e-mail.');
|
|
else setError(data.error || 'Erro ao processar solicitação.');
|
|
} catch { setError('Erro de conexão. Tente novamente.'); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
const handleResetPassword = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (newPassword !== confirmNew) { setError('As senhas não coincidem.'); return; }
|
|
if (newPassword.length < 6) { setError('A senha deve ter no mínimo 6 caracteres.'); return; }
|
|
setLoading(true); setError(''); setSuccess('');
|
|
try {
|
|
const res = await fetch(`${API_URL}/api/auth/reset-password`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token: resetToken, novaSenha: newPassword }),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok && data.success) {
|
|
setSuccess('Senha redefinida! Redirecionando para o login...');
|
|
window.history.replaceState({}, '', window.location.pathname);
|
|
setTimeout(() => { setAuthMode('login'); setSuccess(''); setNewPassword(''); setConfirmNew(''); }, 2200);
|
|
} else setError(data.error || 'Link inválido ou expirado. Solicite um novo.');
|
|
} catch { setError('Erro de conexão. Tente novamente.'); }
|
|
finally { setLoading(false); }
|
|
};
|
|
|
|
/* ─── Splash ─── */
|
|
if (initializing) {
|
|
return (
|
|
<div className="h-dvh bg-[#04201d] flex flex-col items-center justify-center gap-5">
|
|
<div className="w-16 h-16 bg-gradient-to-tr from-teal-500 to-emerald-500 rounded-2xl flex items-center justify-center shadow-xl shadow-teal-900/40">
|
|
<Stethoscope className="text-white" size={30} />
|
|
</div>
|
|
<p className="text-2xl font-black text-white tracking-tighter uppercase italic">Score<span className="text-teal-400">Odonto</span></p>
|
|
<div className="flex gap-1.5">
|
|
{[0, 1, 2].map(i => <div key={i} className="w-2 h-2 rounded-full bg-teal-400" style={{ animation: `b 1.2s ease-in-out ${i * 0.2}s infinite` }} />)}
|
|
</div>
|
|
<style>{`@keyframes b{0%,80%,100%{transform:translateY(0);opacity:.5}40%{transform:translateY(-8px);opacity:1}}`}</style>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─── Layout ─── */
|
|
return (
|
|
<div className="h-dvh overflow-hidden bg-white flex flex-col lg:flex-row">
|
|
<style>{`
|
|
@keyframes fadeUp { from{opacity:0;transform:translateY(10px)} to{opacity:1;transform:translateY(0)} }
|
|
@keyframes fadeOut{ from{opacity:1;transform:translateY(0)} to{opacity:0;transform:translateY(-6px)} }
|
|
@keyframes spin { to{transform:rotate(360deg)} }
|
|
@keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-10px)} }
|
|
.fe{animation:fadeUp .3s ease forwards}.fl{animation:fadeOut .2s ease forwards}.sp{animation:spin 1s linear infinite}
|
|
`}</style>
|
|
|
|
{/* ── LEFT — showcase ── */}
|
|
<div className="hidden lg:flex flex-col justify-between flex-1 h-full relative overflow-hidden p-12 xl:p-16
|
|
bg-gradient-to-br from-[#04201d] via-[#065f54] to-[#0d9488] text-white">
|
|
<div className="absolute -top-24 -right-24 w-96 h-96 bg-teal-500/20 rounded-full blur-3xl pointer-events-none" style={{ animation: 'float 8s ease-in-out infinite' }} />
|
|
<div className="absolute -bottom-24 -left-24 w-80 h-80 bg-emerald-500/20 rounded-full blur-3xl pointer-events-none" />
|
|
|
|
{/* Logo */}
|
|
<div className="flex items-center gap-3 relative z-10">
|
|
<div className="w-11 h-11 bg-white/10 backdrop-blur-sm rounded-xl flex items-center justify-center border border-white/20 shadow-lg">
|
|
<Stethoscope className="text-white" size={22} />
|
|
</div>
|
|
<div>
|
|
<p className="font-black text-xl tracking-tighter uppercase italic leading-none">Score<span className="text-teal-300">Odonto</span></p>
|
|
<p className="text-teal-300/70 text-[10px] font-black uppercase tracking-widest">Plataforma Odontológica</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Headline + features */}
|
|
<div className="relative z-10 space-y-7">
|
|
<div>
|
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest bg-teal-500/20 text-teal-200 border border-teal-400/30 px-3 py-1 rounded-full mb-4">
|
|
<Sparkles size={11} /> Novo: marketplace + locação de salas
|
|
</span>
|
|
<h2 className="text-4xl xl:text-5xl font-black tracking-tighter leading-[1.05]">
|
|
Gestão, conexão e<br /><span className="text-teal-300 italic">crescimento</span> da sua prática
|
|
</h2>
|
|
<p className="text-teal-100/70 text-base font-medium leading-relaxed max-w-md mt-4">
|
|
Do prontuário ao marketplace: encontre profissionais e salas por proximidade, gerencie a agenda sem choques e o financeiro num só lugar.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{FEATURES.map(({ icon: Icon, label, desc }) => (
|
|
<div key={label} className="bg-white/5 backdrop-blur-sm rounded-2xl p-4 border border-white/10 hover:bg-white/10 transition-colors">
|
|
<Icon size={20} className="text-teal-300 mb-2" />
|
|
<p className="font-black text-xs uppercase tracking-tight leading-none mb-1">{label}</p>
|
|
<p className="text-teal-200/60 text-[10px] font-medium leading-snug">{desc}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
{ROLE_CHIPS.map(({ icon: Icon, label }) => (
|
|
<span key={label} className="inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wide bg-white/5 border border-white/10 text-teal-100/80 px-2.5 py-1 rounded-lg">
|
|
<Icon size={12} className="text-teal-300" /> {label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="relative z-10 flex items-center gap-2 text-teal-300/50 text-[10px] font-black uppercase tracking-widest">
|
|
<ShieldCheck size={12} /> Dados criptografados · LGPD · {ver.version}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── RIGHT — auth ── */}
|
|
<div className="flex-1 flex flex-col items-center justify-center p-6 lg:p-12 overflow-y-auto">
|
|
{/* Mobile logo */}
|
|
<div className="lg:hidden flex items-center gap-2 mb-8">
|
|
<div className="w-9 h-9 bg-gradient-to-tr from-teal-600 to-emerald-600 rounded-xl flex items-center justify-center shadow-md shadow-teal-200">
|
|
<Stethoscope size={18} className="text-white" />
|
|
</div>
|
|
<span className="text-xl font-black text-gray-900 tracking-tighter uppercase italic">Score<span className="text-teal-600">Odonto</span></span>
|
|
</div>
|
|
|
|
<div className="w-full max-w-md">
|
|
{authMode === 'reset' ? (
|
|
<div className="bg-white rounded-[2rem] shadow-[0_40px_80px_-20px_rgba(0,0,0,0.12)] border border-gray-100 overflow-hidden">
|
|
<div className="h-1.5 bg-gradient-to-r from-teal-500 to-emerald-500" />
|
|
<div className="p-8">
|
|
<button onClick={() => setAuthMode('login')} className="flex items-center gap-1.5 text-xs font-black text-gray-400 hover:text-gray-600 uppercase tracking-widest mb-6 transition-colors">
|
|
<ChevronLeft size={14} /> Voltar ao login
|
|
</button>
|
|
<div className="text-center mb-6">
|
|
<div className="w-12 h-12 bg-teal-50 rounded-2xl flex items-center justify-center mx-auto mb-3"><KeyRound size={22} className="text-teal-600" /></div>
|
|
<h2 className="text-xl font-black text-gray-900 uppercase tracking-tight">Nova Senha</h2>
|
|
<p className="text-xs text-gray-500 font-medium mt-1">Defina sua nova senha de acesso</p>
|
|
</div>
|
|
<form onSubmit={handleResetPassword} className="space-y-4">
|
|
<Field label="Nova Senha">
|
|
<InputIcon icon={<Lock size={16} />} />
|
|
<input type={showNewPass ? 'text' : 'password'} required value={newPassword} onChange={e => setNewPassword(e.target.value)} className={InputCls} placeholder="••••••" autoComplete="new-password" />
|
|
<EyeToggle show={showNewPass} onToggle={() => setShowNewPass(p => !p)} />
|
|
</Field>
|
|
<Field label="Confirmar Senha">
|
|
<InputIcon icon={<Lock size={16} />} />
|
|
<input type="password" required value={confirmNew} onChange={e => setConfirmNew(e.target.value)} className={InputCls} placeholder="••••••" autoComplete="new-password" />
|
|
</Field>
|
|
{error && <ErrorBanner>{error}</ErrorBanner>}
|
|
{success && <div className="p-3 bg-green-50 border border-green-100 rounded-xl text-green-700 text-xs font-bold text-center uppercase">{success}</div>}
|
|
<button type="submit" disabled={loading} className="w-full bg-teal-600 hover:bg-teal-700 disabled:opacity-60 text-white py-4 rounded-xl text-sm font-black uppercase tracking-widest shadow-lg shadow-teal-200 transition-all flex items-center justify-center gap-2">
|
|
{loading ? <><Loader2 size={18} className="sp" /> Salvando...</> : <>Redefinir Senha <ArrowRight size={18} /></>}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
) : authMode === 'forgot' ? (
|
|
<div className="bg-white rounded-[2rem] shadow-[0_40px_80px_-20px_rgba(0,0,0,0.12)] border border-gray-100 overflow-hidden">
|
|
<div className="h-1.5 bg-gradient-to-r from-teal-500 to-emerald-500" />
|
|
<div className="p-8">
|
|
<button onClick={() => { setAuthMode('login'); setError(''); setSuccess(''); }} className="flex items-center gap-1.5 text-xs font-black text-gray-400 hover:text-gray-600 uppercase tracking-widest mb-6 transition-colors">
|
|
<ChevronLeft size={14} /> Voltar ao login
|
|
</button>
|
|
<div className="text-center mb-6">
|
|
<div className="w-12 h-12 bg-teal-50 rounded-2xl flex items-center justify-center mx-auto mb-3"><RotateCcw size={22} className="text-teal-600" /></div>
|
|
<h2 className="text-xl font-black text-gray-900 uppercase tracking-tight">Recuperar Senha</h2>
|
|
<p className="text-xs text-gray-500 font-medium mt-1">Informe o e-mail cadastrado</p>
|
|
</div>
|
|
<form onSubmit={handleForgotPassword} className="space-y-4">
|
|
<Field label="E-mail Cadastrado">
|
|
<InputIcon icon={<Mail size={16} />} />
|
|
<input type="email" required value={forgotEmail} onChange={e => setForgotEmail(e.target.value)} className={InputCls} placeholder="seu@email.com" autoComplete="email" />
|
|
</Field>
|
|
{error && <ErrorBanner>{error}</ErrorBanner>}
|
|
{success && <div className="p-3 bg-green-50 border border-green-100 rounded-xl text-green-700 text-xs font-bold text-center uppercase">{success}</div>}
|
|
<button type="submit" disabled={loading} className="w-full bg-teal-600 hover:bg-teal-700 disabled:opacity-60 text-white py-4 rounded-xl text-sm font-black uppercase tracking-widest shadow-lg shadow-teal-200 transition-all flex items-center justify-center gap-2">
|
|
{loading ? <><Loader2 size={18} className="sp" /> Enviando...</> : <>Enviar Instruções <ArrowRight size={18} /></>}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="bg-white rounded-[2rem] shadow-[0_40px_80px_-20px_rgba(0,0,0,0.12)] border border-gray-100 overflow-hidden">
|
|
<div className="h-1.5 bg-gradient-to-r from-teal-500 to-emerald-500" />
|
|
<div className="flex border-b border-gray-100">
|
|
{(['login', 'cadastro'] as const).map(t => (
|
|
<button key={t} type="button" onClick={() => switchTab(t)}
|
|
className={`flex-1 py-4 text-xs font-black uppercase tracking-widest transition-all ${tab === t ? 'text-teal-600 border-b-2 border-teal-600' : 'text-gray-400 hover:text-gray-600'}`}>
|
|
{t === 'login' ? 'Entrar' : 'Criar Conta'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="p-8">
|
|
<div className={leaving ? 'fl' : 'fe'}>
|
|
{tab === 'cadastro' ? (
|
|
<OnboardingChat onDone={onLoginSuccess} onBackToLogin={() => switchTab('login')} />
|
|
) : success ? (
|
|
<div className="flex flex-col items-center gap-4 py-6">
|
|
<div className="w-16 h-16 bg-green-50 rounded-[1.5rem] flex items-center justify-center"><CheckCircle2 size={32} className="text-green-500" /></div>
|
|
<p className="text-gray-900 font-black text-lg uppercase tracking-tight">Bem-vindo!</p>
|
|
<p className="text-gray-500 text-sm">Redirecionando para o sistema...</p>
|
|
<Loader2 size={20} className="text-teal-600 sp" />
|
|
</div>
|
|
) : (
|
|
<form onSubmit={handleLogin} className="space-y-5">
|
|
<Field label="E-mail">
|
|
<InputIcon icon={<Mail size={16} />} />
|
|
<input type="email" required value={email} onChange={e => setEmail(e.target.value)} className={InputCls} placeholder="seu@email.com" autoComplete="email" autoFocus />
|
|
</Field>
|
|
<Field label="Senha">
|
|
<InputIcon icon={<Lock size={16} />} />
|
|
<input type={showPass ? 'text' : 'password'} required value={password} onChange={e => setPassword(e.target.value)} className={InputCls.replace('pr-4', 'pr-10')} placeholder="••••••" autoComplete="current-password" />
|
|
<EyeToggle show={showPass} onToggle={() => setShowPass(p => !p)} />
|
|
</Field>
|
|
{error && <ErrorBanner>{error}</ErrorBanner>}
|
|
<button type="submit" disabled={loading}
|
|
className="w-full bg-teal-600 hover:bg-teal-700 disabled:opacity-60 disabled:cursor-not-allowed text-white py-4 rounded-2xl text-sm font-black uppercase tracking-widest shadow-lg shadow-teal-200 hover:shadow-xl hover:shadow-teal-300 hover:-translate-y-0.5 active:translate-y-0 transition-all flex items-center justify-center gap-2">
|
|
{loading ? <><Loader2 size={18} className="sp" /> Verificando...</> : <>Entrar <ArrowRight size={18} /></>}
|
|
</button>
|
|
<div className="text-center">
|
|
<button type="button" onClick={() => { setAuthMode('forgot'); setError(''); setSuccess(''); }}
|
|
className="text-xs text-gray-400 hover:text-teal-600 font-bold uppercase tracking-widest transition-colors">
|
|
Esqueci minha senha
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="px-8 py-4 bg-gray-50 border-t border-gray-100 flex items-center justify-center gap-2">
|
|
<ShieldCheck size={12} className="text-gray-400" />
|
|
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Ambiente Seguro · {ver.version}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|