({id:'demo-diary-'+i,user_id:_DEMO_UID,date:d,location:locs[i%locs.length],opening:full(4),closing:full(4),issues:i===2?'Fridge 1 slightly warm at open (9°C) — adjusted thermostat, back to 4°C within 20 min.':'',created_at:iso(d,8,15)}));
const temps=[];
D.forEach((d,di)=>{
const appl=[{unit:'Fridge 1',type:'cold',th:8,am:4,pm:5},{unit:'Freezer',type:'cold',th:-18,am:-19,pm:-18},{unit:'Hot Hold',type:'hot',th:63,am:71,pm:67}];
appl.forEach((r,ri)=>{
[{h:9,m:5,v:r.am},{h:15,m:30,v:r.pm}].forEach((slot,si)=>{
let temp=slot.v, action=null;
if(di===3&&ri===2&&si===0) temp=58; // one instructive failed hot-hold reading + corrective action
const pass=r.type==='cold'?temp<=r.th:temp>=r.th;
if(!pass) action='Reheated to 75°C and held above 63°C — re-checked OK.';
temps.push({id:'demo-temp-'+di+'-'+ri+'-'+si,user_id:_DEMO_UID,date:d,time:p2(slot.h)+':'+p2(slot.m),unit:r.unit,type:r.type,threshold:r.th,temp,pass,action,created_at:iso(d,slot.h,slot.m)});
});
});
});
const cleaning_logs=D.map((d,i)=>({id:'demo-clean-'+i,user_id:_DEMO_UID,date:d,checks:full(8),saved_by:'Sam Rivera',created_at:iso(d,16,45)}));
const van_checks=D.map((d,i)=>({id:'demo-van-'+i,user_id:_DEMO_UID,date:d,checks:full(8),signed_by:'Sam Rivera',created_at:iso(d,8,30)}));
const lpg_logs=D.slice(0,5).map((d,i)=>({id:'demo-lpg-'+i,user_id:_DEMO_UID,date:d,hose:true,reg:true,flame:true,leak:true,notes:i===0?'Leak-tested all joints with detergent spray — no bubbles.':'',created_at:iso(d,8,20)}));
const water_logs=D.slice(0,3).map((d,i)=>({id:'demo-water-'+i,user_id:_DEMO_UID,date:d,filled:true,cleaned:true,san:true,sanitised:true,created_at:iso(d,8,25)}));
const pNames=['Borough Market','Ropewalk','Kings Cross','Camden Lock','Brick Lane','Greenwich Market'];
const pAddrs=['8 Southwark St, London SE1','Ropewalk, Bristol BS1','Kings Blvd, London N1C','Camden Lock Pl, London NW1','91 Brick Ln, London E1','Greenwich, London SE10'];
const pitch_logs=D.map((d,i)=>({id:'demo-pitch-'+i,user_id:_DEMO_UID,date:d,name:pNames[i%6],address:pAddrs[i%6],created_at:iso(d,7,50)}));
const data={profiles:[_DEMO_PROFILE],diaries,temp_logs:temps,cleaning_logs,van_checks,lpg_logs,water_logs,pitch_logs,feedback:[]};
window.__vgDemoData=data;
return data;
};
class _DemoQ {
constructor(t){this.t=t;this.rows=(_demoBuild()[t]||[]).map(x=>x);this.cnt=false;this.hd=false;this.sg=false;this.mb=false;this._lim=null;}
select(c='*',o){o=o||{};if(o.count)this.cnt=true;if(o.head)this.hd=true;return this;}
eq(c,v){this.rows=this.rows.filter(r=>String(r[c])===String(v));return this;}
gte(c,v){this.rows=this.rows.filter(r=>String(r[c])>=String(v));return this;}
order(c,o){const asc=!(o&&o.ascending===false);this.rows=this.rows.slice().sort((a,b)=>a[c]===b[c]?0:(a[c]>b[c]?1:-1)*(asc?1:-1));return this;}
limit(n){this._lim=n;return this;}
single(){this.sg=true;return this;}
maybeSingle(){this.mb=true;return this;}
_run(){let rows=this.rows;if(this._lim!=null)rows=rows.slice(0,this._lim);if(this.cnt||this.hd)return Promise.resolve({count:rows.length,data:null,error:null});if(this.mb||this.sg)return Promise.resolve({data:rows[0]||null,error:null});return Promise.resolve({data:rows,error:null});}
then(res,rej){return this._run().then(res,rej);}
}
class _DemoU {
constructor(t){this.t=t;}
eq(){return this;} select(){return this;} single(){return this;}
_run(){return Promise.resolve({data:this.t==='profiles'?_DEMO_PROFILE:null,error:null});}
then(res,rej){return this._run().then(res,rej);}
}
const _demoFrom=table=>({
select:(c,o)=>new _DemoQ(table).select(c,o),
insert:async()=>({data:null,error:null}),
upsert:async()=>({data:null,error:null}),
update:()=>new _DemoU(table),
});
const sb = {
auth: {
getSession: async () => ({data:{session:_getS()},error:null}),
signUp: async ({email,password,options}) => {
// Pass name/business/referral as user_metadata so the DB trigger populates the
// profile reliably — works whether or not email confirmation is on.
const r=await fetch(`${SUPA_URL}/auth/v1/signup`,{method:'POST',headers:{'apikey':SUPA_KEY,'Content-Type':'application/json'},body:JSON.stringify({email,password,data:options?.data||{}})});
const d=await r.json();
if(!r.ok || d.error || d.error_description || (d.msg && !d.id && !d.access_token)){
return {error:{message:d.error_description||d.msg||d.message||'Sign up failed. Please try again.'}};
}
const uid=d.user?.id||d.id;
// Email confirmation ON → no session yet. Profile is created by the trigger.
if(!d.access_token){
return {data:{session:null,user:d.user||d},error:null};
}
// Autoconfirm → we have a session. (Trigger also creates the profile; this is a safe fallback.)
await fetch(`${SUPA_URL}/rest/v1/profiles`,{method:'POST',headers:{..._h(d.access_token),'Prefer':'resolution=merge-duplicates,return=minimal'},body:JSON.stringify({id:uid,name:options?.data?.name,business_name:options?.data?.businessName})}).catch(()=>{});
fetch('/.netlify/functions/send-welcome',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({firstName:options?.data?.name,email:(d.user||d).email})}).catch(()=>{});
const s={access_token:d.access_token,refresh_token:d.refresh_token,user:d.user||d};
_saveS(s); _ls.forEach(fn=>fn('SIGNED_IN',s));
return {data:{session:s},error:null};
},
signInWithPassword: async ({email,password}) => {
const r=await fetch(`${SUPA_URL}/auth/v1/token?grant_type=password`,{method:'POST',headers:{'apikey':SUPA_KEY,'Content-Type':'application/json'},body:JSON.stringify({email,password})});
const d=await r.json();
if(!d.access_token) return {error:{message:d.error_description||d.msg||'Incorrect email or password'}};
const s={access_token:d.access_token,refresh_token:d.refresh_token,user:d.user};
_saveS(s); _ls.forEach(fn=>fn('SIGNED_IN',s));
return {data:{session:s},error:null};
},
signOut: async () => {
const s=_getS();
if(s?.access_token) await fetch(`${SUPA_URL}/auth/v1/logout`,{method:'POST',headers:{'apikey':SUPA_KEY,'Authorization':`Bearer ${s.access_token}`}}).catch(()=>{});
_saveS(null); _ls.forEach(fn=>fn('SIGNED_OUT',null));
return {error:null};
},
updatePassword: async (newPassword) => {
const s=_getS();
if(!s?.access_token) return {error:{message:'Not signed in'}};
const r=await fetch(`${SUPA_URL}/auth/v1/user`,{method:'PUT',headers:{..._h(s.access_token)},body:JSON.stringify({password:newPassword})});
const d=await r.json();
if(d.error||d.code>=400) return {error:{message:d.message||'Failed to update password'}};
return {data:d,error:null};
},
resetPassword: async (email) => {
const r=await fetch(`${SUPA_URL}/auth/v1/recover`,{method:'POST',headers:{'apikey':SUPA_KEY,'Content-Type':'application/json'},body:JSON.stringify({email})});
return r.ok?{error:null}:{error:{message:'Could not send reset email. Please check your email address.'}};
},
onAuthStateChange: cb => { _ls.push(cb); return {data:{subscription:{unsubscribe:()=>{const i=_ls.indexOf(cb);if(i>-1)_ls.splice(i,1);}}}}; }
},
from: table => {
if(typeof window!=='undefined'&&window.__vgDemo) return _demoFrom(table);
const tok=_getS()?.access_token;
return {
select:(c,o)=>new _Q(table,tok).select(c,o),
insert:async d=>{if(window.__vgDemo)return{data:null,error:null};try{const r=await fetch(`${SUPA_URL}/rest/v1/${table}`,{method:'POST',headers:{..._h(tok),'Prefer':'return=minimal'},body:JSON.stringify(d)});return r.ok?{data:null,error:null}:{data:null,error:await r.json()};}catch{if(!navigator.onLine){_addQ({table,data:d,tok,prefer:'return=minimal'});return{data:null,error:null,offline:true};}return{data:null,error:{message:'Network error'}};}},
upsert:async d=>{if(window.__vgDemo)return{data:null,error:null};try{const r=await fetch(`${SUPA_URL}/rest/v1/${table}`,{method:'POST',headers:{..._h(tok),'Prefer':'resolution=merge-duplicates,return=minimal'},body:JSON.stringify(d)});return r.ok?{data:null,error:null}:{data:null,error:await r.json()};}catch{if(!navigator.onLine){_addQ({table,data:d,tok,prefer:'resolution=merge-duplicates,return=minimal'});return{data:null,error:null,offline:true};}return{data:null,error:{message:'Network error'}};}},
update:d=>new _U(table,tok,d),
};
}
};
// ── OFFLINE QUEUE ─────────────────────────────────────────────────────────────
const _OQ_KEY='vg_offline_queue';
const _getQ=()=>{try{return JSON.parse(localStorage.getItem(_OQ_KEY)||'[]');}catch{return[];}};
const _saveQ=q=>localStorage.setItem(_OQ_KEY,JSON.stringify(q));
const _addQ=item=>{const q=_getQ();q.push({...item,qid:Date.now()+Math.random().toString(36).slice(2)});_saveQ(q);};
const replayOfflineQueue=async()=>{
const q=_getQ();
if(!q.length) return 0;
const remain=[];
for(const item of q){
try{
const tok=_getS()?.access_token||item.tok;
const heads={..._h(tok)};
if(item.prefer) heads['Prefer']=item.prefer;
const r=await fetch(`${SUPA_URL}/rest/v1/${item.table}`,{method:'POST',headers:heads,body:JSON.stringify(item.data)});
if(!r.ok) remain.push(item);
}catch{remain.push(item);}
}
_saveQ(remain);
return q.length-remain.length;
};
// ── HELPERS ───────────────────────────────────────────────────────────────────
const today = () => new Date().toISOString().slice(0,10);
const nowTime = () => new Date().toTimeString().slice(0,5);
const fmtDate = d => new Date(d+'T12:00:00').toLocaleDateString('en-GB',{day:'numeric',month:'short',year:'numeric'});
const fmtDateShort = d => new Date(d+'T12:00:00').toLocaleDateString('en-GB',{day:'numeric',month:'short'});
// ── ICONS ─────────────────────────────────────────────────────────────────────
const I = {
home:,
diary:,
temp:<>>,
van:,
eho:,
check:,
warn:,
user:,
chevron:,
back:,
fire:,
location:,
water:,
lock:,
logout:,
star:,
plus:,
team:,
download:,
mail:,
chat:,
bell:,
};
const Icon = ({name,size=20,color='currentColor'}) => (
);
// ── VAN ICON SVG ─────────────────────────────────────────────────────────────
const VanIcon = ({size=40, wheelColor='#1A2744'}) => (
);
// ── SHARED UI ─────────────────────────────────────────────────────────────────
const Spinner = ({full}) => (
);
const Card = ({children,className=''}) => {children}
;
const Badge = ({label,color='teal'}) => {
const cls={green:'bg-green-100 text-green-700',red:'bg-red-100 text-red-700',amber:'bg-amber-100 text-amber-700',teal:'bg-teal-light text-teal',navy:'bg-navy/10 text-navy'}[color]||'bg-teal-light text-teal';
return {label};
};
const Checkbox = ({checked,onChange,label,isNA,onToggleNA}) => {
if(isNA) return (
NA
{label}
);
return (
{onToggleNA&&}
);
};
const Input = ({label,value,onChange,type='text',placeholder='',required=false,hint}) => (
{label&&
}
onChange(e.target.value)} placeholder={placeholder}
className="w-full border border-gray-200 rounded-xl px-4 py-3 text-sm text-gray-800 bg-gray-50 focus:outline-none focus:border-teal focus:bg-white transition-colors"/>
{hint&&
{hint}
}
);
const Btn = ({children,onClick,variant='primary',className='',disabled=false,loading=false,size='md'}) => {
const cls={primary:'bg-teal text-white active:opacity-80',navy:'bg-navy text-white active:opacity-80',ghost:'bg-gray-100 text-gray-600 active:bg-gray-200',outline:'border border-navy text-navy bg-white active:bg-gray-50',danger:'bg-red-500 text-white active:opacity-80'}[variant];
const sz={md:'py-3.5 text-sm',sm:'py-2.5 text-xs',lg:'py-4 text-base'}[size];
return (
);
};
const ErrMsg = ({msg}) => msg?{msg}
:null;
const OkMsg = ({msg}) => msg?{msg}
:null;
// ── TOP NAV ───────────────────────────────────────────────────────────────────
const TABS = [
{key:'dashboard',label:'Home',icon:'home'},
{key:'diary',label:'Diary',icon:'diary'},
{key:'temp',label:'Temps',icon:'temp'},
{key:'van',label:'Equipment',icon:'van'},
{key:'records',label:'Records',icon:'eho'},
];
// ── LIVE COMPLIANCE (today's %) ───────────────────────────────────────────────
// Same 4-part logic as the Dashboard: diary (opening+closing), 2+ temps, cleaning, unit.
async function computeTodayCompliance(userId){
try{
const todayStr=today();
const [{data:tD},{data:tT},{data:tC2},{data:tV}]=await Promise.all([
sb.from('diaries').select('opening,closing').eq('user_id',userId).eq('date',todayStr),
sb.from('temp_logs').select('id').eq('user_id',userId).eq('date',todayStr),
sb.from('cleaning_logs').select('id').eq('user_id',userId).eq('date',todayStr),
sb.from('van_checks').select('id').eq('user_id',userId).eq('date',todayStr),
]);
let na={};try{na=JSON.parse(localStorage.getItem('vg_na_diary_'+userId))||{};}catch{}
const rmO=readRemoved(userId,'opening'),rmC=readRemoved(userId,'closing');
const d0=tD&&tD[0];
const openDone=!!d0&&DEFAULT_OPENING.every((_,i)=>rmO.includes(i)||(d0.opening&&d0.opening[i])||na['o'+i]);
const closeDone=!!d0&&DEFAULT_CLOSING.every((_,i)=>rmC.includes(i)||(d0.closing&&d0.closing[i])||na['c'+i]);
const done=[openDone&&closeDone,(tT?.length||0)>=2,!!(tC2?.length),!!(tV?.length)].filter(Boolean).length;
return Math.round(done/4*100);
}catch{return 0;}
}
const complianceColor=(p)=>`hsl(${(30+(140-30)*(Math.max(0,Math.min(100,p))/100)).toFixed(1)},78%,45%)`;
const ComplianceRing=({pct=0,variant='header',onClick})=>{
const size=variant==='sidebar'?60:38;
const sw=variant==='sidebar'?6:4.5;
const r=(size-sw)/2-1, c=2*Math.PI*r;
const col=complianceColor(pct);
const done=pct>=100;
const fs=variant==='sidebar'?18:12;
return (
);
};
// ── CONFETTI (plays once on success) ──────────────────────────────────────────
const Confetti=({show,onDone})=>{
const ref=useRef(null);
useEffect(()=>{
if(!show) return;
if(!ref.current||!window.lottie){ const t=setTimeout(()=>onDone&&onDone(),100); return ()=>clearTimeout(t); }
let anim;
try{
anim=window.lottie.loadAnimation({container:ref.current,renderer:'svg',loop:false,autoplay:true,path:'/success-confetti.json'});
anim.addEventListener('complete',()=>onDone&&onDone());
}catch{ onDone&&onDone(); }
const safety=setTimeout(()=>onDone&&onDone(),4500);
return ()=>{clearTimeout(safety);try{anim&&anim.destroy();}catch{}};
},[show]);
if(!show) return null;
return ;
};
const TopNav = ({screen,onChange,profile,compliance=0}) => {
const initials = (profile?.name||'?').split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase();
return (
{/* Header bar */}
VanGuard
{profile?.business_name||'Stay compliant. Keep moving.'}
onChange('dashboard')}/>
{/* Tab bar */}
{TABS.map(t=>(
))}
);
};
// ── DESKTOP SIDEBAR (≥lg) ─────────────────────────────────────────────────────
const CogIcon = ({size=18})=>(
);
const DesktopSidebar = ({screen,onChange,profile,onHelp,compliance=0}) => {
const item=(active)=>`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold transition-all ${active?'bg-teal text-white':'text-white/60 hover:bg-white/10 hover:text-white'}`;
return (
VanGuard
{profile?.business_name||'Stay compliant'}
onChange('dashboard')}/>
);
};
// ── LANDING PAGE ──────────────────────────────────────────────────────────────
// ── ISO CAROUSEL ──────────────────────────────────────────────────────────────
const IsoCarousel = ({bg='#0C1628'}) => {
const stkRef=useRef(null);
const titleRef=useRef(null);
const dotsRef=useRef(null);
const wrapRef=useRef(null);
useEffect(()=>{
const S=[
{t:'Dashboard',i:'📊',c:'#14A085',rows:[{l:'Temperature logs',v:'3 readings',ok:true},{l:'Van checks',v:'All clear',ok:true},{l:'Food diary',v:'Pending',ok:null},{l:'Cleaning log',v:'Done',ok:true}]},
{t:'Temperature Log',i:'🌡',c:'#E85D04',rows:[{l:'Fridge',v:'3°C',ok:true},{l:'Freezer',v:'-18°C',ok:true},{l:'Hot hold',v:'68°C',ok:true},{l:'Delivery box',v:'5°C',ok:true}]},
{t:'Van Checks',i:'🚐',c:'#0D7377',rows:[{l:'Wheels & tyres',ok:true},{l:'Fire extinguisher',ok:true},{l:'First aid kit',ok:true},{l:'Gas seals checked',ok:true}]},
{t:'EHO Report',i:'📋',c:'#7209B7',cta:'↓ Download PDF report',rows:[{l:'30-day log complete',ok:true},{l:'Temperature records',ok:true},{l:'Cleaning schedule',ok:true},{l:'Food diary entries',ok:true}]},
{t:'Food Diary',i:'📖',c:'#F77F00',rows:[{l:'09:15 Delivery in',v:'Butcher+veg'},{l:'11:00 Trading',v:'Borough Mkt'},{l:'14:30 Complaints',v:'None'},{l:'16:00 Waste',v:'0.5 kg'}]},
];
const ZP=[115,58,0,-58,-115],OP=[1,0.82,0.64,0.48,0.34];
const mkP=s=>{
const rows=s.rows.map(r=>{const dot=r.ok===true?'#14A085':r.ok===null?'#F59E0B':'rgba(255,255,255,0.1)';return``;}).join('');
const cta=s.cta?`${s.cta}
`:'';
return`${s.t.toUpperCase()}
`;
};
const stk=stkRef.current,titleEl=titleRef.current,dotsEl=dotsRef.current;
if(!stk)return;
const cards=S.map((s,i)=>{
const d=document.createElement('div');
d.style.cssText='position:absolute;top:50%;left:50%;width:170px;height:212px;background:rgba(8,17,38,0.93);border-top:1px solid rgba(255,255,255,0.30);border-left:1px solid rgba(255,255,255,0.15);border-right:1px solid rgba(255,255,255,0.07);border-bottom:1px solid rgba(255,255,255,0.07);border-radius:14px;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:8px;box-sizing:border-box;will-change:transform,opacity';
d.innerHTML=mkP(s);
d.style.transform=`translate(-50%,-50%) translateZ(${ZP[i]}px)`;
d.style.opacity=String(OP[i]);
d.style.zIndex=String(10-i);
stk.appendChild(d);
return d;
});
let queue=[0,1,2,3,4],busy=false;
const updateUI=()=>{
if(titleEl)titleEl.textContent=S[queue[0]].t.toUpperCase();
if(dotsEl)dotsEl.querySelectorAll('.iso-dot').forEach((d,i)=>{d.className='iso-dot'+(queue[0]===i?' iso-on':'');});
};
if(dotsEl){
S.forEach((_,i)=>{
const d=document.createElement('span');
d.className='iso-dot'+(i===0?' iso-on':'');
d.style.cssText='width:6px;height:6px;border-radius:50%;background:rgba(255,255,255,0.22);display:inline-block;margin:0 3px;cursor:pointer;transition:all 0.3s;vertical-align:middle';
d.onclick=()=>{
if(busy||queue[0]===i)return;
const pos=queue.indexOf(i);if(pos<0)return;
queue=[...queue.slice(pos),...queue.slice(0,pos)];
queue.forEach((sc,p)=>{cards[sc].style.transition='transform 0.46s cubic-bezier(0.4,0,0.2,1),opacity 0.36s';cards[sc].style.transform=`translate(-50%,-50%) translateZ(${ZP[p]}px)`;cards[sc].style.opacity=String(OP[p]);cards[sc].style.zIndex=String(10-p);});
updateUI();
};
dotsEl.appendChild(d);
});
}
// Dot active style via JS since we can't use a
In the print dialog, choose your printer, or set the destination to “Save as PDF”.
${reportInner}