/* ================= 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);