/* 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 ( {children}
{toasts.map(t => (
{t.message}
))}
); } // ─── Favorites Hook ───────────────────────────────────────── function useFavorites(signedIn) { const [agencies, setAgencies] = useState(() => { try { return JSON.parse(localStorage.getItem(_geoKey('nb_fav_agencies')) || '[]'); } catch { return []; } }); // Services stored as [{agencyHash, serviceHash}] const [services, setServices] = useState(() => { try { return JSON.parse(localStorage.getItem(_geoKey('nb_fav_services')) || '[]'); } catch { return []; } }); // Full agency data for favorites display const [favAgencyData, setFavAgencyData] = useState([]); // Sync agencies from server when logged in useEffect(() => { if (!signedIn) return; API.get('/client/favorites').then(res => { if (res.message === 'success' && Array.isArray(res.data)) { const list = res.data; const hashes = list.map(f => f.agency_hash || f.Hash || f.AgencyHash).filter(Boolean); setAgencies(hashes); localStorage.setItem(_geoKey('nb_fav_agencies'), JSON.stringify(hashes)); // Store full data for display setFavAgencyData(list.map(f => ({ hash: f.agency_hash || f.Hash || f.AgencyHash, name: f.agency_name || f.Name || f.AgencyName || '', company: f.company_name || f.CompanyName || '', zone: f.zone || f.Zone || '', }))); } }).catch(() => {}); // Sync service favorites API.get('/client/favorites/services').then(res => { if (res.message === 'success' && Array.isArray(res.data)) { const svcFavs = res.data.map(f => ({ agencyHash: f.agency_hash || f.AgencyHash, serviceHash: f.service_hash || f.ServiceHash, })); setServices(svcFavs); localStorage.setItem(_geoKey('nb_fav_services'), JSON.stringify(svcFavs)); } }).catch(() => {}); }, [signedIn]); const isAgencyFav = (hash) => agencies.includes(hash); const toggleAgency = async (hash, agencyData) => { const isFav = isAgencyFav(hash); NbTracker.track(isFav ? 'unfav_agency' : 'fav_agency', { hash }); const next = isFav ? agencies.filter(h => h !== hash) : [...agencies, hash]; setAgencies(next); localStorage.setItem(_geoKey('nb_fav_agencies'), JSON.stringify(next)); if (!isFav && agencyData) { setFavAgencyData(prev => [...prev, { hash, name: agencyData.Name || agencyData.name || '', company: '', zone: agencyData.Zone || '' }]); } else { setFavAgencyData(prev => prev.filter(a => a.hash !== hash)); } if (signedIn) { try { if (isFav) await API.del(`/client/favorites/${hash}`); else await API.post('/client/favorites', { agency_hash: hash }); } catch(e) {} } }; const isServiceFav = (agencyHash, serviceHash) => services.some(s => s.agencyHash === agencyHash && s.serviceHash === serviceHash); const toggleService = async (agencyHash, serviceHash) => { const isFav = isServiceFav(agencyHash, serviceHash); const next = isFav ? services.filter(s => !(s.agencyHash === agencyHash && s.serviceHash === serviceHash)) : [...services, { agencyHash, serviceHash }]; setServices(next); localStorage.setItem(_geoKey('nb_fav_services'), JSON.stringify(next)); if (signedIn) { try { if (isFav) await API.del(`/client/favorites/services/${agencyHash}/${serviceHash}`); else await API.post('/client/favorites/services', { agency_hash: agencyHash, service_hash: serviceHash }); } catch(e) {} } }; return { agencies, favAgencyData, isAgencyFav, toggleAgency, services, isServiceFav, toggleService }; } // ─── Empty State (reusable) ────────────────────────────────── function EmptyState({ icon, title, description, actions }) { return (
{title &&

{title}

} {description &&

{description}

} {actions && actions.length > 0 && (
{actions.map((a, i) => ( ))}
)}
); } // ─── Icons (Lucide via inline SVG) ────────────────────────── const Ico = ({ name, size = 18 }) => { const paths = { home: <>, ticket: <>, map: <>, radar: <>, user: <>, bell: <>, help: <>, search: <>, arrow: <>, arrowL: <>, bolt: <>, bank: <>, briefcase: <>, building: <>, mail: <>, cross: <>, scissors: <>, shield: <>, phone: <>, mic: <>, pin: <>, pinOff: <>, settings: <>, grid: <>, list: <>, qr: <>, share: <>, clock: <>, chevR: <>, chevD: <>, star: <>, logout: <>, wallet: <>, calendar: <>, store: <>, gamepad: <>, receipt: <>, lock: <>, check: <>, wifi: <>, droplet: <>, carWash: <>, zap: <>, tag: <>, copy: <>, sparkle: <>, send: <>, trophy: <>, users: <>, field: <>, activity: <>, pharmacy: <>, restaurant: <>, plane: <>, car: <>, school: <>, hospital: <>, alert: <>, info: <>, camera: <>, package: <>, truck: <>, boxCheck: <>, chevronLeft: <>, chevronRight: <>, trash: <>, sun: <>, moon: <>, searchOff: <>, }; return ( {paths[name]} ); }; // ─── Category icon mapping ────────────────────────────────── const FA_TO_ICO = { 'fa-building-columns': 'bank', 'fa-city': 'building', 'fa-envelope': 'mail', 'fa-futbol': 'field', 'fa-bolt': 'bolt', 'fa-heart-pulse': 'pharmacy', 'fa-hospital': 'hospital', 'fa-briefcase': 'briefcase', 'fa-users': 'scissors', 'fa-shield-halved': 'shield', 'fa-phone': 'phone', 'fa-plane': 'plane', 'fa-car': 'car', 'fa-school': 'school', 'fa-droplet': 'droplet', 'fa-sparkles': 'sparkle', }; function getCategoryIcon(faClass) { if (!faClass) return 'briefcase'; for (const [fa, ico] of Object.entries(FA_TO_ICO)) { if (faClass.includes(fa)) return ico; } return 'briefcase'; } // ─── Sport icon mapping (Material Icons → emoji) ────────── const SPORT_ICON_MAP = { sports_soccer: '⚽', sports_tennis: '🎾', sports_basketball: '🏀', sports_volleyball: '🏐', sports_handball: '🤾', sports_baseball: '⚾', sports_hockey: '🏒', sports_rugby: '🏉', sports_cricket: '🏏', sports_golf: '⛳', sports_martial_arts: '🥋', sports_gymnastics: '🤸', sports_esports: '🎮', sports_kabaddi: '🤼', sports_mma: '🥊', pool: '🏊', surfing: '🏄', rowing: '🚣', sailing: '⛵', fitness_center: '🏋️', directions_run: '🏃', directions_bike: '🚴', downhill_skiing: '⛷️', hiking: '🥾', skateboarding: '🛹', sports: '🏅', emoji_events: '🏆', }; function getSportEmoji(iconName) { if (!iconName) return '🏅'; return SPORT_ICON_MAP[iconName] || '🏅'; } // ─── Saved Cards Helper (React/Desktop) ───────────────────── let _deskCardsCache = null; let _deskCardsCacheTs = 0; async function loadSavedCardsDesktop() { if (_deskCardsCache && Date.now() - _deskCardsCacheTs < 60000) return _deskCardsCache; try { const res = await API.get('/client/cards'); if (res.message === 'success') { _deskCardsCache = res.data || []; _deskCardsCacheTs = Date.now(); } } catch {} return _deskCardsCache || []; } /** * Show a card picker modal and initiate payment (DOM-based, works in any React context). * @param {Object} payload - Payment payload (service_type, amount, metadata, etc.) * @param {Object} opts - { serviceName, addToast } * @returns {Promise} true if redirect happened */ async function initiatePaymentWithCardPicker(payload, opts) { const cards = await loadSavedCardsDesktop(); return new Promise((resolve) => { const doPay = async (card) => { try { const res = card ? await API.post(`/client/cards/${card.ID}/pay`, payload) : await API.post('/client/payments/initiate', payload); if (res.message === 'success' && res.data?.redirect_html) { document.open(); document.write(res.data.redirect_html); document.close(); resolve(true); } else { if (opts?.addToast) opts.addToast(res.data || res.detail || _t('tokencard.payment_error'), 'error'); resolve(false); } } catch { if (opts?.addToast) opts.addToast(_t('tokencard.payment_error'), 'error'); resolve(false); } }; if (!cards || cards.length === 0) { doPay(null); return; } const _cardDigits = (c) => (c.CardPanMasked || '').replace(/\*/g, '').slice(-4) || '****'; const overlay = document.createElement('div'); overlay.className = 'nb-card-picker-overlay'; overlay.innerHTML = `

${_t('tokencard.select_card')}

${opts?.serviceName ? `

${opts.serviceName}${payload.amount ? ' — ' + parseInt(payload.amount).toLocaleString('pt-CV') + ' CVE' : ''}

` : ''}
${cards.map((c, i) => ``).join('')}
`; const close = () => overlay.remove(); overlay.querySelector('.nb-card-picker__close').onclick = () => { close(); resolve(false); }; overlay.querySelector('.nb-card-picker__normal').onclick = () => { close(); doPay(null); }; overlay.addEventListener('click', (e) => { if (e.target === overlay) { close(); resolve(false); } }); overlay.querySelectorAll('.nb-card-picker__card').forEach(btn => { btn.onclick = () => { close(); doPay(cards[parseInt(btn.dataset.idx)]); }; }); document.body.appendChild(overlay); }); } // ─── Components ───────────────────────────────────────────── function Sidebar({ view, setView, activeTickets, width, onResizeStart, collapsed, onToggle }) { return ( ); } // ─── User Profile Dropdown ────────────────────────────────── function UserProfileDropdown({ user, onSignOut, onClose, activeTicketCount, onViewTickets, onEditProfile, theme, onToggleTheme }) { return (
e.stopPropagation()}> {/* Header with photo/avatar */}
{user.photo_url ? ( ) : (
{user.initials}
)}
{user.name}
{user.email}
{/* User details */}
{user.phone && (
{user.phone_country_code || ''} {user.phone}
)} {user.birthday && (
{new Date(user.birthday).toLocaleDateString('pt', { day: '2-digit', month: '2-digit', year: 'numeric' })}
)} {user.registry_date && (
{_t('webapp.member_since')} {new Date(user.registry_date).toLocaleDateString('pt', { day: '2-digit', month: '2-digit', year: 'numeric' })}
)}
{/* Tickets summary */}
{/* Actions */}
{/* Logout */}
); } // ─── Wallet Dropdown ──────────────────────────────────────── function WalletDropdown({ onClose, onViewAll, onPayPending }) { const [payments, setPayments] = useState([]); const [loading, setLoading] = useState(true); const [savedCards, setSavedCards] = useState([]); const [addingCard, setAddingCard] = useState(false); useEffect(() => { API.get('/client/payments').then(res => { if (res.message === 'success' && Array.isArray(res.data)) setPayments(res.data.slice(0, 5)); setLoading(false); }).catch(() => setLoading(false)); API.get('/client/cards').then(res => { if (res.message === 'success') setSavedCards(res.data || []); }).catch(() => {}); }, []); const cardDigits = (c) => (c.CardPanMasked || '').replace(/\*/g, '').slice(-4) || '****'; const addCard = async () => { if (addingCard) return; setAddingCard(true); try { const res = await API.post('/client/cards/tokenize', { min_amount: 1, return_path: window.location.origin + window.location.pathname + '?view=payments' }); if (res.message === 'success' && res.data?.redirect_html) { document.open(); document.write(res.data.redirect_html); document.close(); } else { setAddingCard(false); } } catch { setAddingCard(false); } }; const removeCard = async (card) => { try { const res = await API.post('/client/cards/' + card.ID + '/cancel'); if (res.message === 'success') { setSavedCards(prev => prev.filter(c => c.ID !== card.ID)); _deskCardsCache = null; } } catch {} }; const statusMeta = { success: { label: _t('webapp.pay_status_success'), color: '#02A586', bg: 'rgba(2,165,134,0.12)' }, pending: { label: _t('webapp.pay_status_pending'), color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' }, error: { label: _t('webapp.pay_status_error'), color: '#ef4444', bg: 'rgba(239,68,68,0.12)' }, canceled:{ label: _t('webapp.pay_status_canceled'), color: '#6b7280', bg: 'rgba(107,114,128,0.12)' }, }; const stypeMeta = { purchase: 'Pagamento', phone_topup: 'Carregamento', electricity: 'Electricidade', water: 'Água', insurance: 'Seguro', internet: 'Internet', other: 'Outro', games_booking: 'Reserva Campo', games_tournament: 'Torneio', games_subscription: 'Plano Games', car_route_booking: 'Transporte', wash_booking: 'Car Wash', store_purchase: 'Loja', beauty_prepaid: 'Beleza', }; const totalSuccess = payments.filter(p => p.Status === 'success').reduce((s, p) => s + parseFloat(p.Amount || 0), 0); const pendingCount = payments.filter(p => p.Status === 'pending').length; const formatDate = (d) => { if (!d) return ''; const dt = new Date(d); const now = new Date(); const diff = Math.floor((now - dt) / 86400000); if (diff === 0) return 'Hoje'; if (diff === 1) return 'Ontem'; return dt.toLocaleDateString('pt', { day: '2-digit', month: 'short' }); }; return (
e.stopPropagation()}>
NhaBex Pay
{/* Summary cards */}
{_t('webapp.wallet_recent_spent')} {totalSuccess.toLocaleString('pt')} CVE
{pendingCount > 0 && (
{_t('webapp.wallet_pending')} {pendingCount}
)}
{/* Saved cards */}
{_t('tokencard.title')}
{savedCards.length === 0 ? (
{_t('tokencard.no_cards_desc')}
) : savedCards.map(c => (
{c.CardLabel || ('•••• ' + cardDigits(c))} {'•••• ' + cardDigits(c)}
))}
{/* Recent transactions */}
{_t('webapp.wallet_recent')}
{loading ? (
) : payments.length === 0 ? (
{_t('webapp.wallet_empty')}
) : ( payments.map(p => { const st = statusMeta[p.Status] || statusMeta.error; const stypeIcon = { phone_topup: 'phone', electricity: 'bolt', water: 'activity', insurance: 'shield', internet: 'wifi', store_purchase: 'store', games_booking: 'field', games_tournament: 'trophy', games_subscription: 'wallet', wash_booking: 'activity', car_route_booking: 'truck', beauty_prepaid: 'user' }[p.ServiceType] || 'wallet'; return (
{p.ServiceLogoURL ? ( ) : (
)}
{p.ServiceName || stypeMeta[p.ServiceType] || p.ServiceType} {formatDate(p.created_at)}
{parseFloat(p.Amount).toLocaleString('pt')} {p.CurrencyCode || 'CVE'}
{st.label} {p.Status === 'pending' && onPayPending && ( )}
); }) )}
{/* Footer */}
); } // ─── Country flag emoji from ISO 3166-1 alpha-2 ────── function countryFlag(code) { if (!code || code.length !== 2) return ''; return String.fromCodePoint(...[...code.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65)); } function CountrySelector() { const [open, setOpen] = useState(false); const geo = window.__nbGeo || { current: 'CV', countries: [] }; const apiBase = (window.__nbConfig && window.__nbConfig.apiUrl) || '/api'; if (!geo.countries || geo.countries.length <= 1) return null; return (
{open && ( <>
setOpen(false)} />
{_t('webapp.select_country') || 'Selecionar pais'}
{geo.countries.map(c => ( setOpen(false)}> {countryFlag(c.CountryCode)} {c.CountryName} {c.CountryCode === geo.current && } ))}
)}
); } function Topbar({ view, currentCategory, onSearch, query, signedIn, authChecking, user, onSignOut, onSearchSelect, activeTicketCount, onViewTickets, onEditProfile, theme, onToggleTheme, notifUnread, onNotifications, onHelp, onLogin, locationGranted, locationName, onRequestLocation, onGoHome, onViewPayments, onPayPending, cartCount, onViewCart }) { const [searchFocused, setSearchFocused] = useState(false); const [profileOpen, setProfileOpen] = useState(false); const [walletOpen, setWalletOpen] = useState(false); const [searchScope, setSearchScope] = useState('all'); return (
{view !== 'home' && <> / {currentCategory || { home: _t('webapp.nav_home'), tickets: _t('webapp.nav_tickets'), appointments: _t('webapp.nav_appointments'), tracking: _t('webapp.nav_track_order'), map: _t('webapp.nav_map'), payments: _t('webapp.nav_payments'), store: _t('webapp.nav_store'), pharmacy: _t('webapp.nav_pharmacy'), flights: _t('webapp.nav_flights'), cars: _t('webapp.nav_cars'), carwash: _t('webapp.wash_title') || 'Lavagem', games: _t('webapp.nav_games'), profile: _t('webapp.nav_profile'), settings: _t('webapp.nav_settings') }[view] || view.charAt(0).toUpperCase() + view.slice(1)} } {view === 'category' && currentCategory && <> / {currentCategory} }
onSearch(e.target.value)} onFocus={() => setSearchFocused(true)} onBlur={() => setTimeout(() => setSearchFocused(false), 200)} onKeyDown={(e) => e.key === 'Escape' && (onSearch(''), e.target.blur())} /> {query && }
{searchFocused && { onSearch(''); setSearchFocused(false); }} />}
{cartCount > 0 && ( )} {signedIn && (
{walletOpen && ( <>
setWalletOpen(false)} /> setWalletOpen(false)} onViewAll={onViewPayments} onPayPending={(p) => { setWalletOpen(false); onPayPending && onPayPending(p); }} /> )}
)} {signedIn ? (
{profileOpen && ( <>
setProfileOpen(false)} /> { setProfileOpen(false); onSignOut(); }} onClose={() => setProfileOpen(false)} activeTicketCount={activeTicketCount} onViewTickets={onViewTickets} onEditProfile={onEditProfile} theme={theme} onToggleTheme={onToggleTheme} /> )}
) : authChecking ? (
) : ( )}
); } const CAT_COLORS = [ { bg: 'rgba(2,165,134,0.18)', fg: '#02A586', border: 'rgba(2,165,134,0.15)' }, // green (default) { bg: 'rgba(25,118,210,0.18)', fg: '#1976D2', border: 'rgba(25,118,210,0.15)' }, // blue { bg: 'rgba(156,39,176,0.18)', fg: '#9C27B0', border: 'rgba(156,39,176,0.15)' }, // purple { bg: 'rgba(230,81,0,0.18)', fg: '#E65100', border: 'rgba(230,81,0,0.15)' }, // orange { bg: 'rgba(0,121,107,0.18)', fg: '#00796B', border: 'rgba(0,121,107,0.15)' }, // teal { bg: 'rgba(198,40,40,0.18)', fg: '#C62828', border: 'rgba(198,40,40,0.15)' }, // red { bg: 'rgba(21,101,192,0.18)', fg: '#1565C0', border: 'rgba(21,101,192,0.15)' }, // indigo { bg: 'rgba(46,125,50,0.18)', fg: '#2E7D32', border: 'rgba(46,125,50,0.15)' }, // dark green { bg: 'rgba(245,127,23,0.18)', fg: '#F57F17', border: 'rgba(245,127,23,0.15)' }, // amber { bg: 'rgba(0,131,143,0.18)', fg: '#00838F', border: 'rgba(0,131,143,0.15)' }, // cyan ]; function catColorIndex(id) { if (!id) return 0; let h = 0; const s = String(id); for (let i = 0; i < s.length; i++) h = s.charCodeAt(i) + ((h << 5) - h); return Math.abs(h) % CAT_COLORS.length; } function CategoryCard({ cat, onOpen }) { const m = cat.title.match(/^(.*?)\s*(\([^)]+\))\s*$/); const titleMain = m ? m[1] : cat.title; const titleAside = m ? m[2] : null; const clr = CAT_COLORS[catColorIndex(cat.id)]; return (
onOpen(cat)}>

