/* global React, ReactDOM */
const { useState, useEffect, useMemo, useRef, useCallback } = React;
// ─── i18n helper (mirrors app/assets/js/lib/i18n.js) ──────
function _t(key) {
if (!key) return '';
if (window._i18n && window._i18n[key] !== undefined) return window._i18n[key];
return key;
}
// ─── Country-scoped localStorage key ─────────────────────
function _geoKey(base) {
const country = (window.__nbGeo && window.__nbGeo.current) || 'CV';
return base + '_' + country;
}
// ─── Feature flag check (from DB via PHP) ────────────────
function _feat(code) {
const features = window.__nbFeatures;
// If no features configured, default all enabled (backwards compat)
if (!features || !Array.isArray(features) || features.length === 0) return true;
return features.includes(code);
}
// ─── API Client (mirrors ApiClient from api-client.js) ──────
const API = (() => {
const BASE = (window.__nbConfig && window.__nbConfig.apiUrl) || '/api';
const PLATFORM = (window.__nbConfig && window.__nbConfig.platformToken) || '';
// Auth endpoint paths — never clear token on 401 for these (wrong credentials, not expired session)
const AUTH_PATHS = ['/client/auth/login', '/client/auth/register', '/client/auth/google-login',
'/client/auth/facebook-login', '/client/auth/apple-login', '/client/auth/redeem-token'];
function headers(extra = {}) {
const lang = localStorage.getItem('nb_preferred_lang') || 'pt';
const h = { 'Accept': 'application/json', 'Accept-Language': lang, ...extra };
const token = localStorage.getItem('sanctum_token');
if (token) h['Authorization'] = 'Bearer ' + token;
if (PLATFORM) h['X-Platform-Token'] = PLATFORM;
return h;
}
function buildUrl(path, params) {
const url = new URL(BASE + path, window.location.origin);
if (params) {
Object.entries(params).forEach(([k, v]) => {
if (v !== null && v !== undefined && v !== '') url.searchParams.set(k, v);
});
}
return url.toString();
}
async function handleResponse(r, path) {
if (r.status === 401) {
let data = {};
try { data = await r.json(); } catch (_e) {}
// Non-auth 401 = expired/invalid token — clear it
const isAuth = AUTH_PATHS.some(p => path.includes(p));
if (!isAuth && localStorage.getItem('sanctum_token')) {
console.warn('[API] 401 — token expired/invalid, clearing session');
localStorage.removeItem('sanctum_token');
}
return { message: data.message || 'not-logged', data: data.data || null };
}
if (r.status === 422) {
try {
const data = await r.json();
return { message: 'validation-error', errors: data.errors || {}, detail: data.message || '', data: null };
} catch (_e) {
return { message: 'validation-error', errors: {}, data: null };
}
}
if (!r.ok) {
try {
const d = await r.json();
if (d && d.message) {
return { message: d.message, detail: d.detail || d.error || '', error: d.error || d.detail || '', code: d.code || '', data: d.data || null };
}
} catch (_e) {}
console.warn('[API]', r.status, path);
return { message: 'error', detail: '', error: '', code: '', data: null };
}
return r.json();
}
// Dedup: cache in-flight GET requests to avoid concurrent duplicate calls
const _inflight = {};
async function get(path, params = {}) {
const key = path + JSON.stringify(params);
if (_inflight[key]) return _inflight[key];
const promise = fetch(buildUrl(path, params), { headers: headers(), credentials: 'include' })
.then(r => handleResponse(r, path))
.finally(() => { delete _inflight[key]; });
_inflight[key] = promise;
return promise;
}
async function post(path, body = {}) {
const r = await fetch(buildUrl(path), {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body),
credentials: 'include',
});
return handleResponse(r, path);
}
async function put(path, body = {}) {
const r = await fetch(buildUrl(path), {
method: 'PUT',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body),
credentials: 'include',
});
return handleResponse(r, path);
}
async function del(path) {
const r = await fetch(buildUrl(path), {
method: 'DELETE',
headers: headers(),
credentials: 'include',
});
return handleResponse(r, path);
}
async function upload(path, formData) {
const r = await fetch(buildUrl(path), {
method: 'POST',
headers: headers(), // No Content-Type — browser sets multipart boundary
body: formData,
credentials: 'include',
});
return handleResponse(r, path);
}
return { get, post, put, del, upload };
})();
// ─── Ticket data normalizer ─────────────────────────────────
// API returns Service/Agency/TicketWait but components expect ServiceName/AgencyName/People
function normalizeTicket(t) {
return {
...t,
ServiceName: t.ServiceName || t.Service || '',
AgencyName: t.AgencyName || t.Agency || '',
CompanyName: t.CompanyName || '',
CategoryName: t.CategoryName || '',
Code: t.Code || t.Number || '',
People: t.People ?? t.TicketWait ?? 0,
WaitTime: t.WaitTime || (t.EstimatedWait ? Math.ceil(t.EstimatedWait / 60) + ' min' : null),
};
}
// ─── Pusher Client ──────────────────────────────────────────
const PusherClient = (() => {
let _pusher = null;
let _channels = {};
function init(key, cluster) {
if (_pusher || !key || typeof Pusher === 'undefined') return;
_pusher = new Pusher(key, { cluster, forceTLS: true });
}
function isReady() { return !!_pusher; }
function subscribe(channel) {
if (!_pusher || _channels[channel]) return;
_channels[channel] = _pusher.subscribe(channel);
}
function bind(channel, event, callback) {
if (_channels[channel]) _channels[channel].bind(event, callback);
}
function unbind(channel, event, callback) {
if (_channels[channel]) _channels[channel].unbind(event, callback);
}
function unsubscribe(channel) {
if (_pusher && _channels[channel]) {
_pusher.unsubscribe(channel);
delete _channels[channel];
}
}
function unsubscribeAll() {
Object.keys(_channels).forEach(ch => { if (_pusher) _pusher.unsubscribe(ch); });
_channels = {};
}
return { init, isReady, subscribe, bind, unbind, unsubscribe, unsubscribeAll };
})();
// ─── Notification Sound ─────────────────────────────────────
let _audioCtx = null;
function playNotificationSound() {
try {
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const osc = _audioCtx.createOscillator();
const gain = _audioCtx.createGain();
osc.type = 'sine'; osc.frequency.value = 880;
gain.gain.value = 0.3;
osc.connect(gain); gain.connect(_audioCtx.destination);
osc.start(); osc.stop(_audioCtx.currentTime + 0.3);
} catch(e) {}
}
// ─── QR Code Generator (minimal) ───────────────────────────
const NbQR = (() => {
function toDataURL(text, size = 200) {
// Use a simple QR rendering via canvas API approach
// Encode data as a shareable URL rendered as a grid pattern
const modules = generateQR(text);
const canvas = document.createElement('canvas');
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
const cellSize = size / modules.length;
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = '#000';
modules.forEach((row, y) => {
row.forEach((cell, x) => {
if (cell) ctx.fillRect(x * cellSize, y * cellSize, cellSize, cellSize);
});
});
return canvas.toDataURL();
}
// Minimal QR code generator (alphanumeric mode, EC level L)
function generateQR(text) {
// Fallback: generate a simple visual pattern with the text encoded
// For production, use a proper QR library. This creates a readable grid.
const size = 21; // Version 1 QR
const grid = Array.from({length: size}, () => Array(size).fill(false));
// Finder patterns
const drawFinder = (ox, oy) => {
for (let y = 0; y < 7; y++) for (let x = 0; x < 7; x++) {
grid[oy+y][ox+x] = (y === 0 || y === 6 || x === 0 || x === 6 || (y >= 2 && y <= 4 && x >= 2 && x <= 4));
}
};
drawFinder(0, 0); drawFinder(size-7, 0); drawFinder(0, size-7);
// Timing patterns
for (let i = 8; i < size-8; i++) { grid[6][i] = i%2===0; grid[i][6] = i%2===0; }
// Data area: encode text bytes as pattern
const bytes = new TextEncoder().encode(text);
let bit = 0;
for (let x = size-1; x >= 1; x -= 2) {
if (x === 6) x = 5;
for (let y = 0; y < size; y++) {
for (let dx = 0; dx < 2; dx++) {
const cx = x - dx, cy = y;
if (grid[cy][cx] !== undefined && cy >= 0 && cx >= 0) {
if ((cy < 9 && cx < 9) || (cy < 9 && cx >= size-8) || (cy >= size-8 && cx < 9)) continue;
if (cy === 6 || cx === 6) continue;
const byteIdx = Math.floor(bit / 8);
const bitIdx = 7 - (bit % 8);
grid[cy][cx] = byteIdx < bytes.length ? !!((bytes[byteIdx] >> bitIdx) & 1) : false;
bit++;
}
}
}
}
return grid;
}
return { toDataURL };
})();
// ─── Toast Notification System ──────────────────────────────
const ToastContext = React.createContext({ addToast: () => {} });
function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const idRef = useRef(0);
const addToast = useCallback((message, type = 'info', duration = 5000) => {
const id = ++idRef.current;
setToasts(prev => [...prev, { id, message, type }]);
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), duration);
}, []);
return (
{title}
} {description &&{description}
} {actions && actions.length > 0 && (${opts.serviceName}${payload.amount ? ' — ' + parseInt(payload.amount).toLocaleString('pt-CV') + ' CVE' : ''}
` : ''}{agencies.length} agências
{agencyInfo?.Zone || agency.Zone || ''}{' '} {working && ( )} {lastUpdate && Atualizado {lastUpdate.toLocaleTimeString('pt', {hour:'2-digit',minute:'2-digit'})}}
{filtered.length} {_t('webapp.services') || 'serviços'}
{ e.preventDefault(); const el = document.getElementById('nb-auth-panel'); if (el) { el.classList.add('auth-panel-highlight'); setTimeout(() => el.classList.remove('auth-panel-highlight'), 1500); } }} style={{ color: 'var(--nb-green)', textDecoration: 'underline', cursor: 'pointer' }}>{_t('webapp.tkt_login_prompt_link')} {_t('webapp.tkt_login_prompt_text')}
{_t('webapp.tkt_count_summary').replace(':total', tickets.length).replace(':active', counts.active)}
{ e.preventDefault(); const el = document.getElementById('nb-auth-panel'); if (el) { el.classList.add('auth-panel-highlight'); setTimeout(() => el.classList.remove('auth-panel-highlight'), 1500); } }} style={{ color: 'var(--nb-green)', textDecoration: 'underline', cursor: 'pointer' }}>{_t('webapp.apt_login_prompt_link')} {_t('webapp.apt_login_prompt_text')}
{_t('webapp.apt_count_summary').replace(':total', items.length).replace(':upcoming', counts.upcoming)}
} {showPicker && !pickerAgency &&{_t('webapp.apt_pick_agency_desc')}
}{_t('webapp.pay_subtitle_sisp')}
} {view === 'services' && selectedCat &&{selectedCat.Name} · {(selectedCat.services || []).length} {(selectedCat.services || []).length === 1 ? _t('webapp.pay_service_singular') : _t('webapp.pay_service_plural')}
}{p.nickname}
}{p.level}{p.member_since ? ` · ${_t('games.member_since')} ${p.member_since}` : ''}
}{stats.tournaments || 0} {(stats.tournaments || 0) === 1 ? (_t('games.tournament_singular') || 'torneio') : _t('games.tab_tournaments').toLowerCase()} · {stats.fields || 0} {(stats.fields || 0) === 1 ? (_t('games.field_singular') || 'campo') : _t('games.tab_fields').toLowerCase()} · {stats.games || 0} {(stats.games || 0) === 1 ? (_t('games.game_singular') || 'jogo') : _t('games.tab_games').toLowerCase()}
}Após conversão terás {b.available_points - maxPts} pontos restantes
{_t('games.no_matches')}
{_t('games.no_matches_hint') || 'Seleccione outra data ou verifique mais tarde.'}
{trainingFilter === 'my' ? _t('games.no_my_trainings') : trainingFilter === 'needs' ? _t('games.no_trainings_need_players') : _t('games.no_trainings')}
{_t('games.no_trainings_hint') || 'Cria um treino aberto para convidar outros atletas.'}
{plan.description}
}{_t('games.centers_subtitle')}
{_t('games.centers_no_results')}
{needsPayment ? _t('webapp.game_payment_pending_desc') : 'Apresenta este código na recepção'}
{!needsPayment && ({_t('games.all_teams_registered')}
}{bookingHourlyRate.toLocaleString('pt')}$00 × {bookingDuration}h = {bookingTotal.toLocaleString('pt')}$00
{(platformFees?.games_booking_platform_fee || 0) > 0 &&Taxa de serviço: {platformFees.games_booking_platform_fee}$00
}Total: {(bookingTotal + (platformFees?.games_booking_platform_fee || 0)).toLocaleString('pt')}$00
Ao confirmar, concordas com os{' '} Termos de Utilização {' '}e a{' '} Política de Privacidade {' '}da NHABEX.
{info.name} · {info.island}
{error}
Sem voos para hoje neste aeroporto.
{tab === 'map' ? 'Mapa por Flightradar24' : ( <>{_t('webapp.flight_auto_update')}{lastUpdate && <> · {_t('webapp.flight_last_update')}: {lastUpdate.toLocaleTimeString('pt-CV', { hour: '2-digit', minute: '2-digit' })}>}> )}
> ); } // ─── Cars View (NhaBex Cars — Mobilidade CV) ─────────────── const CV_ISLANDS_CARS = [ { code: 'all', name: _t('webapp.cars_all_islands') }, { code: 'santiago', name: 'Santiago' }, { code: 'sal', name: 'Sal' }, { code: 'sao-vicente', name: 'São Vicente' }, { code: 'boa-vista', name: 'Boa Vista' }, { code: 'fogo', name: 'Fogo' }, { code: 'santo-antao', name: 'Santo Antão' }, { code: 'maio', name: 'Maio' }, { code: 'sao-nicolau', name: 'São Nicolau' }, ]; const CARS_TABS = [ { id: 'routes', label: 'Rotas', icon: 'map' }, { id: 'terminals', label: _t('webapp.driver_terminals') || 'Terminais', icon: 'location' }, { id: 'rent', label: 'Aluguer', icon: 'car' }, { id: 'driver', label: _t('webapp.driver_tab'), icon: 'user' }, ]; const CAR_TYPES = [ { id: 'all', label: _t('webapp.cars_all') }, { id: 'rent', label: _t('webapp.cars_rent') }, { id: 'sale', label: _t('webapp.cars_sale') }, ]; const CAR_BODY_TYPES = [ { id: 'all', label: _t('webapp.cars_all') }, { id: 'sedan', label: 'Sedan' }, { id: 'suv', label: 'SUV' }, { id: 'hatchback', label: 'Hatchback' }, { id: 'pickup', label: 'Pickup' }, { id: 'van', label: 'Van' }, { id: 'coupe', label: 'Coupé' }, ]; const CAR_FUEL_TYPES = [ { id: 'all', label: _t('webapp.cars_all') }, { id: 'gasoline', label: _t('webapp.cars_gasoline') }, { id: 'diesel', label: _t('webapp.cars_diesel') }, { id: 'electric', label: _t('webapp.cars_electric') }, { id: 'hybrid', label: _t('webapp.cars_hybrid') }, ]; const ROUTE_VEHICLE_TYPES = [ { id: 'all', label: _t('webapp.cars_all') }, { id: 'hiace', label: 'Toyota Hiace' }, { id: 'hilux', label: 'Hilux' }, { id: 'minibus', label: 'Minibus' }, { id: 'bus', label: _t('webapp.routes_bus') }, ]; function CarDetailPanel({ car, onClose, collapsed, onToggle }) { if (!car) return null; const isRent = car.listing_type === 'rent'; const price = isRent ? car.price_day : car.price; return ( ); } function CarCard({ car, onSelect }) { const isRent = car.listing_type === 'rent'; const price = isRent ? car.price_day : car.price; return (NhaBex Pay via SISP 3D Secure
Não feches esta janela.
Completa o pagamento na janela do SISP que foi aberta. Após o pagamento, a reserva será confirmada automaticamente.
> ) : ( <>A tua reserva foi confirmada com sucesso.
> )}{result.status === 'pending' ? 'Após completar o pagamento SISP, receberás confirmação por SMS.' : 'Guarda o código de reserva. Apresenta-o ao operador no momento do embarque.'}
{_t('webapp.live_no_boarding') || 'Nenhuma viatura a embarcar'}