}
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.
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.
Use top-level categories such as Food Menu, Bar Menu, Chinese or Continental. Nest subcategories beneath them, or create only top-level category chips.
Upload JPG, PNG, or PDF menu pages. OCR runs in this browser (the first run needs internet to load OCR libraries). Review names, prices, descriptions and categories before committing to MySQL. Multiple prices can require manual verification.
{ocrBusy&&
{ocrMessage}
}{!ocrBusy&&ocrMessage&&
{ocrMessage}
}{ocrRows.length>0&&<>
Extracted dishes — review before saving ({ocrRows.filter(x=>!x.skip).length})
Note: the database currently has one price per dish. Where the menu has Half/Full or Regular/Large prices, only the first price is saved until a price-variant table is implemented. Do not publish those prices without reviewing.
}{previewModel&&setPreviewModel(null)} onReplace={file=>{upload('model',[file]);setPreviewModel(null);flash('Replacement uploaded as a new version candidate. Assign it to the dish from matching table.')}}/>}
}
function Upload({label,accept,multiple,onFiles}){const input=useRef(null);return
input.current?.click()} onDragOver={e=>e.preventDefault()} onDrop={e=>{e.preventDefault();onFiles(e.dataTransfer.files)}}>{label}Click or drag files here{onFiles(e.target.files);e.target.value=''}}/>
}
function Publish({r,mutate,onOpen}){const[ready,setReady]=useState(false),canvas=useRef(null);useEffect(()=>{if(r.published&&canvas.current&&window.QRCode){QRCode.toCanvas(canvas.current,r.menu_url,{width:260,margin:2,color:{dark:'#21362b',light:'#ffffff'}},()=>setReady(true))}},[r.menu_url,r.published]);return
YOUR RESTAURANT IS ALMOST READY
Publish your QR menu
A single printable QR code opens your customer's selected restaurant menu.
Loads the exact Unity WebGL build shipped with this project. This checks iframe, tracker, WebGL initialization and camera permissions; it does not confirm dish loading.
On phones, tap the original ALLOW ACCESS button inside the Unity frame if the browser requires a direct gesture. Camera access requires HTTPS (or localhost), and WebGL startup can be slow on first load.
Expected milestones: bridge_ready and engine_ready. engine_ready means the Unity WebGL engine started, not that a model was successfully spawned. For that, the compiled C# loader must support the bridge callback.
;
}
function Admin({users,restaurants,onOpen}){const[selected,setSelected]=useState('');const owners=users.filter(u=>u.role==='owner');const current=restaurants.filter(r=>!selected||String(r.user_id)===String(selected));return
PLATFORM MANAGEMENT
Super admin overview
Inspect every owner, restaurant, dish and uploaded asset.
}
function ModelModal({model,onClose,onReplace}){const[error,setError]=useState('');useEffect(()=>{if(document.querySelector('script[data-modelviewer]'))return;let s=document.createElement('script');s.type='module';s.src='https://ajax.googleapis.com/ajax/libs/model-viewer/4.0.0/model-viewer.min.js';s.dataset.modelviewer='true';document.head.appendChild(s)},[]);return
Edited uploads are safely stored as new assets; reassign a dish to the corrected model. The existing file is not deleted without an explicit replacement workflow.
}
function CustomerMenu({restaurant:r,onAR,arDish}){const[detail,setDetail]=useState(null);return
{detail&&
{detail.image_url&&}
{detail.name}
{r.currency} {detail.price}
{detail.description}
{detail.model_url&&}
}{arDish&&onAR(null)}/>}
}
function ARViewer({restaurant:r,initialDish,onClose}){
const [phase,setPhase]=useState('permission'),[dish,setDish]=useState(initialDish),[message,setMessage]=useState('Preparing AR…'),[details,setDetails]=useState(false);
const frame=useRef(null),engineReady=useRef(false),pending=useRef(initialDish);
const available=r.dishes.filter(d=>d.model_url);
const listUrl=`${location.origin}/api/model-list.php?restaurant=${encodeURIComponent(r.slug)}`;
function send(action,extra={}){frame.current?.contentWindow?.postMessage({source:'foodar-react',action,...extra},location.origin)}
function transmit(d){pending.current=d;setDish(d);setPhase('loading');setMessage('Loading selected dish…');send('dish',{payload:{dishId:String(d.id),modelName:d.name,name:d.name,modelUrl:d.model_url,initialScale:1}})}
useEffect(()=>{
function receive(e){if(e.origin!==location.origin||e.source!==frame.current?.contentWindow||e.data?.source!=='foodar-unity')return;
const x=e.data;
if(x.status==='bridge_ready'){send('list',{url:listUrl});}
if(x.status==='engine_ready'){engineReady.current=true;send('list',{url:listUrl});transmit(pending.current);}
if(x.status==='loaded'||x.status==='dish_loaded'){setPhase('ready');setMessage('');}
if(x.status==='error'||x.status==='dish_error'){setPhase('failed');setMessage(x.message||'Unity reported an error');}
}
window.addEventListener('message',receive);return()=>window.removeEventListener('message',receive);
},[listUrl]);
function start(){setPhase('loading');setMessage('Starting camera and Unity…');send('start')}
function changeDish(d){if(d.id===dish.id)return;send('close');setDish(d);pending.current=d;setPhase('loading');setMessage('Switching models and resetting origin…');window.setTimeout(()=>{if(engineReady.current)transmit(d)},750)}
function exit(){send('close');onClose()}
return