Wifiv2 / public /engine.js
Mbonea's picture
perf(portal): respond instantly after Omada auth, record session async
1aa17d8
Raw
History Blame Contribute Delete
14.2 kB
/**
* WiFi Portal Engine
* Handles the guest payment & authorization flow.
* Requires window.PORTAL to be set before this script loads.
*/
(function () {
'use strict';
const P = window.PORTAL;
if (!P) return;
// ── DOM refs ──────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const screens = {
select: $('screen-select'),
waiting: $('screen-waiting'),
code: $('screen-code'),
success: $('screen-success'),
};
function showScreen(name) {
Object.values(screens).forEach(s => s && s.classList.remove('active'));
if (screens[name]) screens[name].classList.add('active');
}
function showError(elId, msg) {
const el = $(elId);
if (!el) return;
el.textContent = msg;
el.classList.add('show');
}
function clearError(elId) {
const el = $(elId);
if (el) { el.textContent = ''; el.classList.remove('show'); }
}
async function readJsonSafe(res) {
const text = await res.text();
try {
return text ? JSON.parse(text) : {};
} catch {
return { error: text || 'Unexpected server response' };
}
}
// ── State ─────────────────────────────────────────────────────────────────
let selectedPlanId = null;
let selectedPlanPrice = null;
let selectedPlanIsPromo = false;
let pollTimer = null;
let currentReference = null;
let purchaseInFlight = false;
// ── Plan selection ────────────────────────────────────────────────────────
const planRadios = document.querySelectorAll('input[name="plan"]');
const payBtn = $('pay-btn');
const phoneInput = $('phone-input');
function isPhoneValid() {
return phoneInput?.value?.replace(/\D/g, '').length >= 9;
}
function updatePayBtn() {
if (!payBtn) return;
if (selectedPlanId && isPhoneValid()) {
payBtn.textContent = selectedPlanIsPromo
? 'Claim promo'
: `Pay ${parseInt(selectedPlanPrice).toLocaleString()} TZS & Connect`;
payBtn.disabled = false;
} else if (selectedPlanId) {
payBtn.textContent = 'Enter your phone number';
payBtn.disabled = true;
} else {
payBtn.textContent = 'Select a plan to continue';
payBtn.disabled = true;
}
}
planRadios.forEach(radio => {
radio.addEventListener('change', () => {
selectedPlanId = radio.dataset.planId;
selectedPlanPrice = radio.dataset.planPrice;
selectedPlanIsPromo = radio.dataset.planPromo === '1' || Number(selectedPlanPrice) === 0;
updatePayBtn();
});
});
if (phoneInput) {
phoneInput.addEventListener('input', () => {
// Auto-format TZ numbers: 0765 123 456 (groups 4-3-3)
let digits = phoneInput.value.replace(/\D/g, '').slice(0, 10);
if (digits.length > 7)
digits = digits.slice(0, 4) + ' ' + digits.slice(4, 7) + ' ' + digits.slice(7);
else if (digits.length > 4)
digits = digits.slice(0, 4) + ' ' + digits.slice(4);
phoneInput.value = digits;
updatePayBtn();
});
}
// ── Pay button ────────────────────────────────────────────────────────────
if (payBtn) {
payBtn.addEventListener('click', async () => {
if (purchaseInFlight) return;
clearError('select-error');
const phone = phoneInput?.value?.trim();
if (!selectedPlanId) return showError('select-error', 'Please select a plan.');
if (!phone || phone.replace(/\D/g, '').length < 9) {
return showError('select-error', 'Enter a valid phone number.');
}
if (selectedPlanIsPromo && !P.clientMac) {
return showError('select-error', 'Open this portal from the WiFi login page on the device you want to connect.');
}
purchaseInFlight = true;
payBtn.disabled = true;
showScreen('waiting');
try {
const endpoint = selectedPlanIsPromo ? 'promo' : 'purchase';
const payload = selectedPlanIsPromo
? { planId: selectedPlanId, phone, clientMac: P.clientMac }
: { planId: selectedPlanId, phone };
const res = await fetch(`/portal/${P.siteId}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await readJsonSafe(res);
if (selectedPlanIsPromo && res.ok && data.code) {
purchaseInFlight = false;
payBtn.disabled = false;
showCodeEntry(data.code);
return;
}
if (!res.ok || !data.reference) {
showScreen('select');
purchaseInFlight = false;
payBtn.disabled = false;
return showError('select-error', data.error || (selectedPlanIsPromo ? 'Could not issue promo code. Please ask the attendant.' : 'Purchase failed. Try again.'));
}
currentReference = data.reference;
startPolling(data.reference);
} catch (err) {
showScreen('select');
purchaseInFlight = false;
payBtn.disabled = false;
showError('select-error', 'Network error. Please try again.');
}
});
}
// ── Polling ───────────────────────────────────────────────────────────────
function startPolling(reference) {
let attempts = 0;
const MAX = 100; // ~5 min at 3s
clearInterval(pollTimer);
pollTimer = setInterval(async () => {
attempts++;
if (attempts > MAX) {
clearInterval(pollTimer);
purchaseInFlight = false;
showScreen('select');
if (payBtn) payBtn.disabled = false;
showError('select-error', 'Payment timed out. Please try again.');
return;
}
try {
const res = await fetch(`/portal/${P.siteId}/check/${reference}`);
const data = await res.json();
if (data.status === 'paid' && data.code) {
clearInterval(pollTimer);
await authorizeWithCode(data.code);
} else if (data.status === 'failed') {
clearInterval(pollTimer);
purchaseInFlight = false;
showScreen('select');
if (payBtn) payBtn.disabled = false;
showError('select-error', 'Payment was declined. Please try again.');
}
} catch { /* network blip β€” keep polling */ }
}, 3000);
}
// Cancel button
const cancelBtn = $('cancel-btn');
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
clearInterval(pollTimer);
purchaseInFlight = false;
showScreen('select');
if (payBtn) payBtn.disabled = false;
});
}
// ── Auth with code ────────────────────────────────────────────────────────
async function authorizeWithCode(code) {
if (!P.clientMac) {
purchaseInFlight = false;
showScreen('code');
showError('code-error', 'This page was opened outside the WiFi login flow. Reopen the hotspot portal on the device that is joining the WiFi.');
return;
}
await authorizeViaServer(code, 'select-error');
}
// Call WiFiBiz's /portal/:siteId/omada-auth directly. WiFiBiz validates the
// voucher with Omada server-side and records the session in one step β€” no
// fragile browser→Omada redirect hop that can silently fail and leave
// WiFiBiz unaware the guest connected.
async function authorizeViaServer(code, errorElId) {
showScreen('waiting');
const waitingText = $('waiting-text');
if (waitingText) waitingText.textContent = 'Connecting your device...';
try {
const res = await fetch(`/portal/${P.siteId}/omada-auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(omadaAuthPayload(code)),
});
const data = await readJsonSafe(res);
if (res.ok && data.success) {
purchaseInFlight = false;
showSuccess(data);
// Redirect to Omada's landing URL immediately β€” this is what makes the
// captive portal detect connectivity and close the login screen. Don't
// wait; the guest is already authorized.
const target = data.landingUrl || (P.originUrl && /^https?:\/\//i.test(P.originUrl) ? P.originUrl : null);
if (target) {
window.location.href = target;
}
return;
}
// Server-side auth failed β€” fall back to the redirect approach as a
// last resort so the guest isn't stuck.
const msg = data.error || data.msg || 'Could not validate the voucher.';
showError(errorElId, msg + ' Retrying via Omada...');
setTimeout(() => redirectToOmadaAuth(code, errorElId), 2000);
} catch (err) {
// Network error talking to WiFiBiz β€” fall back to redirect.
redirectToOmadaAuth(code, errorElId);
}
}
function showSuccess(data) {
const codeEl = $('success-code');
if (codeEl) codeEl.textContent = data.code || 'β€”';
const metaEl = $('success-meta');
if (metaEl) {
const hours = data.expiresIn ? Math.ceil(data.expiresIn / 3600) : '?';
const validFor = hours === '?'
? 'Available'
: hours < 24
? `${hours} ${hours === 1 ? 'hour' : 'hours'}`
: `${Math.ceil(hours / 24)} ${Math.ceil(hours / 24) === 1 ? 'day' : 'days'}`;
metaEl.innerHTML = `
<div class="meta-row"><span class="meta-key">Time left</span><strong class="meta-val">${validFor}</strong></div>
<div class="meta-row"><span class="meta-key">Download</span><strong class="meta-val">${data.speedDown || 'Active'}</strong></div>
<div class="meta-row"><span class="meta-key">Upload</span><strong class="meta-val">${data.speedUp || 'Active'}</strong></div>
<div class="meta-row"><span class="meta-key">Reconnect</span><strong class="meta-val">Use this code</strong></div>
`;
}
showScreen('success');
}
const successCopyBtn = $('success-copy-btn');
if (successCopyBtn) {
successCopyBtn.addEventListener('click', async () => {
const code = $('success-code')?.textContent?.trim();
if (!code || code === 'β€”') return;
try {
await navigator.clipboard.writeText(code);
successCopyBtn.textContent = 'Copied';
setTimeout(() => { successCopyBtn.textContent = 'Copy code'; }, 1800);
} catch {
successCopyBtn.textContent = 'Select code';
setTimeout(() => { successCopyBtn.textContent = 'Copy code'; }, 1800);
}
});
}
const successContinueBtn = $('success-continue-btn');
if (successContinueBtn) {
successContinueBtn.addEventListener('click', () => {
const target = P.originUrl && /^https?:\/\//i.test(P.originUrl) ? P.originUrl : 'http://example.com';
window.location.href = target;
});
}
function omadaAuthPayload(code) {
return {
code,
clientMac: P.clientMac,
apMac: P.apMac,
gatewayMac: P.gatewayMac,
ssidName: P.ssidName,
radioId: P.radioId,
vid: P.vid,
originUrl: P.originUrl,
};
}
function redirectToOmadaAuth(code, errorElId) {
const returnUrl = P.omadaReturnUrl;
if (!returnUrl) {
showError(errorElId, 'Open this portal from the WiFi login page, then enter the voucher again.');
return;
}
try {
const url = new URL(returnUrl, window.location.href);
url.searchParams.set('authCode', code);
url.searchParams.set('authSource', 'wifibiz');
window.location.href = url.toString();
} catch {
showError(errorElId, 'Could not return to Omada to validate the voucher. Reopen the WiFi login page.');
}
}
// ── Code entry (reconnect) ────────────────────────────────────────────────
const showCodeEntryLink = $('show-code-entry');
const showPlansLink = $('show-plans');
const codeInput = $('code-input');
const codeSubmitBtn = $('code-submit-btn');
if (showCodeEntryLink) {
showCodeEntryLink.addEventListener('click', () => {
clearError('code-error');
showScreen('code');
});
}
if (showPlansLink) {
showPlansLink.addEventListener('click', () => showScreen('select'));
}
function showCodeEntry(prefill) {
if (codeInput && prefill) codeInput.value = prefill;
clearError('code-error');
showScreen('code');
}
if (codeInput) {
codeInput.addEventListener('input', () => {
codeInput.value = codeInput.value.replace(/[^a-zA-Z0-9]/g, '').slice(0, 32);
});
}
if (codeSubmitBtn) {
codeSubmitBtn.addEventListener('click', async () => {
clearError('code-error');
const code = codeInput?.value?.trim();
if (!code || code.length < 6) {
return showError('code-error', 'Enter the voucher code exactly as shown.');
}
if (!P.clientMac) {
return showError('code-error', 'Open this portal from the WiFi login page on the device you want to connect.');
}
codeSubmitBtn.disabled = true;
codeSubmitBtn.textContent = 'Connecting...';
try {
await authorizeViaServer(code, 'code-error');
} catch {
showError('code-error', 'Could not connect. Please try again.');
} finally {
codeSubmitBtn.disabled = false;
codeSubmitBtn.textContent = 'Connect';
}
});
}
// If clientMac is missing (portal opened in browser), warn in console only
if (!P.clientMac) {
console.warn('[WiFi Portal] No clientMac β€” running in preview mode');
}
})();