VehnixOS
Built for motor traders
Build v6.0.1 · Login Scope Fix

VehnixOS

Built for motor traders

Your dealer toolkit, in one place.

Look up vehicles, assess deals and prepare stock records. Your stock, sales and dealer intelligence are saved securely on this device and remain available after closing the app.

Garage0
Sales0
Profit£0
Install on iPhone: open Share → Add to Home Screen.
Vehicle Lookup

Enter registration

Current connection retrieves the vehicle identity fields available from the existing lookup service.

Vehicle details

REGISTRATION
VIN
MAKE
MODEL
COLOUR
FUEL
YEAR
ENGINE

Vehicle status

Check the official MOT history, then bring the latest result back into VehnixOS.

MOT unknown
MOT STATUS
Not checked
Expiry unavailable
TAX STATUS
Not checked
Due date unavailable
FIRST REGISTERED
EMISSIONS
The official history opens on GOV.UK. Automatic retrieval will be added once approved API credentials are available.
Garage
On-device storage: Vehicles and sales now save automatically in this browser. Use JSON backup regularly before changing device or clearing browser data.

My Garage

0 vehicles saved
🚗
No vehicles yet
Add your first vehicle or send one here from Vehicle Lookup.
Deal Calculator

Purchase and selling target

Repairs and preparation

Deal result

TOTAL INVESTED£0.00
EXPECTED PROFIT£0.00
PROFIT MARGIN0.0%
MAXIMUM BUY PRICE£0.00
BREAK-EVEN£0.00
LABOUR COST£0.00
Maximum buy price uses your expected sale price, all non-purchase costs and required profit. It is an estimate, not a valuation.
Advert Builder
📝

Advert Builder is coming

This will generate Facebook, Auto Trader-style, eBay and WhatsApp adverts using your vehicle information and your own approved advert script.

Planned VehnixOS Pro feature
Business Dashboard

Garage and sales overview

Live figures saved on this device.

Current stock0
Cash invested£0
Potential profit£0
Deposits taken0
Outstanding balances£0
Awaiting collection0
Sales this month0
Profit this month£0
Completed sales0
MOT due ≤30 days0
Price reviews due0
Stock over 42 days0
Pending purchases0
Preparing0
Average days in stock0

Today's tasks

Add vehicles to Garage to generate tasks.

Recent completed sales

Completed sales will appear here.
Dealer Intelligence

Your trading performance

Built from vehicles marked Sold. The more complete your records, the smarter VehnixOS becomes.

AVERAGE PROFIT£0
AVERAGE DAYS TO SELL0
DEALER SCORE
TOTAL REALISED PROFIT£0

Best makes and models

Complete a sale to unlock insights.

Best suppliers

Add seller or purchase location to compare suppliers.

Repair trends

Repair categories will appear here.
Add and sell vehicles to receive buying recommendations based on your own history.
Account & Sync

Connect VehnixOS Cloud

Your app is fully usable locally. To share live stock across phones, connect a free Supabase project once in Settings.

Offline-first: Every change still saves immediately on the device. When connected and signed in, VehnixOS uploads the latest dealership state and automatically retries after signal returns.
Developer Console

Platform owner

This console appears only for email addresses listed in the platform configuration and verified by the database.

DEALERSHIPS
MEMBERS
STATES
YOUR ACCESS
Developer

Feature flags

Foundation prepared for controlled beta releases. Global flag editing will be enabled after subscriptions and audit logging are introduced.

Current release keeps flags read-only to prevent accidental changes affecting every dealership.
Settings

VehnixOS

VERSION5.2.0
RELEASEQuick Lookup, Smart Adverts & Secure Account
DATA STORAGEOffline-first local + optional cloud
PLATFORMWeb / installable PWA

Cloud & account

VehnixOS connects automatically using the platform configuration. Customers never need API keys.

CONNECTIONChecking…
SIGNED IN AS
DEALERSHIP
ROLE

Dealer details

Used automatically on invoices.

App mode

Choose a clean everyday setup or unlock the full sales toolkit. You can still customise every option below.

Optional sales features

Turn off anything you do not use. Existing information stays saved and reappears when switched back on.

Customise dashboard

Select only the cards you want to see.

Privacy

Vehicle, customer and dealer information is always saved locally first. When cloud is configured and you are signed in, dealership data is also synchronised securely between authorised members. JSON backup remains available as an additional safety copy.

