/* ================= Small helpers ================= */
const $ = s => document.querySelector(s);
const escapeHtml = s => String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
/* ================= State & DOM ================= */
const jobs = []; // {code, status: 'QUEUED'|'REPORTING'|'COMPLETE'|'FAILED'}
const confirmMap = new Map();
let isWorking = false;
let lastQr = null, lastPlate = null;
const progress = [];
const video = $('#video');
const canvas = $('#qrCanvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const logsEl = $('#logs');
const queueEl = $('#queue');
const emptyQueue = $('#emptyQueue');
const queueCount = $('#queueCount');
const lastQrWrap = $('#lastQrWrap');
const lastPlateWrap = $('#lastPlateWrap');
const lastQrEl = $('#lastQr');
const lastPlateEl = $('#lastPlate');
const pauseBtn = $('#pauseBtn');
const resumeBtn = $('#resumeBtn');
const clearCompletedBtn = $('#clearCompleted');
const log = (s) => {
progress.push(s);
logsEl.innerHTML = progress.map(t => `
• ${escapeHtml(t)}
`).join("");
logsEl.scrollTop = logsEl.scrollHeight;
};
const setLastQr = (s) => { lastQr = s; lastQrEl.textContent = s; lastQrWrap.style.display = s ? "" : "none"; };
const setLastPlate = (s) => { lastPlate = s; lastPlateEl.textContent = s; lastPlateWrap.style.display = s ? "" : "none"; };
/* ================= Queue UI ================= */
function renderQueue(){
queueEl.innerHTML = "";
if (!jobs.length){
emptyQueue.style.display = "";
queueCount.textContent = "0 items";
return;
}
emptyQueue.style.display = "none";
queueCount.textContent = `${jobs.length} item${jobs.length>1?'s':''}`;
for (const j of jobs){
const row = document.createElement('div'); row.className='row';
const left = document.createElement('div'); left.textContent = j.code; left.style.fontWeight=600;
const right = document.createElement('div'); right.style.display='flex'; right.style.alignItems='center'; right.style.gap='8px';
const dot = document.createElement('div'); dot.className='dot';
const label = document.createElement('div');
if (j.status==='REPORTING'){ dot.style.background='var(--primary)'; label.textContent='reporting...'; }
else if (j.status==='QUEUED'){ dot.style.background='var(--warn)'; label.textContent='in queue'; }
else if (j.status==='COMPLETE'){ dot.style.background='var(--ok)'; label.textContent='complete'; }
else { dot.style.background='var(--err)'; label.textContent='failed'; }
right.append(dot,label); row.append(left,right); queueEl.append(row);
}
}
function setStatus(code, status){
const i = jobs.findIndex(j=>j.code===code);
if (i>=0){ jobs[i]={...jobs[i], status}; renderQueue(); }
}
const isSixLetterPlateCode = s => /^[A-Z]{6}$/.test(s);
function enqueuePlate(raw){
const code = raw.toUpperCase();
if (!isSixLetterPlateCode(code)) return;
const completedExists = jobs.some(j=>j.code===code && j.status==='COMPLETE');
const activeExists = jobs.some(j=>j.code===code && j.status!=='COMPLETE');
if (activeExists) return;
if (completedExists && confirmMap.get(code)!==true){
confirmMap.set(code,true);
log(`[?] ${code} already complete, scan again to confirm.`);
return;
}
jobs.push({code, status:'QUEUED'});
renderQueue();
log(`[+] queued ${code}`);
startNextIfIdle();
}
/* ================= Camera + QR (jsQR) ================= */
let scanning = true;
pauseBtn.onclick = ()=>{ scanning=false; pauseBtn.disabled=true; resumeBtn.disabled=false; };
resumeBtn.onclick = ()=>{ scanning=true; resumeBtn.disabled=true; pauseBtn.disabled=false; };
async function startCamera(){
try{
const stream = await navigator.mediaDevices.getUserMedia({ video:{ facingMode:{ ideal:'environment' } }, audio:false });
video.srcObject = stream; await video.play();
}catch(e){ log(`Camera permission/error: ${e.message || e}`); }
}
function drawFrameToCanvas(){
const vw = video.videoWidth, vh = video.videoHeight; if (!vw || !vh) return false;
const maxW = 640, maxH = 480, scale = Math.min(maxW/vw, maxH/vh);
const w = scale<1 ? Math.floor(vw*scale) : vw, h = scale<1 ? Math.floor(vh*scale) : vh;
canvas.width = w; canvas.height = h; ctx.drawImage(video, 0, 0, w, h);
return true;
}
let lastScanTime = 0; const SCAN_INTERVAL_MS = 200;
function extractLimeToken(text){ const m = text.match(/https:\/\/lime\.bike\/bc\/v1\/([\w+=/_-]+)/); return m?.[1] || null; }
function scanLoop(ts){
requestAnimationFrame(scanLoop);
if (!scanning) return;
if (ts - lastScanTime < SCAN_INTERVAL_MS) return; lastScanTime = ts;
if (!drawFrameToCanvas()) return;
try{
const img = ctx.getImageData(0,0,canvas.width,canvas.height);
const res = window.jsQR(img.data, canvas.width, canvas.height, { inversionAttempts:"attemptBoth" });
if (res && res.data && res.data !== lastQr){
setLastQr(res.data);
const scanbox = document.querySelector('.scanbox'); scanbox.style.boxShadow='0 0 0 3px rgba(46,125,50,.9) inset';
setTimeout(()=> scanbox.style.boxShadow='0 0 0 1px rgba(255,255,255,.15) inset',150);
const token = extractLimeToken(res.data);
if (token){
fetchLimePlate(token,
(plate)=>{ setLastPlate(plate); enqueuePlate(plate); },
(err)=>{ log(`[ERR] ${err}`); }
);
}
}
}catch{} // keep scanning regardless
}
/* ================= API (via your own proxy on same origin) ================= */
// Frontend calls your Vercel function at /api/proxy with a 'route' param
async function fetchJSON(route, method, payload){
const res = await fetch(`/api/proxy?route=${encodeURIComponent(route)}`, {
method,
headers:{ 'content-type':'application/json' },
body: payload ? JSON.stringify(payload) : null
});
if (!res.ok) {
// Try to extract error payload for better logging
let msg = `HTTP ${res.status}`;
try { const t = await res.text(); if (t) msg += ` — ${t.slice(0,180)}`; } catch {}
throw new Error(msg);
}
// Some upstreams might return non-JSON with application/json type;
// try JSON first, fallback to text.
const text = await res.text();
try { return JSON.parse(text); } catch { return { raw: text }; }
}
async function fetchLimePlate(token, onPlate, onError){
try{
const j = await fetchJSON('lime.preview', 'POST', { qr_code_token: token, is_group_ride:false });
const plate = j?.data?.attributes?.bike?.attributes?.plate_number;
if (plate) onPlate(plate);
else onError('parse error');
}catch(e){ onError(e.message || String(e)); }
}
/* ================= Forethought flow ================= */
async function runForethoughtFlow(code,{ onLog,onFinal,onError }){
try{
if (!isSixLetterPlateCode(code)) throw new Error('Plate must be 6 uppercase letters.');
onLog(`[*] Starting report for '${code}'`);
// Step 1
const j1 = await fetchJSON('ft.widgetConfig','POST', {
parent_url:"https://help.li.me/hc/en-gb",
raw_params:{
src:"https://solve-widget.forethought.ai/embed.js",
type:"application/javascript",
"data-ft-embed-script-language":"en",
"data-ft-language":"english",
"date-ft-tracking_email":"agatha@forethought.ai",
"data-ft-origin":"ft_web",
"offset-y":"80px",
"data-ft-workflow-tag":"english"
}
});
const conversation_id = j1.conversation_id;
if (!conversation_id) throw new Error('No conversation_id');
onLog(`[+] conversation_id: ${conversation_id}`);
// Step 2
const j2 = await fetchJSON('ft.conversation','POST', {
ai_instruction_filters_for_preview:null,
context_variables:{},
free_form_query:"get this bike removed",
conversation_id,
next_step_id:"",
origin_information:{
browser_name:"Edge", device_type:"desktop", origin:"https://help.li.me",
os:"Windows", parent_url:"https://help.li.me/hc/en-gb", solve_ui_type:"web",
user_agent:"Mozilla/5.0"
},
previous_intent_ids:[], transcript_length:null, widget_parameters:{}
});
const nextStep1 = j2.next_step_id || "";
const transcript1 = j2.transcript_length || 0;
onLog(`[+] next_step_id_1: ${nextStep1}; transcript_1: ${transcript1}`);
// Step 3
const j3 = await fetchJSON('ft.freeForm','PUT', {
conversation_id,
next_step_id: nextStep1,
query: code,
transcript_length: transcript1
});
const nextStep2 = j3.next_step_id || nextStep1;
const transcript2 = j3.transcript_length || transcript1;
onLog(`[+] submitted code; next_step_id_2: ${nextStep2}; transcript_2: ${transcript2}`);
// Step 4
const NAMES = ["mike","joel","jane","kate","emma","noah","liam","olga","nate","tess","luke","mary","paul","jack","rick","brad","eric","mark","sara","lily","sofi","zoey","liza","anna","beth","dana","gwen","iris","joan","kira"];
const name = NAMES[Math.floor(Math.random()*NAMES.length)];
const email = `${name.slice(0,2)}${Math.floor(10+Math.random()*90)}@gmail.com`;
onLog(`[+] generated identity: ${name} ${email}`);
const j4 = await fetchJSON('ft.freeForm','PUT', {
conversation_id,
next_step_id: nextStep2,
query: `${name} ${email}`,
transcript_length: transcript2
});
// Final message (not considered a hard failure if missing)
let finalMessage = "No final message returned.";
const comps = j4?.widget_components;
if (Array.isArray(comps)){
for (const c of comps){
if (c?.component_type === "text_message"){
const msg = c?.component_fields?.message;
if (msg){ finalMessage = msg; break; }
}
}
}
onFinal(finalMessage);
}catch(e){
onError(e.message || String(e));
// Propagate so the queue worker marks FAILED and frees up
throw e;
}
}
/* ================= Queue worker (never blocks) ================= */
async function startNextIfIdle(){
if (isWorking) return;
const next = jobs.find(j=>j.status==='QUEUED');
if (!next) return;
next.status='REPORTING';
renderQueue();
isWorking=true;
progress.length=0;
logsEl.innerHTML='';
try {
// Timeout guard so a hung request won't block the queue
await Promise.race([
runForethoughtFlow(next.code,{
onLog: s=>log(s),
onFinal: s=>log(`[FINAL] ${s}`),
onError: s=>log(`[ERROR] ${s}`)
}),
new Promise((_,rej)=> setTimeout(()=>rej(new Error('timeout (20s)')),20000))
]);
setStatus(next.code,'COMPLETE');
} catch(e){
setStatus(next.code,'FAILED');
log(`!! ${e.message || e}`);
} finally {
// ALWAYS free the worker and try the next job
isWorking=false;
startNextIfIdle();
}
}
/* ================= Controls ================= */
clearCompletedBtn.onclick = ()=>{
for (let i=jobs.length-1;i>=0;i--) if (jobs[i].status==='COMPLETE') jobs.splice(i,1);
renderQueue();
};
/* ================= Boot ================= */
await startCamera();
requestAnimationFrame(scanLoop);