{titleMain}{titleAside ? ` ${titleAside}` : ''}

{cat.sub &&
{cat.sub}
}
); } // ─── Category usage tracking (localStorage) ──────────────── function getCatUsage() { try { return JSON.parse(localStorage.getItem(_geoKey('nb_cat_usage')) || '{}'); } catch { return {}; } } function trackCatUsage(catId, type) { const usage = getCatUsage(); if (!usage[catId]) usage[catId] = { visits: 0, tickets: 0, last: 0 }; if (type === 'visit') usage[catId].visits++; if (type === 'ticket') usage[catId].tickets++; usage[catId].last = Date.now(); try { localStorage.setItem(_geoKey('nb_cat_usage'), JSON.stringify(usage)); } catch {} } function sortCatsByUsage(cats) { const usage = getCatUsage(); const hasUsage = Object.keys(usage).length > 0; if (!hasUsage) return cats; return [...cats].sort((a, b) => { const ua = usage[a.id] || { visits: 0, tickets: 0, last: 0 }; const ub = usage[b.id] || { visits: 0, tickets: 0, last: 0 }; const scoreA = ua.tickets * 3 + ua.visits; const scoreB = ub.tickets * 3 + ub.visits; if (scoreB !== scoreA) return scoreB - scoreA; return ub.last - ua.last; }); } // ─── Recent agencies tracking (localStorage, scoped by country) ─────────────── const NB_RECENT_MAX = 8; function saveRecentAgency(agency, company) { try { const key = _geoKey('nb_recent_agencies'); const hash = agency.Hash || agency.Token || agency.hash || ''; if (!hash) return; const raw = localStorage.getItem(key); let list = raw ? JSON.parse(raw) : []; list = list.filter(r => r.hash !== hash); list.unshift({ hash, name: agency.Name || agency.name || agency.DesignationWeb || '', zone: agency.Zone || agency.City || '', companyHash: company?.hash || company?.Hash || agency.ID_Company || '', companyName: company?.name || company?.Name || agency.Company || '', logo: agency.LogoURL || company?.logo || '', date: new Date().toLocaleDateString('pt-PT', { day: '2-digit', month: '2-digit', year: 'numeric' }), ts: Date.now(), }); list = list.slice(0, NB_RECENT_MAX); localStorage.setItem(key, JSON.stringify(list)); } catch (_) {} } function getRecentAgencies() { try { const key = _geoKey('nb_recent_agencies'); const raw = localStorage.getItem(key); if (!raw) return []; const list = JSON.parse(raw); const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; return list.filter(r => r.ts > cutoff); } catch (_) { return []; } } function HomeView({ onOpenCategory, query, categories, loading, recents, onSelectRecent, favAgencies, onSelectFavAgency }) { const filtered = useMemo(() => { const sorted = sortCatsByUsage(categories); if (!query.trim()) return sorted; const q = query.toLowerCase(); return sorted.filter(c => (c.title || '').toLowerCase().includes(q) || (c.sub || '').toLowerCase().includes(q) ); }, [query, categories]); if (loading) { return ; } return ( <> {/* Favorite agencies */} {favAgencies && favAgencies.length > 0 && ( <>