Release notes

  • Rebranded to VehnixOS.
  • Removed the stored-image feature.
  • Introduced Garage, Deal Calculator, Dashboard Preview and Settings.
  • Preserved the working registration lookup.
  • Added an in-session multi-vehicle Garage with edit, repair, sale and dashboard workflows.
  • Added official MOT, tax and MOT-history shortcuts plus status fields ready for API integration.
  • Added Garage JSON backup and restore for safer testing before cloud accounts.
  • Added a guided MOT-history workflow, expiry reminders and MOT fields that transfer into Garage.
  • Added CSV export for opening current session records in Excel.
  • Prepared the interface for accounts and permanent cloud stock.
  • Added the under-30-second Quick Add workflow with registration auto-fill and conditional Yes/No cost fields.
  • Added automatic days-in-stock tracking and recurring 14-day price-review alerts.
  • Added complete sales stages, individual payment history, outstanding balance tracking and collection status.
  • Added retail/trade sale type, warranty details, sale notes and improved professional invoices.
  • Added monthly sales, monthly profit, deposits, balances, collection tasks and recent sales to Dashboard.
  • Added Standard, Pro and fully custom app modes.
  • Added optional payments, balances, customer, collection, warranty, invoice and intelligence features.
  • Added a fully editable dashboard with per-card visibility controls.
