Files
VPS 4 Builder ba062e92d0 fix(login): render standalone views edge-to-edge e esconde scrollbar do carrossel de onboarding
- App.tsx: wrappers de padding (p-4/md:p-8) e max-w-7xl/mx-auto agora só se aplicam quando não é tela standalone; login/landing/public renderizam full-bleed
- OnboardingChat.tsx: classe ob-noscroll esconde a barra de rolagem do slide intro (mantém scroll), substituindo a custom-scrollbar indefinida no fluxo de login

Inclui também demais alterações pendentes do working tree (snapshot do estado atual de dev).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:23:19 +02:00

204 lines
9.3 KiB
TypeScript

import React, { useRef, useState, useEffect, useCallback } from 'react';
import { Camera, X, Zap, ZapOff, Image as ImageIcon, RotateCcw, Loader2, AlertTriangle } from 'lucide-react';
interface Props {
onCapture: (file: File) => void;
onClose: () => void;
title?: string;
allowGallery?: boolean;
}
// Resolução baixa só para análise de movimento/reflexo (leve no celular).
const AW = 80, AH = 60;
/**
* Captura por câmera no NAVEGADOR (getUserMedia). Recursos:
* - Câmera traseira ao vivo, guia de enquadramento.
* - Auto-disparo quando a imagem fica ESTÁVEL (anti-tremor) — opcional (botão AUTO).
* - Detecção de REFLEXO (estouro de luz) avisando para reposicionar.
* - Lanterna (torch) onde o navegador suporta (Android/Chrome).
* - Fallback para galeria/arquivo.
* Observação: requer HTTPS (dev.scoreodonto.com já é).
*/
export const CameraCapture: React.FC<Props> = ({ onCapture, onClose, title, allowGallery = true }) => {
const videoRef = useRef<HTMLVideoElement>(null);
const captureCanvas = useRef<HTMLCanvasElement | null>(null);
const analysisCanvas = useRef<HTMLCanvasElement | null>(null);
const prevGray = useRef<Float32Array | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const rafRef = useRef<number>(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const stableCountRef = useRef(0);
const capturedRef = useRef(false);
const [facing, setFacing] = useState<'environment' | 'user'>('environment');
const [error, setError] = useState('');
const [ready, setReady] = useState(false);
const [torchSupported, setTorchSupported] = useState(false);
const [torchOn, setTorchOn] = useState(false);
const [auto, setAuto] = useState(true);
const [hint, setHint] = useState('INICIANDO CÂMERA…');
const [steady, setSteady] = useState(false);
const cvs = (r: React.MutableRefObject<HTMLCanvasElement | null>) => (r.current ??= document.createElement('canvas'));
const stop = useCallback(() => {
cancelAnimationFrame(rafRef.current);
streamRef.current?.getTracks().forEach(t => t.stop());
streamRef.current = null;
}, []);
const start = useCallback(async (mode: 'environment' | 'user') => {
setError(''); setReady(false); prevGray.current = null; stableCountRef.current = 0; capturedRef.current = false;
stop();
if (!navigator.mediaDevices?.getUserMedia) { setError('CÂMERA NÃO SUPORTADA NESTE NAVEGADOR.'); return; }
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: mode }, width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: false,
});
streamRef.current = stream;
const v = videoRef.current;
if (v) { v.srcObject = stream; await v.play().catch(() => {}); }
const track = stream.getVideoTracks()[0];
const caps: any = track.getCapabilities ? track.getCapabilities() : {};
setTorchSupported(!!caps.torch);
setTorchOn(false);
setReady(true);
setHint('ENQUADRE E SEGURE FIRME');
} catch (e: any) {
setError(
e?.name === 'NotAllowedError' ? 'PERMISSÃO DE CÂMERA NEGADA.' :
e?.name === 'NotFoundError' ? 'NENHUMA CÂMERA ENCONTRADA.' :
'NÃO FOI POSSÍVEL ABRIR A CÂMERA.'
);
}
}, [stop]);
useEffect(() => { start(facing); return stop; /* eslint-disable-next-line */ }, []);
const toGray = (v: HTMLVideoElement): Float32Array => {
const c = cvs(analysisCanvas); c.width = AW; c.height = AH;
const ctx = c.getContext('2d', { willReadFrequently: true })!;
ctx.drawImage(v, 0, 0, AW, AH);
const d = ctx.getImageData(0, 0, AW, AH).data;
const g = new Float32Array(AW * AH);
for (let i = 0, j = 0; i < d.length; i += 4, j++) g[j] = d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114;
return g;
};
const capture = useCallback(() => {
if (capturedRef.current) return;
const v = videoRef.current;
if (!v || !v.videoWidth) return;
capturedRef.current = true;
const c = cvs(captureCanvas); c.width = v.videoWidth; c.height = v.videoHeight;
c.getContext('2d')!.drawImage(v, 0, 0);
c.toBlob(blob => {
if (!blob) { capturedRef.current = false; return; }
onCapture(new File([blob], `foto_${Date.now()}.jpg`, { type: 'image/jpeg' }));
stop(); onClose();
}, 'image/jpeg', 0.92);
}, [onCapture, onClose, stop]);
// Loop de análise: estabilidade + reflexo + auto-disparo
useEffect(() => {
if (!ready) return;
let last = 0;
const loop = (ts: number) => {
rafRef.current = requestAnimationFrame(loop);
if (ts - last < 120) return;
last = ts;
const v = videoRef.current;
if (!v || !v.videoWidth || capturedRef.current) return;
const g = toGray(v);
let bright = 0;
for (let i = 0; i < g.length; i++) if (g[i] > 245) bright++;
const glare = bright / g.length;
let motion = 999;
if (prevGray.current) {
let sum = 0;
for (let i = 0; i < g.length; i++) sum += Math.abs(g[i] - prevGray.current[i]);
motion = sum / g.length;
}
prevGray.current = g;
const isSteady = motion < 5;
setSteady(isSteady && glare <= 0.18);
if (glare > 0.18) { setHint('REFLEXO DETECTADO — INCLINE OU AFASTE A LUZ'); stableCountRef.current = 0; return; }
if (!isSteady) { setHint('SEGURE FIRME…'); stableCountRef.current = 0; return; }
stableCountRef.current++;
setHint(auto ? 'ESTÁVEL — CAPTURANDO…' : 'ESTÁVEL — TOQUE NO BOTÃO');
if (auto && stableCountRef.current >= 4) capture();
};
rafRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafRef.current);
}, [ready, auto, capture]);
const toggleTorch = async () => {
const track = streamRef.current?.getVideoTracks()[0];
if (!track) return;
try { await track.applyConstraints({ advanced: [{ torch: !torchOn }] as any }); setTorchOn(t => !t); } catch { /* ignore */ }
};
const onPickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) { stop(); onCapture(f); onClose(); }
};
return (
<div className="fixed inset-0 bg-black z-[90] flex flex-col select-none">
<div className="flex items-center justify-between px-4 py-3 text-white flex-shrink-0">
<span className="text-xs font-black uppercase truncate">{title || 'CÂMERA'}</span>
<button onClick={() => { stop(); onClose(); }} className="p-2 hover:bg-white/10 rounded-lg"><X size={20} /></button>
</div>
<div className="flex-1 relative min-h-0 flex items-center justify-center overflow-hidden">
{error ? (
<div className="text-center text-white/80 px-6">
<AlertTriangle size={36} className="mx-auto mb-3 text-amber-400" />
<p className="text-sm font-bold uppercase">{error}</p>
{allowGallery && (
<button onClick={() => fileInputRef.current?.click()} className="mt-4 px-4 py-2 bg-white/10 rounded-xl text-xs font-black uppercase">
Usar galeria
</button>
)}
</div>
) : (
<>
<video ref={videoRef} playsInline muted className="max-w-full max-h-full object-contain" />
{!ready && <Loader2 size={32} className="absolute animate-spin text-white/60" />}
{ready && <div className={`absolute inset-6 border-2 rounded-2xl pointer-events-none transition-colors duration-200 ${steady ? 'border-emerald-400' : 'border-white/40'}`} />}
{ready && (
<div className={`absolute bottom-4 left-1/2 -translate-x-1/2 text-[11px] font-black uppercase px-3 py-1.5 rounded-full ${steady ? 'bg-emerald-500 text-white' : 'bg-black/60 text-white'}`}>
{hint}
</div>
)}
</>
)}
</div>
<div className="flex items-center justify-between px-6 py-5 gap-3 flex-shrink-0">
<div className="flex gap-2 w-24">
{allowGallery && (
<button onClick={() => fileInputRef.current?.click()} className="p-3 bg-white/10 text-white rounded-full" title="Galeria"><ImageIcon size={20} /></button>
)}
{torchSupported && (
<button onClick={toggleTorch} className={`p-3 rounded-full ${torchOn ? 'bg-amber-400 text-black' : 'bg-white/10 text-white'}`} title="Lanterna">
{torchOn ? <Zap size={20} /> : <ZapOff size={20} />}
</button>
)}
</div>
<button onClick={capture} disabled={!ready} className="w-16 h-16 rounded-full bg-white border-4 border-white/40 disabled:opacity-40 flex items-center justify-center active:scale-95 transition-transform">
<Camera size={26} className="text-gray-800" />
</button>
<div className="flex gap-2 w-24 justify-end">
<button onClick={() => setAuto(a => !a)} className={`px-3 py-2 rounded-full text-[10px] font-black uppercase ${auto ? 'bg-emerald-500 text-white' : 'bg-white/10 text-white'}`} title="Auto-disparo quando firme">AUTO</button>
<button onClick={() => { const m = facing === 'environment' ? 'user' : 'environment'; setFacing(m); start(m); }} className="p-3 bg-white/10 text-white rounded-full" title="Trocar câmera"><RotateCcw size={20} /></button>
</div>
</div>
{allowGallery && <input ref={fileInputRef} type="file" accept="image/*" capture="environment" className="hidden" onChange={onPickFile} />}
</div>
);
};