Favoritos

{favAgencies.length} agência{favAgencies.length > 1 ? 's' : ''}
{favAgencies.map((a, i) => (
onSelectFavAgency && onSelectFavAgency(a)}>

{a.name}

{(a.zone || a.company) &&
{a.zone || a.company}
}
))}
)} {/* Recent tickets */} {recents.length > 0 && ( <>

{_t('webapp.recent')}

Volta a marcar com 1 clique
{recents.map((r, i) => (
onSelectRecent && onSelectRecent(r)}>
{r.name}
{r.when &&
{r.when}
}
))}
)}

{_t('webapp.select_category') || 'Selecione a categoria'} {filtered.length} {filtered.length === 1 ? _t('webapp.category_singular') || 'categoria' : _t('webapp.categories_plural') || 'categorias'}

{filtered.map(c => ( ))}
); } // ─── Company View (agencies in a company) ─────────────────── function CompanyView({ company, onBack, onSelectAgency, userLat, userLng }) { const [agencies, setAgencies] = useState([]); const [loading, setLoading] = useState(true); // Haversine distance calculation const haversineKm = (lat1, lng1, lat2, lng2) => { const R = 6371, toRad = (d) => d * Math.PI / 180; const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); }; const fmtDist = (km) => km < 1 ? Math.round(km * 1000) + ' m' : km.toFixed(1) + ' km'; useEffect(() => { setLoading(true); const hasGeo = userLat && userLng; // If user has location, try nearby API first for distance-sorted results const nearbyPromise = hasGeo ? API.get('/data/agencies/nearby', { company: company.hash, lat: userLat, lng: userLng, radius: 100, limit: 50 }) .catch(() => null) : Promise.resolve(null); nearbyPromise.then(nearbyRes => { if (nearbyRes?.message === 'success' && nearbyRes.data?.length > 0) { // Nearby API returns distance-sorted agencies setAgencies(nearbyRes.data); if (nearbyRes.data.length === 1) onSelectAgency(nearbyRes.data[0], company); setLoading(false); } else { // Fallback to standard API, calculate distance client-side API.get('/data/agencies', { company: company.hash }).then(res => { if (res.message === 'success' && res.data) { let list = Array.isArray(res.data) ? res.data : (res.data.agencies || []); // Sort by distance if geo available if (hasGeo) { list = list.map(a => { if (a.Lat && a.Long) { a.distance_km = Math.round(haversineKm(userLat, userLng, parseFloat(a.Lat), parseFloat(a.Long)) * 10) / 10; } return a; }).sort((a, b) => (a.distance_km || 9999) - (b.distance_km || 9999)); } setAgencies(list); if (list.length === 1) onSelectAgency(list[0], company); } setLoading(false); }).catch(() => setLoading(false)); } }); }, [company.hash, userLat, userLng]); return ( <>