`);w.document.close();$('invoiceStatus').textContent='Invoice opened ready to print or save as PDF.';$('invoiceStatus').className='status ok'} $('printInvoiceBtn').addEventListener('click',printInvoice); function renderIntelligence(){const sold=garageRecords.filter(r=>statusGroup(r)==='sold');const total=sold.reduce((a,r)=>a+n(r.actualProfit),0);const avg=sold.length?total/sold.length:0;const avgDays=sold.length?sold.reduce((a,r)=>a+daysBetween(r.buyDate,r.soldDate),0)/sold.length:0;$('iAvgProfit').textContent=money(avg);$('iAvgDays').textContent=sold.length?Math.round(avgDays):0;$('iTotalProfit').textContent=money(total);let score='—';if(sold.length){const margin=sold.reduce((a,r)=>a+(n(r.actualSale)?n(r.actualProfit)/n(r.actualSale)*100:0),0)/sold.length;score=Math.max(1,Math.min(100,Math.round(55+margin*1.5-Math.max(0,avgDays-30)*.35)))}$('iScore').textContent=score==='—'?score:`${score}/100`;const group=(key)=>{const m={};sold.forEach(r=>{const k=key(r)||'Unknown';m[k]??={count:0,profit:0,days:0};m[k].count++;m[k].profit+=n(r.actualProfit);m[k].days+=daysBetween(r.buyDate,r.soldDate)});return Object.entries(m).map(([name,v])=>({name,...v,avg:v.profit/v.count,avgDays:v.days/v.count})).sort((a,b)=>b.avg-a.avg)};const models=group(r=>[r.make,r.model].filter(Boolean).join(' '));$('iBestModels').innerHTML=models.length?models.slice(0,5).map(x=>`
${escapeHtml(x.name)}
${x.count} sold · ${Math.round(x.avgDays)} days avg
${money(x.avg)} avg
`).join(''):'
Complete a sale to unlock insights.
';const suppliers=group(r=>r.location);$('iSuppliers').innerHTML=suppliers.length?suppliers.slice(0,5).map(x=>`
${escapeHtml(x.name)}
${x.count} vehicle${x.count===1?'':'s'}
${money(x.avg)} avg
`).join(''):'
Add seller or purchase location to compare suppliers.
';const repairs={};sold.forEach(r=>(r.repairs||[]).forEach(x=>{const k=x.item||'Other';repairs[k]=(repairs[k]||0)+n(x.parts)+n(x.hours)*n(x.rate)}));const rr=Object.entries(repairs).sort((a,b)=>b[1]-a[1]);$('iRepairs').innerHTML=rr.length?rr.slice(0,5).map(([k,v])=>`
${escapeHtml(k)}${money(v)}
`).join(''):'
Repair categories will appear here.
';$('iRecommendation').textContent=models.length?`Your strongest recorded buy is ${models[0].name}, averaging ${money(models[0].avg)} profit in ${Math.round(models[0].avgDays)} days. Use this as a guide, not a valuation.`:'Add and sell vehicles to receive buying recommendations based on your own history.'} function loadDealerForm(){['Name','Phone','Email','Number','Address'].forEach(k=>{const el=$('dealer'+k);if(el)el.value=dealerProfile[k.toLowerCase()]||''})}$('saveDealer').addEventListener('click',()=>{dealerProfile={name:$('dealerName').value.trim(),phone:$('dealerPhone').value.trim(),email:$('dealerEmail').value.trim(),number:$('dealerNumber').value.trim(),address:$('dealerAddress').value.trim()};persistDealer();$('dealerStatus').textContent='Dealer details saved for invoices.';$('dealerStatus').className='status ok'}); const baseDashboard=renderDashboard;renderDashboard=function(){baseDashboard();const current=garageRecords.filter(r=>statusGroup(r)==='current');const completed=garageRecords.filter(r=>(r.saleStage==='Completed')||statusGroup(r)==='sold');const deposits=garageRecords.filter(r=>r.saleStage==='Deposit Taken');const paid=r=>(r.payments||[]).reduce((a,x)=>a+n(x.amount),n(r.depositReceived)+n(r.balanceReceived));const outstanding=garageRecords.reduce((a,r)=>a+Math.max(0,n(r.actualSale)-paid(r)),0);const awaiting=garageRecords.filter(r=>['Deposit Taken','Sold'].includes(r.saleStage)&&!['Collected','Delivered'].includes(r.collectionStatus));const ym=new Date().toISOString().slice(0,7);const monthSales=completed.filter(r=>(r.soldDate||'').slice(0,7)===ym);$('dDeposits').textContent=deposits.length;$('dOutstanding').textContent=money(outstanding);$('dAwaitingCollection').textContent=awaiting.length;$('dMonthSales').textContent=monthSales.length;$('dMonthProfit').textContent=money(monthSales.reduce((a,r)=>a+n(r.actualProfit),0));awaiting.forEach(r=>{const t=document.createElement('div');t.className='task';t.innerHTML=`🔑 Awaiting collection · ${escapeHtml(r.reg)}${escapeHtml(r.customerName||'Customer not entered')} · ${r.collectionDate?escapeHtml(r.collectionDate):'Collection date missing'}`;$('dashboardTasks').appendChild(t)});garageRecords.filter(r=>Math.max(0,n(r.actualSale)-paid(r))>0&&r.actualSale).forEach(r=>{const t=document.createElement('div');t.className='task';t.innerHTML=`💳 Balance outstanding · ${escapeHtml(r.reg)}${money(Math.max(0,n(r.actualSale)-paid(r)))}`;$('dashboardTasks').appendChild(t)});$('recentSales').innerHTML=completed.length?completed.slice().sort((a,b)=>(b.soldDate||'').localeCompare(a.soldDate||'')).slice(0,6).map(r=>`
${escapeHtml(r.reg)} · ${escapeHtml([r.make,r.model].filter(Boolean).join(' '))}
${escapeHtml(r.soldDate||'Date missing')} · ${escapeHtml(r.customerName||'Customer')}
${money(r.actualSale)}
${money(r.actualProfit)} profit
`).join(''):'
Completed sales will appear here.
'}; const oldRenderDashboard=renderDashboard;renderDashboard=function(){oldRenderDashboard();const sold=garageRecords.filter(r=>statusGroup(r)==='sold');$('homeStock').textContent=garageRecords.filter(r=>statusGroup(r)==='current').length;$('homeSales').textContent=sold.length;$('homeProfit').textContent=money(sold.reduce((a,r)=>a+n(r.actualProfit),0));renderIntelligence()}; document.querySelectorAll('[data-app-mode]').forEach(b=>b.addEventListener('click',()=>setMode(b.dataset.appMode)));document.querySelectorAll('[data-feature]').forEach(i=>i.addEventListener('change',()=>{appSettings.features[i.dataset.feature]=i.checked;appSettings.mode='custom';persistAppSettings();applyAppSettings();$('appSettingsStatus').textContent='Sales preferences saved.';$('appSettingsStatus').className='status ok'}));$('dashboardSettings').addEventListener('change',e=>{const i=e.target.closest('[data-dashboard-setting]');if(!i)return;appSettings.dashboard=i.checked?[...new Set([...appSettings.dashboard,i.dataset.dashboardSetting])]:appSettings.dashboard.filter(x=>x!==i.dataset.dashboardSetting);persistAppSettings();applyAppSettings();$('appSettingsStatus').textContent='Dashboard updated.';$('appSettingsStatus').className='status ok'});$('resetDashboard').addEventListener('click',()=>{appSettings.dashboard=[...DEFAULT_DASHBOARD];persistAppSettings();renderSettingsControls();renderDashboard();});loadAppSettings();renderSettingsControls(); loadGarage();loadBusinessData();loadDealer();loadDealerForm();clearEditor();paintLookup();renderGarageList();renderCalc();renderDashboard();window.addEventListener('load',async()=>{try{if('serviceWorker'in navigator){const regs=await navigator.serviceWorker.getRegistrations();await Promise.all(regs.map(r=>r.unregister()))}if('caches'in window){const keys=await caches.keys();await Promise.all(keys.map(k=>caches.delete(k)))}}catch(e){console.warn('Cache cleanup',e)}});} /* VehnixOS v2.0 Beta — optional Supabase cloud sync */ const CLOUD_CONFIG_KEY='vehnixos.cloud.config.v1', CLOUD_SESSION_KEY='vehnixos.cloud.session.v1', CLOUD_META_KEY='vehnixos.cloud.meta.v1'; let cloudConfig={url:(window.VEHNIX_CONFIG?.supabaseUrl||'').trim(),anonKey:(window.VEHNIX_CONFIG?.supabaseAnonKey||'').trim()},cloudSession=null,cloudDealer=null,cloudRole='',cloudDirty=false,cloudTimer=null,authMode='login',cloudApplying=false; function loadCloudLocal(){const deployed={url:(window.VEHNIX_CONFIG?.supabaseUrl||'').trim(),anonKey:(window.VEHNIX_CONFIG?.supabaseAnonKey||'').trim()};cloudConfig=deployed.url&&deployed.anonKey?deployed:{};try{cloudSession=JSON.parse(localStorage.getItem(CLOUD_SESSION_KEY)||'null')}catch(e){cloudSession=null}try{const m=JSON.parse(localStorage.getItem(CLOUD_META_KEY)||'{}');cloudDirty=!!m.dirty}catch(e){}} function saveCloudMeta(extra={}){const old=(()=>{try{return JSON.parse(localStorage.getItem(CLOUD_META_KEY)||'{}')}catch(e){return{}}})();localStorage.setItem(CLOUD_META_KEY,JSON.stringify({...old,dirty:cloudDirty,...extra}))} function cloudReady(){return /^https:\/\/.+\.supabase\.co\/?$/.test((cloudConfig.url||'').trim())&&!!cloudConfig.anonKey} function authHeaders(json=true){const h={apikey:cloudConfig.anonKey,Authorization:`Bearer ${cloudSession?.access_token||cloudConfig.anonKey}`};if(json)h['Content-Type']='application/json';return h} async function cloudFetch(path,opts={}){if(!cloudReady())throw new Error('Cloud is not configured.');const res=await fetch(cloudConfig.url.replace(/\/$/,'')+path,{...opts,headers:{...authHeaders(opts.json!==false),...(opts.headers||{})}});let body=null;const text=await res.text();if(text)try{body=JSON.parse(text)}catch(e){body=text}if(!res.ok)throw new Error(body?.msg||body?.message||body?.error_description||body?.hint||`Cloud error ${res.status}`);return body} async function refreshCloudToken(){if(!cloudSession?.refresh_token)return false;try{const d=await cloudFetch('/auth/v1/token?grant_type=refresh_token',{method:'POST',body:JSON.stringify({refresh_token:cloudSession.refresh_token})});cloudSession=d;localStorage.setItem(CLOUD_SESSION_KEY,JSON.stringify(d));return true}catch(e){return false}} async function ensureToken(){if(!cloudSession)return false;const exp=(cloudSession.expires_at||0)*1000;if(exp&&Date.now()>exp-60000)return refreshCloudToken();return true} function cloudPayload(){return{schema:3,garageRecords,customerRecords,expenseRecords,taskRecords,advertRecords,dealerProfile,appSettings,exportedAt:new Date().toISOString()}} function markCloudDirty(){if(cloudApplying||!cloudDealer)return;cloudDirty=true;saveCloudMeta();updateCloudUI();clearTimeout(cloudTimer);cloudTimer=setTimeout(()=>pushCloudState(false),1800)} const localPersistGarage=persistGarage,localPersistDealer=persistDealer,localPersistAppSettings=persistAppSettings; persistGarage=function(){localPersistGarage();markCloudDirty()};persistDealer=function(){localPersistDealer();markCloudDirty()};persistAppSettings=function(){localPersistAppSettings();markCloudDirty()}; async function getMembership(){await ensureToken();if(!cloudSession?.user?.id)throw new Error('Your login session could not be restored. Please sign in again.');const rows=await cloudFetch('/rest/v1/dealer_members?select=dealer_id,role,dealers(id,name)&user_id=eq.'+encodeURIComponent(cloudSession.user.id),{method:'GET'});if(rows?.length){cloudDealer={id:rows[0].dealer_id,name:rows[0].dealers?.name||'Dealership'};cloudRole=rows[0].role||'member'}else{cloudDealer=null;cloudRole=''}updateCloudUI();return cloudDealer} async function pushCloudState(manual=true){if(!cloudDealer||!navigator.onLine)return false;try{await ensureToken();const body={dealer_id:cloudDealer.id,payload:cloudPayload(),updated_by:cloudSession.user.id,updated_at:new Date().toISOString()};await cloudFetch('/rest/v1/dealer_state?on_conflict=dealer_id',{method:'POST',headers:{Prefer:'resolution=merge-duplicates,return=minimal'},body:JSON.stringify(body)});cloudDirty=false;saveCloudMeta({lastSync:new Date().toISOString()});updateCloudUI();if(manual)setStatus('syncStatus','Everything is synced across authorised devices.','ok');return true}catch(e){cloudDirty=true;saveCloudMeta();updateCloudUI();if(manual)setStatus('syncStatus',e.message,'err');return false}} async function pullCloudState(confirmFirst=true){if(!cloudDealer)return;if(confirmFirst&&!confirm('Replace this device’s current VehnixOS data with the latest cloud copy?'))return;try{await ensureToken();const rows=await cloudFetch('/rest/v1/dealer_state?select=payload,updated_at&dealer_id=eq.'+cloudDealer.id+'&limit=1',{method:'GET'});if(!rows?.length){await pushCloudState(false);setStatus('syncStatus','First cloud copy created from this device.','ok');return}const x=rows[0].payload||{};cloudApplying=true;if(Array.isArray(x.garageRecords))garageRecords=x.garageRecords;if(Array.isArray(x.customerRecords))customerRecords=x.customerRecords;if(Array.isArray(x.expenseRecords))expenseRecords=x.expenseRecords;if(Array.isArray(x.taskRecords))taskRecords=x.taskRecords;if(Array.isArray(x.advertRecords))advertRecords=x.advertRecords;if(x.dealerProfile)dealerProfile=x.dealerProfile;if(x.appSettings)appSettings={...appSettings,...x.appSettings,features:{...appSettings.features,...(x.appSettings.features||{})}};localPersistGarage();persistBusinessData();localPersistDealer();localPersistAppSettings();cloudApplying=false;cloudDirty=false;saveCloudMeta({lastSync:rows[0].updated_at||new Date().toISOString()});loadDealerForm();renderSettingsControls();renderGarageList();renderDashboard();updateHomeMetrics();updateCloudUI();setStatus('syncStatus','Latest dealership data restored from cloud.','ok')}catch(e){cloudApplying=false;setStatus('syncStatus',e.message,'err')}} function setStatus(id,msg,type=''){const el=$(id);if(el){el.textContent=msg;el.className='status'+(type?' '+type:'')}} function updateCloudUI(){const configured=cloudReady(),signed=!!cloudSession?.access_token,joined=!!cloudDealer;$('cloudNotConfigured')?.classList.toggle('hidden',configured);$('authCard')?.classList.toggle('hidden',!configured||signed);$('accountCard')?.classList.toggle('hidden',!signed);$('dealerOnboarding')?.classList.toggle('hidden',!signed||joined);$('teamCard')?.classList.toggle('hidden',!signed||!joined||!['owner','manager'].includes(cloudRole));if($('accountEmail'))$('accountEmail').textContent=cloudSession?.user?.email||'';if($('cloudDealerName'))$('cloudDealerName').textContent=cloudDealer?.name||'Not connected';if($('cloudRole'))$('cloudRole').textContent=cloudRole?cloudRole[0].toUpperCase()+cloudRole.slice(1):'—';let m={};try{m=JSON.parse(localStorage.getItem(CLOUD_META_KEY)||'{}')}catch(e){}if($('lastSync'))$('lastSync').textContent=m.lastSync?new Date(m.lastSync).toLocaleString('en-GB'):'Never';if($('dirtyStatus'))$('dirtyStatus').textContent=cloudDirty?'Waiting to sync':'Saved';const badge=$('syncBadge');if(badge){badge.className='cloud-pill '+(joined&&navigator.onLine?'online':'warn')+' nav';badge.dataset.page='account';badge.textContent=joined?(navigator.onLine?(cloudDirty?'● Syncing…':'● Cloud synced'):'● Offline'):'● Local only'}if($('cloudUrl'))$('cloudUrl').value=cloudConfig.url||'';if($('cloudAnonKey'))$('cloudAnonKey').value=cloudConfig.anonKey||''} function gateStep(id,state){const e=$(id);if(!e)return;e.className='gate-step '+state;e.textContent=(state==='done'?'✓ ':state==='active'?'◌ ':'○ ')+e.textContent.replace(/^[✓◌○]\s*/,'')} function showGateForm(msg=''){document.body.classList.add('portal-locked');$('authGate')?.classList.remove('hidden');$('gateAuthForm')?.classList.remove('hidden');$('gateLoading')?.classList.add('hidden');if(msg)setStatus('gateStatus',msg,'err')} function openPortal(){ document.body.classList.remove('portal-locked'); const gate=document.getElementById('authGate'); if(gate)gate.classList.add('hidden'); const home=document.getElementById('home'); document.querySelectorAll('main.page').forEach(page=>page.classList.toggle('active',page===home)); try{window.scrollTo({top:0,behavior:'auto'})}catch(e){} if(typeof renderDashboard==='function')renderDashboard(); if(typeof updateHomeMetrics==='function')updateHomeMetrics(); } function normalizeCloudSession(session){ if(!session?.access_token)return session; let payload={}; try{payload=decodeJwtPayload(session.access_token)}catch(e){} const user={...(session.user||{})}; user.id=user.id||payload.sub||''; user.email=user.email||payload.email||''; return {...session,user,expires_at:Number(session.expires_at||payload.exp||Math.floor(Date.now()/1000)+Number(session.expires_in||3600))}; } async function completeLoginFlow(sessionOverride=null){ try{ cloudSession=normalizeCloudSession(sessionOverride||cloudSession); if(!cloudSession?.access_token)throw new Error('Supabase did not return a valid access token. Please sign in again.'); // Always ask Supabase for the authoritative user instead of relying on a decoded token shape. if(!cloudSession?.user?.id){ try{ const user=await cloudFetch('/auth/v1/user',{method:'GET',json:false}); if(user?.id)cloudSession={...cloudSession,user}; }catch(userError){console.warn('User hydration failed',userError)} } if(!cloudSession?.user?.id)throw new Error('Your account signed in, but Supabase did not return the user profile. Please sign out and retry.'); try{localStorage.setItem(CLOUD_SESSION_KEY,JSON.stringify(cloudSession))}catch(e){} $('gateAuthForm')?.classList.add('hidden'); $('gateLoading')?.classList.remove('hidden'); gateStep('loadAuth','done'); // Open the product immediately after authentication using direct DOM changes only. document.body.classList.remove('portal-locked'); const authGateEl=document.getElementById('authGate'); if(authGateEl)authGateEl.classList.add('hidden'); const homeEl=document.getElementById('home'); document.querySelectorAll('main.page').forEach(page=>page.classList.toggle('active',page===homeEl)); try{window.scrollTo({top:0,behavior:'auto'})}catch(e){} if(typeof renderDashboard==='function')renderDashboard(); if(typeof updateHomeMetrics==='function')updateHomeMetrics(); gateStep('loadDealer','active'); try{await getMembership()}catch(membershipError){console.warn('Dealership membership could not be loaded',membershipError);cloudDealer=null;cloudRole='';updateCloudUI()} gateStep('loadDealer','done'); gateStep('loadData','active'); try{if(cloudDealer){if(cloudDirty)await pushCloudState(false);else await pullCloudState(false)}}catch(syncError){console.warn('Initial cloud sync failed',syncError)} gateStep('loadData','done'); gateStep('loadOpen','active'); try{await loadTeamMembers()}catch(e){console.warn('Team members could not be loaded',e)} try{await checkDeveloperAccess()}catch(e){console.warn('Developer access check failed',e)} gateStep('loadOpen','done'); }catch(e){ cloudSession=null; localStorage.removeItem(CLOUD_SESSION_KEY); showGateForm(e.message||'Your account could not be opened. Please sign in again.'); throw e; } } async function initCloud(){loadCloudLocal();updateCloudUI();if(!cloudReady()){showGateForm();$('gateConfigWarning')?.classList.remove('hidden');return}if(cloudSession){try{if(await ensureToken()){await completeLoginFlow();return}}catch(e){console.warn('Cloud init',e)}}showGateForm()} document.addEventListener('click',e=>{const nav=e.target.closest('.nav');if(nav&&nav.dataset.page)setTimeout(()=>{if(nav.dataset.page==='account')updateCloudUI()},0)}); document.querySelectorAll('[data-auth-mode]').forEach(b=>b.addEventListener('click',()=>{authMode=b.dataset.authMode;document.querySelectorAll('[data-auth-mode]').forEach(x=>x.classList.toggle('active',x===b));$('authTitle').textContent=authMode==='login'?'Sign in':'Create account';$('authSubmit').textContent=authMode==='login'?'Sign in':'Create account';$('signupNameWrap').classList.toggle('hidden',authMode==='login')})); $('authSubmit')?.addEventListener('click',async()=>{const email=$('authEmail').value.trim(),password=$('authPassword').value;if(!email||password.length<6)return setStatus('authStatus','Enter an email and a password of at least 6 characters.','err');try{setStatus('authStatus',authMode==='login'?'Signing in…':'Creating account…');const path=authMode==='login'?'/auth/v1/token?grant_type=password':'/auth/v1/signup';const body={email,password};if(authMode==='signup')body.data={name:$('authName').value.trim()};const d=await cloudFetch(path,{method:'POST',body:JSON.stringify(body)});if(!d.access_token){setStatus('authStatus','Account created. Check your email to confirm it, then sign in.','ok');return}cloudSession=d;localStorage.setItem(CLOUD_SESSION_KEY,JSON.stringify(d));await getMembership();updateCloudUI();setStatus('authStatus','Signed in.','ok')}catch(e){setStatus('authStatus',e.message,'err')}}); $('signOutBtn')?.addEventListener('click',async()=>{try{await cloudFetch('/auth/v1/logout',{method:'POST'})}catch(e){}cloudSession=null;cloudDealer=null;cloudRole='';localStorage.removeItem(CLOUD_SESSION_KEY);updateCloudUI()}); $('createDealerBtn')?.addEventListener('click',async()=>{const name=$('newDealerName').value.trim();if(!name)return setStatus('onboardingStatus','Enter a dealership name.','err');try{const d=await cloudFetch('/rest/v1/rpc/create_dealer',{method:'POST',body:JSON.stringify({dealer_name:name})});await getMembership();dealerProfile.name=dealerProfile.name||name;localPersistDealer();await pushCloudState(false);setStatus('onboardingStatus','Dealership created and this device is synced.','ok')}catch(e){setStatus('onboardingStatus',e.message,'err')}}); $('joinDealerBtn')?.addEventListener('click',async()=>{const code=$('joinCode').value.trim().toUpperCase();if(!code)return setStatus('onboardingStatus','Enter the invite code.','err');try{await cloudFetch('/rest/v1/rpc/join_dealer',{method:'POST',body:JSON.stringify({invite_code:code})});await getMembership();await pullCloudState(false);setStatus('onboardingStatus','Joined dealership. Shared data has been loaded.','ok')}catch(e){setStatus('onboardingStatus',e.message,'err')}}); $('createInviteBtn')?.addEventListener('click',async()=>{try{const d=await cloudFetch('/rest/v1/rpc/create_dealer_invite',{method:'POST',body:JSON.stringify({member_role:$('inviteRole').value})});const code=typeof d==='string'?d:d?.code||d;$('inviteCodeBox').textContent=code;$('inviteCodeBox').classList.remove('hidden');setStatus('teamStatus','Share this code privately. It expires after 7 days and can be used once.','ok')}catch(e){setStatus('teamStatus',e.message,'err')}}); $('syncNowBtn')?.addEventListener('click',()=>pushCloudState(true));$('pullCloudBtn')?.addEventListener('click',()=>pullCloudState(true)); $('saveCloudConfig')?.addEventListener('click',()=>{const url=$('cloudUrl').value.trim().replace(/\/$/,''),key=$('cloudAnonKey').value.trim();if(!/^https:\/\/.+\.supabase\.co$/.test(url)||key.length<20)return setStatus('cloudConfigStatus','Enter a valid Supabase project URL and anon/publishable key.','err');cloudConfig={url,anonKey:key};localStorage.setItem(CLOUD_CONFIG_KEY,JSON.stringify(cloudConfig));setStatus('cloudConfigStatus','Cloud connection saved. Open Account & Sync to sign in.','ok');updateCloudUI()}); $('clearCloudConfig')?.addEventListener('click',()=>{if(!confirm('Disconnect cloud on this device? Local VehnixOS data will remain.'))return;cloudConfig={};cloudSession=null;cloudDealer=null;localStorage.removeItem(CLOUD_CONFIG_KEY);localStorage.removeItem(CLOUD_SESSION_KEY);updateCloudUI();setStatus('cloudConfigStatus','Cloud disconnected. Local data is unchanged.','ok')}); window.addEventListener('online',()=>{updateCloudUI();if(cloudDirty)pushCloudState(false)});window.addEventListener('offline',updateCloudUI); async function signOutEverywhere(){try{await cloudFetch('/auth/v1/logout',{method:'POST'})}catch(e){}cloudSession=null;cloudDealer=null;cloudRole='';localStorage.removeItem(CLOUD_SESSION_KEY);updateCloudUI();showGateForm()} $('settingsSignOut')?.addEventListener('click',signOutEverywhere);$('settingsSyncNow')?.addEventListener('click',()=>pushCloudState(true)); async function loadTeamMembers(){const box=$('teamMemberList');if(!box||!cloudDealer||!['owner','manager'].includes(cloudRole)){if(box)box.innerHTML='';return}try{const rows=await cloudFetch('/rest/v1/dealer_members?select=user_id,role,created_at&dealer_id=eq.'+cloudDealer.id+'&order=created_at.asc',{method:'GET'});box.innerHTML=(rows||[]).map((m,i)=>`
${i===0?'Owner account':'Team member'}${m.user_id===cloudSession?.user?.id?'You · ':''}${m.user_id.slice(0,8)}…
${m.role}
`).join('')||'
No team members found.
'}catch(e){box.innerHTML='
'+esc(e.message)+'
'}} async function checkDeveloperAccess(){const allowed=(window.VEHNIX_CONFIG?.developerEmails||[]).map(x=>x.toLowerCase());const email=(cloudSession?.user?.email||'').toLowerCase();let ok=allowed.includes(email);if(ok){try{const rows=await cloudFetch('/rest/v1/platform_admins?select=user_id&user_id=eq.'+cloudSession.user.id+'&limit=1',{method:'GET'});ok=!!rows?.length}catch(e){ok=false}}document.body.classList.toggle('developer-mode',ok);if(ok)loadDevStats()} async function loadDevStats(){if(!document.body.classList.contains('developer-mode'))return;try{const d=await cloudFetch('/rest/v1/rpc/platform_stats',{method:'POST',body:'{}'});$('devDealers').textContent=d?.dealers??'—';$('devMembers').textContent=d?.members??'—';$('devStates').textContent=d?.states??'—';setStatus('devStatus','Platform statistics refreshed.','ok')}catch(e){setStatus('devStatus',e.message,'err')}} $('refreshDevStats')?.addEventListener('click',loadDevStats); const baseUpdateCloudUI=updateCloudUI;updateCloudUI=function(){baseUpdateCloudUI();if($('settingsCloudStatus'))$('settingsCloudStatus').textContent=cloudReady()?(navigator.onLine?'Connected':'Offline'):'Not configured';if($('settingsAccountEmail'))$('settingsAccountEmail').textContent=cloudSession?.user?.email||'—';if($('settingsDealerName'))$('settingsDealerName').textContent=cloudDealer?.name||'—';if($('settingsDealerRole'))$('settingsDealerRole').textContent=cloudRole||'—'}; function decodeJwtPayload(token){ const part=(token.split('.')[1]||'').replace(/-/g,'+').replace(/_/g,'/'); const padded=part+'='.repeat((4-part.length%4)%4); return JSON.parse(atob(padded)); } function consumeAuthCallback(){ return false; } // Authentication callbacks are processed by auth-gate-fix.js v3.0.2. window.openPortal=openPortal;window.completeLoginFlow=completeLoginFlow;window.VEHNIX_SET_SESSION=function(session){cloudSession=normalizeCloudSession(session);try{localStorage.setItem(CLOUD_SESSION_KEY,JSON.stringify(cloudSession))}catch(e){};return cloudSession;}; // Authentication bootstrap is handled by auth-gate-fix.js v3.0.0