const {useState,useEffect,useRef}=React; const templates=[{id:'luna',name:'Luna Terrace',style:'Champagne Luxe',img:'./assets/templates/luna.png'},{id:'ember',name:'Ember House',style:'Midnight Gold',img:'./assets/templates/ember.png'},{id:'olive',name:'Olive Oven',style:'Rustic Mediterranean',img:'./assets/templates/olive.png'}]; const API='./api/index.php'; const qs=new URLSearchParams(location.search); function Icon({name,size=18}){const icons={restaurant:'▦',home:'⌂',menu:'☷',users:'♙',qr:'▦',template:'▤',upload:'⇧',box:'⬡',arrow:'→',back:'←',check:'✓',edit:'✎',eye:'◉',search:'⌕',settings:'⚙',logout:'↪',plus:'+',close:'×',camera:'◎',print:'▣'};return } function App(){const[csrf,setCsrf]=useState(''),[account,setAccount]=useState(null),[loading,setLoading]=useState(true),[notice,setNotice]=useState(null),[page,setPage]=useState(qs.get('unitytest')==='1'?'unity-test':'dashboard'),[restaurants,setRestaurants]=useState([]),[active,setActive]=useState(null),[adminUsers,setAdminUsers]=useState([]),[showAuth,setShowAuth]=useState('login'),[publicMenu,setPublicMenu]=useState(null),[arDish,setArDish]=useState(null); const menuMode=qs.get('menu'); async function api(op,data,extra={}){const body=data instanceof FormData?data:(data===undefined?undefined:JSON.stringify(data));const r=await fetch(`${API}?op=${op}${extra.rid?'&rid='+extra.rid:''}${extra.slug?'&slug='+encodeURIComponent(extra.slug):''}`,{method:data===undefined?'GET':'POST',credentials:'same-origin',headers:{...(data!==undefined&&!(data instanceof FormData)?{'Content-Type':'application/json'}:{}),...(data!==undefined?{'X-CSRF-Token':csrf}:{})},body});const j=await r.json().catch(()=>({error:'Invalid server response'}));if(!r.ok)throw Error(j.error||'Request failed');return j} function flash(message,error=false){setNotice({message,error});setTimeout(()=>setNotice(null),5500)} async function refresh(){const a=await api('restaurants');setRestaurants(a.restaurants);if(account?.role==='admin'){const u=await api('admin_users');setAdminUsers(u.users)}} async function openRestaurant(id){try{const a=await api('restaurant',undefined,{rid:id});setActive(a.restaurant);setPage('studio')}catch(e){flash(e.message,true)}} async function mutate(op,payload,rid=active?.id){try{const j=await api(op,payload,{rid});if(j.restaurant)setActive(j.restaurant);if(op==='create_restaurant'){setActive(j.restaurant);setPage('studio')}if(op==='publish'||op==='create_restaurant')await refresh();flash('Saved successfully');return j}catch(e){flash(e.message,true);throw e}} async function upload(type,files){if(!files?.length)return;const f=new FormData();f.append('type',type);for(const file of Array.from(files))f.append('files[]',file);try{const j=await mutate('upload',f);return j}catch(e){}} useEffect(()=>{(async()=>{try{const x=await api('bootstrap');setCsrf(x.csrf);setAccount(x.user);if(menuMode){const y=await api('public',undefined,{slug:menuMode});setPublicMenu(y.restaurant)}}catch(e){flash(e.message,true)}finally{setLoading(false)}})()},[]); useEffect(()=>{if(account&&!menuMode){api('restaurants').then(x=>setRestaurants(x.restaurants)).catch(e=>flash(e.message,true));if(account.role==='admin')api('admin_users').then(x=>setAdminUsers(x.users)).catch(()=>{})}},[account]); async function auth(action,values){try{const r=await api(action,values);if(r.csrf)setCsrf(r.csrf);const b=await api('bootstrap');setAccount(b.user);setPage('dashboard');flash('Welcome to FoodAR Studio')}catch(e){flash(e.message,true)}} async function logout(){try{await api('logout',{});setAccount(null);setActive(null);setRestaurants([])}catch(e){flash(e.message,true)}} if(loading)return
; if(menuMode)return publicMenu?:

Menu unavailable

This QR menu is not published yet.

; if(!account)return <>
⬡ FoodAR Studio
THE RESTAURANT EXPERIENCE PLATFORM

Bring every menu to life.

Create immersive restaurant menus, match 3D dishes and images, and publish your QR experience in one place.

{templates.map(t=>)}
FoodAR Studio · Tasvic
RESTAURANT STUDIO

{showAuth==='login'?'Welcome back':'Create your account'}

{showAuth==='login'?'Sign in to manage your restaurants.':'Start building your interactive restaurant menu.'}

{notice&&}
{e.preventDefault();const v=Object.fromEntries(new FormData(e.target));auth(showAuth,v)}}>{showAuth==='register'&&}