{company.name}

{agencies.length} agências

{loading ? ( ) : agencies.length === 0 ? (
Nenhuma agência encontrada.
) : (
{agencies.map((a, i) => (
onSelectAgency(a, company)} style={{ cursor: 'pointer' }}>
{a.Name || a.name}
{a.Zone || a.City || a.where || ''} {a.distance_km != null && ( · {fmtDist(a.distance_km)} )}
))}
)} ); } // ─── Agency Services View ─────────────────────────────────── function AgencyServicesView({ agency, company, onBack, onPrintTicket, signedIn, isAgencyFav, onToggleFav, isServiceFav, onToggleServiceFav, onServiceInfo, hasActiveTicketForService }) { const [services, setServices] = useState([]); const [agencyInfo, setAgencyInfo] = useState(null); const [working, setWorking] = useState(null); const [timeTable, setTimeTable] = useState(null); const [showTimetable, setShowTimetable] = useState(false); const [loading, setLoading] = useState(true); const [lastUpdate, setLastUpdate] = useState(null); const [clientTypes, setClientTypes] = useState([]); const [showTypeModal, setShowTypeModal] = useState(null); const debounceRef = useRef(null); const viewChannelRef = useRef(null); const viewHandlersRef = useRef(null); const agencyHash = agency.Hash || agency.Token || agency.hash; const companyHash = company?.hash || company?.Hash || ''; const refreshServices = useCallback(() => { API.get(`/data/agencies/${agencyHash}/services-monitoring`, { company: companyHash }).then(res => { if (res.message === 'success' && res.data) { setServices(res.data.services || []); setAgencyInfo(res.data.agency || null); setWorking(res.data.working || null); if (res.data.timeTable) setTimeTable(res.data.timeTable); setLastUpdate(new Date()); } }).catch(() => {}); }, [agencyHash, companyHash]); // Initial load + Pusher subscription useEffect(() => { setLoading(true); API.get(`/data/agencies/${agencyHash}/services-monitoring`, { company: companyHash }).then(res => { if (res.message === 'success' && res.data) { setServices(res.data.services || []); setAgencyInfo(res.data.agency || null); setWorking(res.data.working || null); if (res.data.timeTable) setTimeTable(res.data.timeTable); setLastUpdate(new Date()); // Load client types for this agency API.get(`/data/agencies/${agencyHash}/client-types`, { company: companyHash }).then(ctRes => { if (ctRes.message === 'success' && Array.isArray(ctRes.data)) { const allTypes = ctRes.data.filter(ct => (ct.ServicesID && ct.ServicesID.length > 0) || (ct.ServicesHash && ct.ServicesHash.length > 0)); setClientTypes(allTypes); } }).catch(() => {}); // Save to recent agencies (for deep-link and direct access) const ai = res.data.agency; if (ai) saveRecentAgency({ Hash: ai.Hash, Name: ai.Name, Zone: ai.Zone, LogoURL: ai.LogoURL, ID_Company: ai.ID_Company, Company: ai.Company }, company); // Subscribe to Pusher for real-time updates const pusherConfig = res.data.pusher; if (pusherConfig?.status && pusherConfig.key) { PusherClient.init(pusherConfig.key, pusherConfig.clusterID || 'eu'); const channel = pusherConfig.subscribe; if (channel) { viewChannelRef.current = channel; PusherClient.subscribe(channel); const debouncedRefresh = () => { clearTimeout(debounceRef.current); debounceRef.current = setTimeout(refreshServices, 2000); }; viewHandlersRef.current = { debouncedRefresh, onTimetable: (data) => { if (data?.data) setWorking(data.data); } }; PusherClient.bind(channel, 'up-monitor', debouncedRefresh); PusherClient.bind(channel, 'up-waiting', debouncedRefresh); PusherClient.bind(channel, 'data-changed', debouncedRefresh); PusherClient.bind(channel, 'up-kiosk-working-timetable', viewHandlersRef.current.onTimetable); } } } setLoading(false); }).catch(() => setLoading(false)); return () => { clearTimeout(debounceRef.current); // Only unbind this view's handlers — don't unsubscribe the channel // (the ticket-level Pusher subscription may still need it) const ch = viewChannelRef.current; const h = viewHandlersRef.current; if (ch && h) { PusherClient.unbind(ch, 'up-monitor', h.debouncedRefresh); PusherClient.unbind(ch, 'up-waiting', h.debouncedRefresh); PusherClient.unbind(ch, 'data-changed', h.debouncedRefresh); PusherClient.unbind(ch, 'up-kiosk-working-timetable', h.onTimetable); } viewChannelRef.current = null; viewHandlersRef.current = null; }; }, [agencyHash]); const isOpen = working && working.code === 'open'; // Grouped client types: create virtual service cards and hide children const groupedTypes = useMemo(() => clientTypes.filter(ct => ct.DisplayMode === 'grouped'), [clientTypes]); const nonGroupedTypes = useMemo(() => clientTypes.filter(ct => ct.DisplayMode !== 'grouped'), [clientTypes]); const visibleServices = useMemo(() => { if (!groupedTypes.length) return services; const hiddenHashes = new Set(); const hiddenIds = new Set(); groupedTypes.forEach(ct => { (ct.ServicesHash || []).forEach(h => hiddenHashes.add(h)); (ct.ServicesID || []).forEach(id => hiddenIds.add(id)); }); const normal = services.filter(s => !hiddenHashes.has(s.Hash) && !hiddenHashes.has(s.Uuid_Service) && !hiddenIds.has(s.ID)); const virtual = groupedTypes.map(ct => { const ctHashes = new Set(ct.ServicesHash || []); const ctIds = new Set(ct.ServicesID || []); const children = services.filter(s => ctHashes.has(s.Hash) || ctHashes.has(s.Uuid_Service) || ctIds.has(s.ID)); const totalPeople = children.reduce((sum, s) => sum + (parseInt(s.People) || 0), 0); const abbr = ct.Abbreviation || 'AD'; return { Hash: '_grouped_' + ct.ID, ID: '_grouped_' + ct.ID, Name: ct.Name, Abbreviation: abbr, Icon: ct.Icon, People: totalPeople, WaitTime: '—', Duration: '—', StateService: 1, StateSite: 1, _isGrouped: true, _clientType: ct, _childCount: children.length }; }); return [...normal, ...virtual]; }, [services, groupedTypes]); return ( <>

