// Check-in com GPS + foto obrigatoria — orcamento 298, Fase 1. // // Ate aqui o check-in registrava so a HORA (migration 026): a promotora podia // dar check-in de casa. Este arquivo fecha isso — localizacao + foto tirada na // hora, com o raio validado no banco (migration 038). // // A validacao que VALE e a do Postgres. O que esta aqui e para a promotora ver // o problema antes de gastar dados enviando a foto, e para trocar o erro cru do // navegador por uma frase em portugues. // // Sequencia curta de proposito (secao "Diretriz principal" da especificacao): // consentimento LGPD (so na primeira vez) -> localizar -> foto -> pronto. // Nenhum campo de texto obrigatorio: cada campo a mais derruba a adesao da // equipe em campo. const GEO_TIMEOUT_MS = 20000; const GEO_PRECISAO_RUIM_M = 100; // acima disso o aparelho usa torre/wifi, nao GPS const GEO_ERROS = { 1: 'Você precisa permitir o acesso à localização para fazer o check-in. Abra os ajustes do navegador e libere a localização para este site.', 2: 'Não foi possível obter sua localização. Verifique se o GPS do aparelho está ligado.', 3: 'A localização demorou demais para responder. Vá para um lugar mais aberto e tente de novo.', }; function obterPosicao() { return new Promise((resolve, reject) => { if (!navigator.geolocation) { reject(new Error('Este aparelho não permite acesso à localização.')); return; } navigator.geolocation.getCurrentPosition( pos => resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude, precisao: pos.coords.accuracy, }), err => reject(new Error(GEO_ERROS[err.code] || 'Não foi possível obter sua localização.')), { enableHighAccuracy: true, timeout: GEO_TIMEOUT_MS, maximumAge: 0 }, ); }); } // Mesma formula da funcao distancia_metros do banco (Haversine). function distanciaMetros(lat1, lng1, lat2, lng2) { const vals = [lat1, lng1, lat2, lng2]; if (vals.some(v => v === null || v === undefined || v === '')) return null; const rad = g => (Number(g) * Math.PI) / 180; const dLat = rad(lat2) - rad(lat1); const dLng = rad(lng2) - rad(lng1); const a = Math.pow(Math.sin(dLat / 2), 2) + Math.cos(rad(lat1)) * Math.cos(rad(lat2)) * Math.pow(Math.sin(dLng / 2), 2); return Math.round(6371000 * 2 * Math.asin(Math.sqrt(a))); } // O endereco por extenso e um CONFORTO, nao um requisito: se o servico estiver // fora do ar o check-in continua valendo pelas coordenadas. Por isso engole o // erro e devolve null. async function enderecoAproximado(lat, lng) { try { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 6000); const resp = await fetch( 'https://nominatim.openstreetmap.org/reverse?format=jsonv2&zoom=18&accept-language=pt-BR&lat=' + lat + '&lon=' + lng, { signal: ctrl.signal, headers: { Accept: 'application/json' } }, ); clearTimeout(timer); if (!resp.ok) return null; const json = await resp.json(); return (json && json.display_name) || null; } catch (err) { console.warn('[enderecoAproximado] indisponivel:', err && err.message); return null; } } const fmtDistancia = m => (m === null || m === undefined) ? null : (m < 1000 ? Math.round(m) + ' m' : (m / 1000).toFixed(1).replace('.', ',') + ' km'); function CheckInModal({ open, escala, freelancer, onClose, onDone, showToast }) { const ev = (escala && escala.events) || {}; const precisaLgpd = !(freelancer && freelancer.lgpd_localizacao_aceita_em); const raio = ev.raio_checkin_metros || 300; const [etapa, setEtapa] = React.useState('localizando'); // lgpd|localizando|erro|foto|enviando const [pos, setPos] = React.useState(null); const [distancia, setDistancia] = React.useState(null); const [endereco, setEndereco] = React.useState(null); const [erro, setErro] = React.useState(''); const [arquivo, setArquivo] = React.useState(null); const [preview, setPreview] = React.useState(''); // URL de preview e um recurso do navegador: sem o revoke, cada foto tirada // deixa um blob preso na memoria do celular. React.useEffect(() => () => { if (preview) URL.revokeObjectURL(preview); }, [preview]); const localizar = React.useCallback(async () => { setEtapa('localizando'); setErro(''); try { const p = await obterPosicao(); setPos(p); const dist = distanciaMetros(ev.local_lat, ev.local_lng, p.lat, p.lng); setDistancia(dist); if (dist !== null && dist > raio) { setErro('Você está a ' + fmtDistancia(dist) + ' do local da ação (limite: ' + raio + ' m). Aproxime-se do endereço informado para fazer o check-in.'); setEtapa('erro'); return; } setEtapa('foto'); enderecoAproximado(p.lat, p.lng).then(setEndereco); // segue sem esperar } catch (err) { setErro(err.message); setEtapa('erro'); } }, [ev.local_lat, ev.local_lng, raio]); React.useEffect(() => { if (!open) return; setArquivo(null); setPreview(''); if (precisaLgpd) setEtapa('lgpd'); else localizar(); }, [open, precisaLgpd, localizar]); const aceitarLgpd = async () => { const { error } = await sb.rpc('promotor_aceitar_lgpd'); if (error) { console.error('[CheckInModal.aceitarLgpd] Supabase error:', error); showToast('Não foi possível registrar o aceite.', 'danger'); return; } if (freelancer) freelancer.lgpd_localizacao_aceita_em = new Date().toISOString(); localizar(); }; const escolherFoto = (file) => { if (!file) return; if (preview) URL.revokeObjectURL(preview); setArquivo(file); setPreview(URL.createObjectURL(file)); }; const confirmar = async () => { if (!arquivo || !pos) return; setEtapa('enviando'); const ext = (arquivo.name.split('.').pop() || 'jpg').toLowerCase(); const path = 'checkin_' + escala.id + '/' + crypto.randomUUID() + '.' + ext; try { const { error: upErr } = await sb.storage .from('event-photos').upload(path, arquivo, { contentType: arquivo.type }); if (upErr) throw upErr; const { data: pub } = sb.storage.from('event-photos').getPublicUrl(path); const { data, error } = await sb.rpc('promotor_check_in', { p_escala_id: escala.id, p_lat: pos.lat, p_lng: pos.lng, p_foto_url: pub.publicUrl, p_foto_path: path, p_precisao_m: pos.precisao, p_endereco: endereco, }); if (error) { // A foto so existe por causa deste check-in: se ele nao entrou, ela vira lixo. await sb.storage.from('event-photos').remove([path]); throw error; } onDone(Array.isArray(data) ? data[0] : data); showToast('Check-in realizado com sucesso!'); onClose(); } catch (err) { console.error('[CheckInModal.confirmar] error:', err); setErro(err.message || 'Não foi possível concluir o check-in.'); setEtapa('erro'); } }; return ( {etapa === 'lgpd' && (
Para validar sua presença nas ações, o Promo Up usa a localização do aparelho somente no check-in, no envio de registros e no check-out.

Não há rastreamento fora das ações.
Agora não Aceitar e continuar
)} {etapa === 'localizando' && (
Localizando você…
Se o navegador pedir permissão, toque em Permitir.
)} {etapa === 'erro' && (
{erro}
{distancia !== null && (
Distância medida agora: {fmtDistancia(distancia)} · limite {raio} m
)}
Fechar Tentar de novo
)} {(etapa === 'foto' || etapa === 'enviando') && (
Localização confirmada {distancia !== null && ' · ' + fmtDistancia(distancia) + ' do local'}
{endereco && (
{endereco}
)}
{pos && pos.precisao > GEO_PRECISAO_RUIM_M && (
O sinal de GPS está fraco (precisão de {Math.round(pos.precisao)} m). Se possível, saia de baixo de cobertura antes de continuar.
)}
Foto de chegada *
{preview && etapa !== 'enviando' && (
Toque na imagem para tirar outra
)}
Cancelar {etapa === 'enviando' ? 'Enviando…' : 'Confirmar check-in'}
)}
); } Object.assign(window, { CheckInModal, obterPosicao, distanciaMetros, enderecoAproximado, fmtDistancia, GEO_PRECISAO_RUIM_M, });