{showAuth==='login'?'New to FoodAR?':'Have an account?'}

; return
{page==='dashboard'?'Overview':page==='studio'?'Menu Studio':page==='qr'?'Publish & QR Code':page==='unity-test'?'Unity Connection Test':'Super Admin'}
{active&&page!=='dashboard'&&page!=='admin'?active.name:'Manage your restaurant experiences'}
{account.role==='admin'?'ADMIN':'RESTAURANT OWNER'}
{notice&&} {page==='dashboard'&&mutate('create_restaurant',v)} />}{page==='studio'&&active&&} {page==='qr'&&active&&window.open(active.menu_url,'_blank')}/>} {page==='admin'&&account.role==='admin'&&}{page==='unity-test'&&account.role==='admin'&&}
; } function Notice({message,error}){return
{error?'⚠ ': '✓ '}{message}
} function Field({label,children}){return
{children}
} function Dashboard({restaurants,account,onOpen,onCreate}){const[showCreate,setShowCreate]=useState(false);const[draft,setDraft]=useState({name:'',tagline:'',template_id:'luna'});return
YOUR WORKSPACE

Restaurant dashboard

Build menus, organize assets, preview designs and publish instantly.

Restaurants{restaurants.length}Created on FoodAR
Live menus{restaurants.filter(r=>+r.published).length}Published QR experiences
Templates{templates.length}Available menu styles

Your restaurants

Select to manage
{restaurants.map(r=>
onOpen(r.id)}>
▦
{+r.published?'LIVE':'DRAFT'}

{r.name}

{r.tagline||'No tagline yet'}

{r.template_id} templateOpen →
)}{!restaurants.length&&

Your first restaurant starts here

Create your restaurant to upload its menu and publish a QR experience.

}
{showCreate&&
{if(e.target===e.currentTarget)setShowCreate(false)}}>

Create restaurant

setDraft({...draft,name:e.target.value})} placeholder="e.g. Sora Kitchen"/>setDraft({...draft,tagline:e.target.value})} placeholder="Fresh flavors, new experiences"/>

Choose your starting template