{agencyInfo?.Name || agency.Name || agency.name} {isAgencyFav !== undefined && }

{agencyInfo?.Zone || agency.Zone || ''}{' '} {working && ( )} {lastUpdate && Atualizado {lastUpdate.toLocaleTimeString('pt', {hour:'2-digit',minute:'2-digit'})}}

{/* Peak Hours Chart */} {!loading && } {/* Timetable Modal */} {showTimetable && setShowTimetable(false)} />} {loading ? ( ) : services.length === 0 ? (
Nenhum serviço disponível.
) : ( <> {/* Client type chips (above services — only non-grouped types) */} {nonGroupedTypes.length > 0 && (
{nonGroupedTypes.map(ct => ( ))}
)} {/* Client type services modal */} {showTypeModal && (() => { const ct = showTypeModal; const svcIds = ct.ServicesID || []; const svcHashes = ct.ServicesHash || []; const filtered = services.filter(s => svcIds.includes(s.ID) || svcHashes.includes(s.Hash) || svcHashes.includes(s.Uuid_Service)); return (
setShowTypeModal(null)} style={{ position:'fixed', top:0, left:0, right:0, bottom:0, background:'rgba(0,0,0,0.6)', zIndex:9999, display:'flex', alignItems:'center', justifyContent:'center' }}>
e.stopPropagation()} style={{ background:'var(--d-bg-2)', borderRadius:16, maxHeight:'80vh', width:'100%', maxWidth:420, display:'flex', flexDirection:'column', overflow:'hidden' }}>

{ct.Name}

{filtered.length} {_t('webapp.services') || 'serviços'}

{filtered.map(svc => ( ))}
); })()}
{_t('webapp.queue_service')}
{_t('webapp.queue_queue')}
{_t('webapp.queue_wait')}
{_t('webapp.queue_serving')}
 
{visibleServices.map((svc, i) => { if (svc._isGrouped) { return (
setShowTypeModal(svc._clientType)} style={{ cursor: 'pointer', background: 'linear-gradient(135deg, rgba(59,130,246,0.08), rgba(55,195,134,0.08))' }}>
{svc.Abbreviation}
{svc.Name}
{svc._childCount} serviços
{svc.People || 0}
—
—
); } const people = svc.People || 0; const waitTime = svc.WaitTime || '—'; const duration = svc.Duration || '—'; const abbr = svc.Abbreviation || ''; return (
{abbr && {abbr}}
{svc.Name} {isServiceFav && svc.Hash && ( onToggleServiceFav(agencyHash, svc.Hash)} /> )} {onServiceInfo && svc.Hash && ( )}
{svc.Description &&
{svc.Description}
} {svc.Price > 0 &&
{parseInt(svc.Price).toLocaleString('pt')} {svc.PriceCurrency || 'CVE'}
} {svc.TimeService === 0 && (
{svc.TimeServiceSchedule?.length ? (_t('webapp.schedule_label') + ': ' + svc.TimeServiceSchedule.join(', ')) : _t('webapp.service_outside_schedule')}
)}
{people}
{waitTime}
{duration}
{(() => { const agcHash = agency.Hash || agency.Token || agency.hash || ''; const hasActive = hasActiveTicketForService && svc.Hash && agcHash && hasActiveTicketForService(svc.Hash, agcHash); const isBeauty = svc.CategorySlug === 'beauty'; const isOutsideSchedule = svc.TimeService === 0; return ( ); })()}
); })}
)} ); } // ─── Tickets View (real data) ─────────────────────────────── function TicketsView({ onBack, onSelectTicket, signedIn, onUploadTicket, onEvaluate, onNoShowEval, refreshKey }) { const [tickets, setTickets] = useState([]); const [loading, setLoading] = useState(true); const [tab, setTab] = useState('all'); const [cancelling, setCancelling] = useState(null); const [cancelTarget, setCancelTarget] = useState(null); const { addToast } = React.useContext(ToastContext); const fetchTickets = useCallback(() => { if (!signedIn) { setLoading(false); return; } API.get('/tickets/my').then(res => { if (res.message === 'success') { const items = (Array.isArray(res.data) ? res.data : []).map(normalizeTicket); setTickets(items); // Check for unevaluated served tickets — trigger evaluation reminder const reminded = sessionStorage.getItem('nb-webapp-eval-reminded'); if (!reminded && onEvaluate) { const unevaluated = items.filter(t => { const s = t.TicketState || t.State; return s === 3 && t.reception && t.reception.Evaluation === null; }); if (unevaluated.length > 0) { sessionStorage.setItem('nb-webapp-eval-reminded', '1'); setTimeout(() => onEvaluate(unevaluated[0]), 1500); } } } setLoading(false); }).catch(() => setLoading(false)); }, [signedIn]); useEffect(() => { fetchTickets(); }, [signedIn, refreshKey]); // Listen for Pusher-driven queue updates to refresh in real-time useEffect(() => { let debounce = null; const onUpdate = () => { clearTimeout(debounce); debounce = setTimeout(fetchTickets, 1500); }; window.addEventListener('nb-queue-updated', onUpdate); window.addEventListener('nb-ticket-state-push', onUpdate); return () => { clearTimeout(debounce); window.removeEventListener('nb-queue-updated', onUpdate); window.removeEventListener('nb-ticket-state-push', onUpdate); }; }, [fetchTickets]); const stateLabel = (t) => { const s = t.TicketState || t.State; if (s === 1) return { label: _t('webapp.tkt_state_queue'), cls: 'st-active', status: 'active' }; if (s === 6) return { label: _t('webapp.tkt_state_called'), cls: 'st-active', status: 'active' }; if (s === 7) return { label: _t('webapp.tkt_state_serving'), cls: 'st-active', status: 'active' }; if (s === 3) return { label: _t('webapp.tkt_state_served'), cls: 'st-done', status: 'done' }; if (s === 4 || s === 5) return { label: _t('webapp.tkt_state_cancelled'), cls: 'st-cancel', status: 'cancelled' }; return { label: _t('webapp.tkt_state_pending'), cls: 'st-sched', status: 'scheduled' }; }; const handleEvaluate = (e, ticket) => { e.stopPropagation(); if (onEvaluate) onEvaluate(ticket); }; const handleShare = async (e, ticket) => { e.stopPropagation(); const hash = ticket.TicketHash || (ticket.CodeGeneration || '').replace(/^ticket\s+/i, '') || ''; const base = window.location.origin + window.location.pathname.replace(/\/[^/]*$/, '/'); const ticketLink = base + 'qrcode-read?uuid=' + encodeURIComponent(hash); const ticketLabel = ticket.Number || ticket.Code || ''; if (navigator.share) { try { await navigator.share({ title: _t('webapp.tkt_share_title') + ' ' + ticketLabel, url: ticketLink }); return; } catch (err) { if (err.name === 'AbortError') return; } } try { await navigator.clipboard.writeText(ticketLink); addToast(_t('webapp.tkt_link_copied'), 'success', 3000); } catch { addToast(_t('webapp.tkt_copy_failed'), 'error'); } }; const handleCancel = (e, ticket) => { e.stopPropagation(); const state = ticket.TicketState || ticket.State; if (state === 7 || state === 3) { addToast(_t('webapp.cancel_not_allowed'), 'error'); return; } setCancelTarget(ticket); }; const confirmCancel = async (reason) => { const ticket = cancelTarget; setCancelTarget(null); if (!ticket) return; setCancelling(ticket.TicketHash); try { const res = await API.post('/tickets/remove', { ticket_hash: ticket.TicketHash, cancel_reason: reason || null }); if (res.message === 'success') { setTickets(prev => prev.filter(t => t.TicketHash !== ticket.TicketHash)); addToast(_t('webapp.ticket_cancelled'), 'success'); if (onNoShowEval) onNoShowEval(ticket); } else { addToast(res.detail || _t('webapp.ticket_remove_failed'), 'error'); } } catch { addToast(_t('webapp.network_error'), 'error'); } setCancelling(null); }; const withStatus = tickets.map(t => ({ ...t, _st: stateLabel(t) })); const counts = { all: withStatus.length, active: withStatus.filter(t => t._st.status === 'active').length, done: withStatus.filter(t => t._st.status === 'done').length, cancelled: withStatus.filter(t => t._st.status === 'cancelled').length, }; const filtered = tab === 'all' ? withStatus : tab === 'active' ? withStatus.filter(t => t._st.status === 'active') : withStatus.filter(t => t._st.status === tab); if (!signedIn) { return ( ); } return (

{_t('webapp.tkt_title')}

{_t('webapp.tkt_count_summary').replace(':total', tickets.length).replace(':active', counts.active)}

{onUploadTicket && ( )}
{[ { id: 'all', label: _t('webapp.tkt_tab_all') }, { id: 'active', label: _t('webapp.tkt_tab_active') }, { id: 'done', label: _t('webapp.tkt_tab_served') }, { id: 'cancelled', label: _t('webapp.tkt_tab_cancelled') }, ].map(t => ( ))}
{loading ? ( ) : (
{filtered.map((t, i) => { const people = t.People || 0; const progress = t._st.status === 'active' && t.People > 0 ? Math.max(5, 100 - (t.People * 5)) : 0; const isActive = t._st.status === 'active'; const isServed = (t.TicketState || t.State) === 3; const canEvaluate = isServed && t.reception; const hasRating = canEvaluate && t.reception.Evaluation !== null; const canCancel = (t.TicketState || t.State) === 1 || (t.TicketState || t.State) === 6; return (
onSelectTicket && onSelectTicket(t)} style={{ cursor: isActive ? 'pointer' : 'default' }}>
{isActive && } {t._st.label} {t.Date} {t.Time}
#{t.Code || t.Number || ''}
{t.ServiceName || ''}
{t.CategoryName && {t.CategoryName}} {t.CompanyName &&
{t.CompanyName}
}
{t.AgencyName || ''}
{isActive && (
{people} {_t('webapp.tkt_queue_ahead')}
{t.WaitTime || '—'} {_t('webapp.tkt_queue_wait')}
)} {/* Actions bar */}
{canEvaluate && ( )} {canCancel && ( )}
); })} {filtered.length === 0 && (
{_t('webapp.tkt_filter_empty')}
)}
)} {cancelTarget && ( setCancelTarget(null)} /> )}
); } // ─── Appointments View ────────────────────────────────────── function AppointmentsView({ onBack, signedIn, onSelectAppointment, selectedId, onNewBooking, refreshKey }) { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [tab, setTab] = useState('upcoming'); const [categoryFilter, setCategoryFilter] = useState('all'); // Agency picker state const [showPicker, setShowPicker] = useState(false); const [beautyAgencies, setBeautyAgencies] = useState([]); const [pickerLoading, setPickerLoading] = useState(false); const [pickerAgency, setPickerAgency] = useState(null); // selected agency → load services const [pickerServices, setPickerServices] = useState([]); const [pickerSvcLoading, setPickerSvcLoading] = useState(false); useEffect(() => { if (!signedIn) { setLoading(false); return; } setLoading(true); setShowPicker(false); setPickerAgency(null); // Load both beauty appointments and wash bookings Promise.all([ API.get('/beauty/my-appointments'), API.get('/client/wash/my-bookings'), ]).then(([aptRes, washRes]) => { const allItems = []; if (aptRes.message === 'success' && Array.isArray(aptRes.data)) { allItems.push(...aptRes.data); } if (washRes.message === 'success' && Array.isArray(washRes.data)) { // Normalize wash bookings to same shape as beauty appointments washRes.data.forEach(bk => { allItems.push({ ID: 'wash-' + bk.id, _isWash: true, Code: bk.code, DateAudience: bk.date || '', TimeAudience: bk.time ? bk.time + ':00' : '', ID_AudienceState: bk.status === 'completed' ? 3 : bk.status === 'cancelled' || bk.status === 'no_show' ? 4 : 1, ServiceName: bk.company + (bk.plate ? ' · ' + bk.plate : ''), AgencyName: bk.agency || '', AgencyHash: '', ServiceHash: '', CompanyHash: '', CompanyName: bk.company || '', CompanyLogo: '', OperatorName: '', SubjectName: '', IsPaid: bk.can_pay ? 0 : 1, Price: bk.total || 0, AllowPrepayment: bk.can_pay ? 1 : 0, PaymentTransactionId: bk.payment_transaction_id || null, CategorySlug: 'wash', Rating: null, RatingDate: null, _washRaw: bk, }); }); } // Sort: future first, then by date desc const td = new Date().toISOString().split('T')[0]; allItems.sort((a, b) => { const af = a.DateAudience >= td ? 0 : 1; const bf = b.DateAudience >= td ? 0 : 1; if (af !== bf) return af - bf; return (b.DateAudience || '').localeCompare(a.DateAudience || ''); }); setItems(allItems); setLoading(false); }).catch(() => { setLoading(false); }); }, [signedIn, refreshKey]); // Open agency picker — fetch beauty agencies const openPicker = () => { setShowPicker(true); setPickerAgency(null); setPickerServices([]); if (beautyAgencies.length > 0) return; // already loaded setPickerLoading(true); API.get('/data/agencies', { category: 'beauty' }).then(res => { if (res.message === 'success' && Array.isArray(res.data)) { setBeautyAgencies(res.data); } setPickerLoading(false); }).catch(() => setPickerLoading(false)); }; // Select agency in picker → load its services const selectPickerAgency = (ag) => { setPickerAgency(ag); setPickerSvcLoading(true); const agHash = ag.Hash || ag.hash; const companyHash = ag.ID_Company || ''; API.get(`/data/agencies/${agHash}/services-monitoring`, { company: companyHash }).then(res => { if (res.message === 'success' && res.data?.services) { setPickerServices(res.data.services); } setPickerSvcLoading(false); }).catch(() => setPickerSvcLoading(false)); }; // Select service → open booking flow const selectPickerService = (svc) => { if (!pickerAgency || !onNewBooking) return; onNewBooking({ agency: { Hash: pickerAgency.Hash || pickerAgency.hash, Name: pickerAgency.Name }, service: { Hash: svc.Hash, Name: svc.Name, Price: svc.Price, PriceCurrency: svc.PriceCurrency, PriceMode: svc.PriceMode, AllowPrepayment: svc.AllowPrepayment, CategorySlug: svc.CategorySlug }, company: pickerAgency.ID_Company ? { hash: pickerAgency.ID_Company } : null, }); setShowPicker(false); }; const today = new Date().toISOString().split('T')[0]; const stateInfo = (state) => { const s = parseInt(state); if (s === 1) return { label: _t('webapp.apt_state_confirmed'), cls: 'apt-st-waiting', status: 'upcoming' }; if (s === 2 || s === 6 || s === 7) return { label: _t('webapp.apt_state_serving'), cls: 'apt-st-active', status: 'upcoming' }; if (s === 3) return { label: _t('webapp.apt_state_done'), cls: 'apt-st-done', status: 'past' }; if (s === 4 || s === 5) return { label: _t('webapp.apt_state_cancelled'), cls: 'apt-st-cancelled', status: 'cancelled' }; return { label: _t('webapp.apt_state_pending'), cls: 'apt-st-waiting', status: 'upcoming' }; }; const catMap = { 'beauty': { icon: 'scissors', label: _t('webapp.apt_tab_beauty') || 'Barbearia' }, 'wash': { icon: 'droplet', label: _t('webapp.apt_tab_wash') || 'Lavagem' }, 'health': { icon: 'heart', label: _t('webapp.apt_tab_health') || 'Saúde' }, 'bank': { icon: 'building', label: _t('webapp.apt_tab_bank') || 'Banco' }, 'government': { icon: 'building', label: _t('webapp.apt_tab_government') || 'Instituição' }, 'education': { icon: 'book', label: _t('webapp.apt_tab_education') || 'Educação' }, 'telecom': { icon: 'phone', label: _t('webapp.apt_tab_telecom') || 'Telecomunicações' }, }; const withStatus = items.map(a => { const si = stateInfo(a.ID_AudienceState); const isPast = a.DateAudience < today && si.status === 'upcoming'; const cat = a.CategorySlug && catMap[a.CategorySlug] ? a.CategorySlug : 'appointment'; const needsPay = parseInt(a.IsPaid) === 0 && a.Price && parseFloat(a.Price) > 0 && parseInt(a.AllowPrepayment) >= 1; let finalSi = isPast ? { ...si, status: 'past', label: _t('webapp.apt_state_past') } : si; if (needsPay && !isPast && finalSi.status === 'upcoming') { finalSi = { ...finalSi, label: _t('webapp.apt_tl_pending_pay') || 'Pendente', cls: 'apt-st-pending-pay' }; } return { ...a, _si: finalSi, _category: cat }; }); const counts = { upcoming: withStatus.filter(a => a._si.status === 'upcoming').length, past: withStatus.filter(a => a._si.status === 'past').length, cancelled: withStatus.filter(a => a._si.status === 'cancelled').length, }; // Available categories (only show tabs for types that have items) const availableCategories = useMemo(() => { const cats = {}; withStatus.forEach(a => { if (!cats[a._category]) { const info = catMap[a._category] || { icon: 'ticket', label: _t('webapp.apt_tab_appointment') || 'Atendimento' }; cats[a._category] = { id: a._category, ...info }; } }); return Object.values(cats); }, [items]); const filtered = withStatus.filter(a => a._si.status === tab && (categoryFilter === 'all' || a._category === categoryFilter)); const formatDay = (d) => d ? parseInt(d.split('-')[2]) : '--'; const formatMonth = (d) => { if (!d) return ''; const months = [_t('webapp.month_jan_s'),_t('webapp.month_feb_s'),_t('webapp.month_mar_s'),_t('webapp.month_apr_s'),_t('webapp.month_may_s'),_t('webapp.month_jun_s'),_t('webapp.month_jul_s'),_t('webapp.month_aug_s'),_t('webapp.month_sep_s'),_t('webapp.month_oct_s'),_t('webapp.month_nov_s'),_t('webapp.month_dec_s')]; return months[parseInt(d.split('-')[1]) - 1] || ''; }; const formatWeekday = (d) => { if (!d) return ''; const days = [_t('webapp.day_sun_short'),_t('webapp.day_mon_short'),_t('webapp.day_tue_short'),_t('webapp.day_wed_short'),_t('webapp.day_thu_short'),_t('webapp.day_fri_short'),_t('webapp.day_sat_short')]; return days[new Date(d + 'T00:00:00').getDay()] || ''; }; // ── Agencies where user has bookings (unique by AgencyHash) ── // NOTE: must be before any conditional return to respect React hook rules const agencies = useMemo(() => { const map = {}; items.forEach(a => { if (!a.AgencyHash) return; if (!map[a.AgencyHash]) { // Collect unique services for this agency map[a.AgencyHash] = { agencyHash: a.AgencyHash, agencyName: a.AgencyName, companyHash: a.CompanyHash || null, companyName: a.CompanyName || '', companyLogo: (a.CompanyLogo && a.CompanyLogo.startsWith('http')) ? a.CompanyLogo : null, services: {}, lastDate: a.DateAudience, count: 0, }; } map[a.AgencyHash].count++; if (a.ServiceHash && !map[a.AgencyHash].services[a.ServiceHash]) { map[a.AgencyHash].services[a.ServiceHash] = { hash: a.ServiceHash, name: a.ServiceName, abbr: a.Abbreviation }; } }); // Sort by most recently booked return Object.values(map).sort((a, b) => (b.lastDate || '').localeCompare(a.lastDate || '')); }, [items]); if (!signedIn) { return ( ); } return (