setDraft({...draft,template_id:x})}/>
}
} function TemplatePicks({value,onChange}){return
{templates.map(t=>)}
} function Studio({r,mutate,upload,setPage,api,flash}){const[tab,setTab]=useState('details'),[draft,setDraft]=useState({name:r.name,tagline:r.tagline,template_id:r.template_id,accent:r.accent,currency:r.currency}),[newCat,setNewCat]=useState({name:'',parent_id:''}),[newDish,setNewDish]=useState({name:'',description:'',price:'',category_id:''}),[editDish,setEditDish]=useState(null),[previewModel,setPreviewModel]=useState(null),[menuTab,setMenuTab]=useState(null),[search,setSearch]=useState('');useEffect(()=>{setDraft({name:r.name,tagline:r.tagline,template_id:r.template_id,accent:r.accent,currency:r.currency})},[r.id]);const categoryOptions=r.categories.map(c=>({value:c.id,label:(c.parent_id?'↳ ':'')+c.name}));const main=r.categories.filter(c=>!c.parent_id);const filesByType=t=>r.assets.filter(a=>a.type===t);const saveDish=async(v)=>{await mutate('save_dish',{...v,category_id:v.category_id||null});setEditDish(null);setNewDish({name:'',description:'',price:'',category_id:''})};const [importText,setImportText]=useState('');const [ocrRows,setOcrRows]=useState([]),[ocrBusy,setOcrBusy]=useState(false),[ocrMessage,setOcrMessage]=useState(''),[ocrRaw,setOcrRaw]=useState(''); const importMenuFile=async(files)=>{if(!files?.length)return;setOcrBusy(true);setOcrRows([]);setOcrMessage('Preparing OCR…');try{const result=await window.FoodARMenuOCR.extract(Array.from(files),setOcrMessage);setOcrRows(result.rows);setOcrRaw(result.raw);setOcrMessage(`Read ${result.pages} page(s); found ${result.rows.length} potential dishes. Review before saving.`);await upload('menu',files);}catch(e){setOcrMessage('OCR failed: '+e.message);flash(e.message,true)}finally{setOcrBusy(false)}}; const saveOcrRows=async()=>{setOcrBusy(true);let count=0;try{for(const row of ocrRows){if(!row.name.trim()||row.skip)continue;let cat=r.categories.find(c=>c.name.toLowerCase()===row.category.toLowerCase());if(!cat){const j=await mutate('save_category',{name:row.category,parent_id:null});cat=j.restaurant.categories.find(c=>c.name.toLowerCase()===row.category.toLowerCase());}await mutate('save_dish',{name:row.name,description:row.description,price:Number(row.price)||0,category_id:cat?.id||null});count++;}setOcrRows([]);setOcrMessage(`Saved ${count} reviewed dishes.`);setTab('dishes');}catch(e){setOcrMessage(`Saved ${count}; remaining rows must be checked. ${e.message}`)}finally{setOcrBusy(false)}}; const importRows=async()=>{const rows=importText.trim().split('\n').filter(Boolean);let success=0;for(const row of rows){const [name,price,description]=row.split('|').map(x=>x?.trim()||'');if(!name)continue;await mutate('save_dish',{name,price:Number(price)||0,description,category_id:null});success++}setImportText('');flash(`Imported ${success} dishes`)};return
RESTAURANT EDITOR

{r.name}

Design, edit, match and publish your interactive menu.

{+r.published?'PUBLISHED':'DRAFT'}
{[['details','Brand & Template'],['categories','Categories'],['dishes','Dishes & Matching'],['assets','Bulk Assets'],['import','Menu Import']].map(([k,v])=>)}
{tab==='details'&&

Restaurant identity

setDraft({...draft,name:e.target.value})}/>setDraft({...draft,tagline:e.target.value})}/>setDraft({...draft,currency:e.target.value})}/>setDraft({...draft,accent:e.target.value})}/>
upload('logo',f)}/>upload('background',f)}/>

Customer-facing template

setDraft({...draft,template_id:v})}/>
} {tab==='categories'&&

Menu hierarchy

Use top-level categories such as Food Menu, Bar Menu, Chinese or Continental. Nest subcategories beneath them, or create only top-level category chips.

setNewCat({...newCat,name:e.target.value})}/>
{r.categories.map(c=>
{c.parent_id?'↳ ':''}{c.name}{c.parent_id?'Subcategory':'Main category'} · {r.dishes.filter(d=>String(d.category_id)===String(c.id)).length} dishes
)}
} {tab==='dishes'&&

Dish list & matching

Assign any numbered 3D model and image to each dish. Names do not have to match filenames.

setSearch(e.target.value)}/>
{r.dishes.filter(d=>d.name.toLowerCase().includes(search.toLowerCase())).map(d=>)}
DishPriceCategory3D model #Image #
{d.name}
{(d.description||'').slice(0,70)}
{r.currency} {d.price}{r.categories.find(c=>String(c.id)===String(d.category_id))?.name||'Unassigned'}{d.model_url&&}

Add a dish

setNewDish({...newDish,name:e.target.value})}/>setNewDish({...newDish,price:e.target.value})}/>