{showPicker ? (pickerAgency ? pickerAgency.Name : _t('webapp.apt_pick_agency')) : _t('webapp.apt_title')}

{!showPicker &&

{_t('webapp.apt_count_summary').replace(':total', items.length).replace(':upcoming', counts.upcoming)}

} {showPicker && !pickerAgency &&

{_t('webapp.apt_pick_agency_desc')}

}
{!showPicker && onNewBooking && ( )}
{/* ═══ PICKER MODE: beauty agencies → services ═══ */} {showPicker && !pickerAgency && ( <> {/* Quick-book: agencies user has booked at before */} {agencies.length > 0 && (
{_t('webapp.apt_quick_book')}
{agencies.map(ag => { const svcs = Object.values(ag.services); return (
{ { selectPickerAgency({ Hash: ag.agencyHash, Name: ag.agencyName, ID_Company: ag.companyHash, LogoURL: ag.companyLogo, Company: ag.companyName }); } }}>
{ag.companyLogo ? {ag.companyName} : {(ag.companyName || ag.agencyName || '?').substring(0, 2).toUpperCase()} }
{ag.agencyName}
{svcs.slice(0, 3).map(s => ( {s.abbr || s.name} ))} {svcs.length > 3 && +{svcs.length - 3}}
); })}
)} {/* All beauty agencies */}
{_t('webapp.apt_all_agencies')}
{pickerLoading ? : (
{beautyAgencies.map(ag => (
selectPickerAgency(ag)}>
{ag.LogoURL ? {ag.Company} : {(ag.Company || ag.Name || '?').substring(0, 2).toUpperCase()} }
{ag.Name}
{ag.Zone &&
{ag.Zone}
}
))} {beautyAgencies.length === 0 && !pickerLoading && (
{_t('webapp.apt_no_agencies')}
)}
)}
)} {/* ═══ PICKER MODE step 2: services of selected agency ═══ */} {showPicker && pickerAgency && (
{_t('webapp.apt_pick_service')}
{pickerSvcLoading ? : (
{pickerServices.map(svc => (
selectPickerService(svc)}>
{svc.Abbreviation || (svc.Name || '').substring(0, 2).toUpperCase()}
{svc.Name}
{svc.People !== undefined && (
{svc.People} {_t('webapp.queue_queue').toLowerCase()}
)}
))} {pickerServices.length === 0 && !pickerSvcLoading && (
{_t('webapp.apt_no_services')}
)}
)}
)} {/* ═══ NORMAL MODE: appointments list ═══ */} {!showPicker && ( <>
{[ { id: 'upcoming', label: _t('webapp.apt_tab_upcoming') }, { id: 'past', label: _t('webapp.apt_tab_past') }, { id: 'cancelled', label: _t('webapp.apt_tab_cancelled_label') }, ].map(t => ( ))}
{availableCategories.length >= 1 && (
{availableCategories.map(c => ( ))}
)} {loading ? ( ) : (
{filtered.map((apt, i) => (
onSelectAppointment && onSelectAppointment(apt)} style={{ cursor: 'pointer' }}>
{formatDay(apt.DateAudience)} {formatMonth(apt.DateAudience)} {formatWeekday(apt.DateAudience)}
{apt.ServiceName || '-'}
{(apt.TimeAudience || '').substring(0, 5)} {apt.AgencyName && <>·{apt.AgencyName}}
{apt.OperatorName && (
{apt.OperatorName}
)} {apt.SubjectName && {apt.SubjectName}}
{apt._si.label} {/* Payment badge */} {apt.Price && parseFloat(apt.Price) > 0 && parseInt(apt.AllowPrepayment) >= 1 && apt._si.status !== 'cancelled' && ( {parseInt(apt.IsPaid) === 1 ? (_t('webapp.apt_paid') || 'Pago') : (_t('webapp.apt_unpaid') || 'Por pagar')} )} {/* Rating badge */} {apt.Rating && parseInt(apt.Rating) > 0 && ( ★ {parseInt(apt.Rating)}/5 · {_t('webapp.apt_rated') || 'Avaliado'} )} {/* Pending evaluation badge */} {parseInt(apt.ID_AudienceState) === 3 && (!apt.Rating || parseInt(apt.Rating) === 0) && ( ★ {_t('webapp.apt_evaluate') || 'Avaliar'} )}
))} {filtered.length === 0 && (
{tab === 'upcoming' ? _t('webapp.apt_no_upcoming') : tab === 'past' ? _t('webapp.apt_no_past') : _t('webapp.apt_no_cancelled')}
)}
)} )}
); } // ─── Appointment Detail Panel ──────────────────────────────── function AppointmentDetailPanel({ appointment, onClose, collapsed, onToggle, onEvaluate, onNoShowEval, addToast, openReceiptByTxId, setSharedReceipt }) { const [payingNow, setPayingNow] = useState(false); if (collapsed) { return ( ); } if (!appointment) return null; const apt = appointment; const state = parseInt(apt.ID_AudienceState) || 0; const today = new Date().toISOString().split('T')[0]; const isPast = apt.DateAudience < today && ![3,4,5].includes(state); const isCancelled = state === 4 || state === 5; const isDone = state === 3; const isServing = state === 2 || state === 6 || state === 7; const isConfirmed = state === 1; const isPaid = parseInt(apt.IsPaid) === 1; const hasPrice = apt.Price && parseFloat(apt.Price) > 0; const allowPrepay = parseInt(apt.AllowPrepayment) || 0; const needsPayment = hasPrice && allowPrepay >= 1 && !isPaid && !isCancelled && !isDone; // Timeline steps const timelineSteps = [ { key: 'pending', label: _t('webapp.apt_tl_pending'), icon: 'clock' }, { key: 'confirmed', label: _t('webapp.apt_tl_confirmed'), icon: 'check' }, { key: 'serving', label: _t('webapp.apt_tl_serving'), icon: 'zap' }, { key: 'done', label: _t('webapp.apt_tl_done'), icon: 'check' }, ]; const currentStep = isCancelled ? -1 : isDone ? 3 : isServing ? 2 : isConfirmed ? 1 : 0; const stateColor = isCancelled ? '#f87171' : isDone ? '#34d399' : isServing ? 'var(--nb-green)' : '#60a5fa'; const stateLabel = isCancelled ? _t('webapp.apt_state_cancelled') : isDone ? _t('webapp.apt_state_done') : isServing ? _t('webapp.apt_state_serving') : isPast ? _t('webapp.apt_state_past') : needsPayment ? _t('webapp.apt_tl_pending_pay') : _t('webapp.apt_state_confirmed'); const stateIcon = isCancelled ? 'cross' : isDone ? 'check' : isServing ? 'zap' : 'clock'; const formatDate = (d) => { if (!d) return '-'; const months = ['Janeiro','Fevereiro','Março','Abril','Maio','Junho','Julho','Agosto','Setembro','Outubro','Novembro','Dezembro']; const days = [_t('webapp.day_sun'),_t('webapp.dayfull_mon'),_t('webapp.dayfull_tue'),_t('webapp.dayfull_wed'),_t('webapp.dayfull_thu'),_t('webapp.dayfull_fri'),_t('webapp.day_sat')]; const dt = new Date(d + 'T00:00:00'); return `${days[dt.getDay()]}, ${dt.getDate()} de ${months[dt.getMonth()]}`; }; // Pay now handler const handlePayNow = async () => { setPayingNow(true); try { const returnPath = location.origin + location.pathname.replace(/\/(webapp|web)$/, '/payment-return'); const res = await API.post('/beauty/pay-appointment', { appointment_id: apt.ID, return_path: returnPath, }); if (res.message === 'success' && res.data?.redirect_html) { document.open(); document.write(res.data.redirect_html); document.close(); } else { addToast && addToast(res.detail || _t('webapp.error_generic'), 'error'); } } catch(e) { addToast && addToast(_t('webapp.eval_connection_error'), 'error'); } setPayingNow(false); }; return ( ); } // ─── Payments View (NhaBex Pay) ───────────────────────────── const PAY_CATEGORY_ICONS = { phone_android: 'phone', bolt: 'zap', water_drop: 'droplet', shield: 'shield', wifi: 'wifi', more_horiz: 'grid', }; function PaymentsView({ onBack, signedIn, addToast, onSelectService, onServiceInfo, onShowHistory, selectedServiceId }) { const [view, setPayView] = useState('grid'); // grid | services const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [selectedCat, setSelectedCat] = useState(null); const [selectedSvc, setSelectedSvc] = useState(null); const [showMeterLookup, setShowMeterLookup] = useState(false); useEffect(() => { API.get('/data/pay-services').then(res => { if (res.message === 'success' && res.data) { setCategories(Array.isArray(res.data) ? res.data : []); } setLoading(false); }).catch(() => setLoading(false)); }, []); const catIcon = (cat) => PAY_CATEGORY_ICONS[cat.Icon] || 'wallet'; const selectCategory = (cat) => { setSelectedCat(cat); setPayView('services'); if ((cat.services || []).length === 1) { selectService(cat.services[0], cat); } }; const selectService = (svc, cat) => { if (!signedIn) { addToast && addToast(_t('webapp.pay_login_required'), 'warning'); const el = document.getElementById('nb-auth-panel'); if (el) { el.classList.add('auth-panel-highlight'); setTimeout(() => el.classList.remove('auth-panel-highlight'), 1500); } return; } const c = cat || selectedCat; setSelectedSvc(svc); setSelectedCat(c); if (onSelectService) onSelectService(svc, c); }; const goBack = () => setPayView('grid'); return (

{_t('webapp.pay_title')}

{view === 'grid' &&

{_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')}

}
{view === 'services' && selectedCat?.Slug === 'electricity' && ( )} {signedIn && }
{loading ? : ( <> {/* GRID VIEW */} {view === 'grid' && (
{categories.map(cat => ( ))} {/* "Não encontrei o meu serviço" option */} {categories.length === 0 && (
{_t('webapp.pay_not_available')}
)}
)} {/* SISP Brand Logos (requisito SISP checklist #2) */} {view === 'grid' && (
Vinti4 Visa Secure Mastercard ID Check Amex SafeKey
)} {/* SERVICES LIST */} {view === 'services' && selectedCat && (
{(selectedCat.services || []).map(svc => (
selectService(svc)} style={{ cursor: 'pointer' }}>
{svc.LogoURL ? ( ) : ( )}
{svc.Name} {svc.Description && {svc.Description}}
))} {/* "Não encontrei o meu serviço" option in services list */}
selectService({ Name: _t('webapp.pay_service_not_found'), ID: null, FormType: 'entity_ref', EntityCode: '' })} style={{ cursor: 'pointer', border: '1px dashed var(--d-border, #ccc)', opacity: 0.85 }}>
{_t('webapp.pay_service_not_found')} {_t('webapp.pay_service_not_found_desc')}
)} )} {/* Modal: Consultar Referência Contador */} {showMeterLookup && (
{ if (e.target === e.currentTarget) setShowMeterLookup(false); }}>

{_t('webapp.pay_lookup_meter')}