import {branches,categories,initial,item,sar,orderLink,importMenu,branchContactSeed,contactEdit,validatedPhoto,makeProduct,choices,cartLine,orderDraft,deliveryPin,categoryHash,categoryFromHash} from './core.mjs' import {maps} from './maps.mjs' import {socials} from './social.mjs' const $=id=>document.getElementById(id) const preview=new URLSearchParams(location.search).get('mode')==='preview' if(preview)$('contactNav').href='./contact.html?mode=preview' const state=structuredClone(initial), key='noor-local-admin-workbench-v2' let source='' try{ const response=await fetch('./menu.snapshot.json',{cache:'no-store'}) if(response.ok){const imported=importMenu(await response.json());state.menu=imported.menu;source=imported.source} }catch{} if(source)$('menuSource').textContent=source const originalMenu=structuredClone(state.menu) let branchContacts=structuredClone(branchContactSeed) if(preview){ try{ const raw=JSON.parse(localStorage.getItem(key)||'null') if(raw&&Array.isArray(raw.menu)&&raw.menu.length<=300){ state.menu=raw.menu.flatMap(x=>{try{ if(!/^[\w-]{1,60}$/.test(x.id))return [] return [{...makeProduct(x.name,x.category,x.price/100,x.variants?.map(v=>({label:v.label,priceSar:v.price/100}))),id:x.id,photo:validatedPhoto(x.photo)}] }catch{return []}}) for(const b of branches){if(raw.contacts?.[b.id])try{ branchContacts[b.id]=contactEdit(b.id,raw.contacts[b.id].phone,raw.contacts[b.id].whatsapp) }catch{}} } }catch{} } function persist(menu,contacts){ localStorage.setItem(key,JSON.stringify({menu,contacts})) state.menu=menu;branchContacts=contacts } let selected='',filter='',editing='',cart={},chosenPin='' function node(tag,txt,cls){const el=document.createElement(tag);el.textContent=txt;if(cls)el.className=cls;return el} function button(label,action,selected=false){const b=node('button',label,'outline');b.type='button';b.setAttribute('aria-pressed',selected);b.onclick=action;return b} const badges=['01','02','03','04','05','06','07','08','09','10','11','12'] const categoryList=()=>[...new Set(state.menu.length?state.menu.map(x=>x.category):categories)] function route(scroll=false){ filter=categoryFromHash(location.hash,categoryList()) render() if(filter&&scroll){ $('categoryHeading').focus({preventScroll:true}) $('categoryView').scrollIntoView({block:'start'}) }else if(scroll&&['#menu','#cart','#branches','#secrets'].includes(location.hash)){ $(location.hash.slice(1)).scrollIntoView({block:'start'}) } } window.addEventListener('hashchange',()=>route(true)) function setupCallouts(){ const product=originalMenu.find(x=>x.name==='مشوي مشكل'&&x.category==='مشويات لحم') if(!product)return $('grillMenuLink').href=categoryHash(product.category) const notes=choices(product).filter(v=>v.label==='نصف كيلو'||v.label==='كيلو').map(v=>[v.label,'خيار '+v.label+' كما هو في المنيو، بسعر '+sar(v.price)+' في النسخة المرجعية.']) notes.unshift(['عن الطبق','«مشوي مشكل» هو اسم الطبق في قسم مشويات اللحم. الصورة من أصول المطعم.']) let active=0,animation const controls=notes.map(([label,note],i)=>button(label,()=>{ if(i===active)return controls[active].setAttribute('aria-pressed','false');active=i controls[i].setAttribute('aria-pressed','true');$('calloutContent').textContent=note if(!matchMedia('(prefers-reduced-motion: reduce)').matches&&$('grillPlate').animate){ animation?.cancel() animation=$('grillPlate').animate([{transform:'rotate(0deg) scale(1)'},{transform:'rotate('+ (i%2?3:-3)+'deg) scale(1.04)'},{transform:'rotate(0deg) scale(1)'}],{duration:620,easing:'cubic-bezier(.2,.9,.3,1)'}) } },i===0)) $('callouts').replaceChildren(...controls);$('calloutContent').textContent=notes[0][1] } function render(){ const all=categoryList() if(filter&&!all.includes(filter))filter='' const open=Boolean(filter) for(const id of ['homeHero','secrets','menu'])$(id).hidden=open $('categoryView').hidden=!open $('categoryHeading').textContent=filter const products=filter?state.menu.filter(x=>x.category===filter):[] $('categoryCount').textContent=products.length+' أصناف في هذا القسم' $('categories').replaceChildren(...all.map((x,i)=>{ const card=node('a','','category-card'), num=node('span',badges[i]||String(i+1),'eyebrow') card.href=categoryHash(x) card.append(num,node('strong',x),node('span','عرض القسم ←','category-cta')) return card })) $('products').replaceChildren(...products.map(p=>{ const c=node('article','','card') if(p.photo){const im=node('img','');im.src=p.photo;im.alt=p.name;im.loading='lazy';c.append(im)} c.append(node('h3',p.name)) const opts=choices(p), pick=node('select',''), show=node('p',sar(opts[0].price)) pick.setAttribute('aria-label','حجم '+p.name) if(p.variants?.length){ pick.append(...opts.map(v=>new Option(v.label+' · '+sar(v.price),v.id))) pick.onchange=()=>{show.textContent=sar(opts.find(v=>v.id===pick.value).price)} c.append(pick) } c.append(show,button('أضف إلى الطلب',()=>{ const id=p.variants?.length?p.id+':'+pick.value:p.id if(Object.keys(cart).length>=30&&!cart[id])return cart[id]=Math.min((cart[id]||0)+1,20);renderCart() }));return c })) if(open&&!products.length)$('products').append(node('p','تظهر الأصناف والأسعار هنا بعد اعتماد المنيو.')) $('branchList').replaceChildren(...branches.map(b=>{ const card=node('article','','card');card.append(node('h3',b.name),button(selected===b.id?'الفرع المحدد':'اختر '+b.name,()=>{selected=b.id;render();$('branchList').querySelector('[aria-pressed=true]')?.focus()},selected===b.id)) const location=maps[b.id] if(location)card.append(node('p',location.address),external('الخريطة',location.map)) if(preview&&branchContacts[b.id]?.phone)card.append(node('small','هاتف الفرع (مرجع، ليس واتساب الكاشير)'),node('span',branchContacts[b.id].phone,'branchNumber')) return card })) renderCart() if(preview)renderAdmin() } function external(label,url){const a=node('a',label,'outline');a.href=url;a.rel='noopener noreferrer';a.target='_blank';return a} function renderCart(){ $('basket').replaceChildren();let total=0 for(const [id,q] of Object.entries(cart)){ const p=cartLine(state.menu,id,q);if(!p){delete cart[id];continue} total+=p.total const ln=node('div','','line') ln.append(node('strong',p.name+(p.variant?' ('+p.variant+')':'')+' × '+q),node('span',sar(p.total)),button('حذف',()=>{delete cart[id];renderCart();$('notice').tabIndex=-1;$('notice').focus()})) $('basket').append(ln) } if(total)$('basket').append(node('p','المجموع الفرعي: '+sar(total))) else $('basket').append(node('p','السلة فارغة.')) const method=document.querySelector('input[name=fulfillment]:checked').value $('deliveryFields').hidden=method!=='delivery' const info={method,address:method==='delivery'?$('deliveryAddress').value:'',pin:method==='delivery'?chosenPin:''} const draft=orderDraft(state,selected,cart,info) $('draftBox').hidden=!draft $('draftText').textContent=draft||'' const link=orderLink(state,selected,cart,info) $('order').hidden=!link if(link)$('order').href=link;else $('order').removeAttribute('href') $('notice').textContent=!total?'':!selected?'اختر الفرع قبل متابعة الطلب.':!draft?'أدخل عنوان توصيل واضحًا لمراجعة المسودة.':!link?'واتساب الكاشير لهذا الفرع لم يُعتمد بعد؛ لن تُرسل أي رسالة.':'الطلب لا يُعتبر مؤكدًا إلا بعد رد الكاشير.' } for(const control of document.querySelectorAll('input[name=fulfillment]'))control.onchange=()=>{ if(control.value==='pickup'&&control.checked){$('deliveryAddress').value='';clearSelectedPin()} renderCart() } $('deliveryAddress').oninput=renderCart function clearSelectedPin(){ chosenPin='';$('mapPin').value='';$('pinStatus').textContent='' } $('clearPin').onclick=()=>{clearSelectedPin();renderCart()} $('mapPin').oninput=()=>{ if(chosenPin){chosenPin='';$('pinStatus').textContent='الموقع المعدّل غير مضاف حتى تعتمده من جديد.';renderCart()} } $('selectPin').onclick=()=>{ try{ chosenPin=deliveryPin($('mapPin').value) $('pinStatus').textContent=chosenPin?'أُضيف موقع خرائط Google إلى معاينة مسودة التوصيل.':'لم يُضف موقع.' }catch(e){chosenPin='';$('pinStatus').textContent='لم يُضف الموقع. استخدم رابط مشاركة صالحًا من خرائط Google أو إحداثيات صحيحة.'} renderCart() } const form=$('edit') function variantRow(v={}){ if($('variantRows').children.length>=12)return const row=node('div','','editrow'), label=node('input',''), price=node('input','') label.name='variantLabel';label.placeholder='اسم الحجم';label.maxLength=35;label.required=true;label.value=v.label||'' price.name='variantPrice';price.placeholder='سعر الحجم';price.inputMode='decimal';price.required=true;price.value=v.price?v.price/100:'' label.setAttribute('aria-label','اسم الحجم');price.setAttribute('aria-label','سعر الحجم بالريال') row.append(label,price,button('إزالة الحجم',()=>row.remove()));$('variantRows').append(row) } function renderAdmin(){ $('editable').replaceChildren(...state.menu.map(p=>{ const row=node('div','','editrow') row.append(button('تعديل '+p.name+' · '+(p.variants?.length?'أحجام متعددة':sar(p.price)),()=>{editing=p.id;form.elements.photo.value='';form.elements.removePhoto.checked=false;for(const k of ['name','category'])form.elements[k].value=p[k];form.elements.price.value=p.price?p.price/100:'';$('variantRows').replaceChildren();p.variants?.forEach(variantRow);form.elements.price.focus()}), button('حذف '+p.name,()=>{if(!confirm('حذف '+p.name+' من هذه المعاينة المحلية؟'))return;try{persist(state.menu.filter(x=>x.id!==p.id),branchContacts);form.reset();$('adminStatus').textContent='حُذف الصنف من هذه المعاينة فقط.';render()}catch{$('adminStatus').textContent='تعذّر الحفظ محليًا.'}})) return row })) } function photoFromFile(f){ return new Promise((resolve,reject)=>{ if(!['image/jpeg','image/png','image/webp'].includes(f.type)||f.size>750000){reject(Error('استخدم صورة JPEG أو PNG أو WebP حتى 750 كيلوبايت'));return} const reader=new FileReader() reader.onerror=()=>reject(Error('تعذّرت قراءة الصورة')) reader.onload=()=>{ try{ const data=validatedPhoto(reader.result), im=new Image() im.onload=()=>im.naturalWidth>4096||im.naturalHeight>4096?reject(Error('اختر صورة لا تتجاوز 4096 بكسل لكل بعد')):resolve(data) im.onerror=()=>reject(Error('محتوى الصورة غير صالح')) im.src=data }catch(e){reject(e)} } reader.readAsDataURL(f) }) } function renderContacts(){ $('contactEditors').replaceChildren(...branches.map(b=>{ const frm=node('form','','panel contactField') const fields=['phone','whatsapp'].map((k,i)=>{ const lab=node('label',i?'واتساب الكاشير (لا يفعّل الإرسال)':'هاتف الفرع') const inp=node('input','');inp.name=k;inp.type='tel';inp.dir='ltr';inp.maxLength=30;inp.value=branchContacts[b.id][k] lab.append(inp);return lab }) const msg=node('p','');msg.setAttribute('role','status') frm.append(node('h4',b.name),...fields,node('button','حفظ محلي','btn'),msg) frm.onsubmit=e=>{e.preventDefault();try{ const next=contactEdit(b.id,frm.elements.phone.value,frm.elements.whatsapp.value) persist(state.menu,{...branchContacts,[b.id]:next}) frm.elements.phone.value=next.phone;frm.elements.whatsapp.value=next.whatsapp msg.textContent='تم الحفظ محليًا؛ الإرسال ما زال مغلقًا.';render() }catch(e){msg.textContent=e.name==='QuotaExceededError'?'ذاكرة المعاينة ممتلئة. خفّض حجم الصور.':e.message}} return frm })) } if(preview){ $('test').hidden=false form.elements.category.replaceChildren(...[...new Set([...categories,...state.menu.map(x=>x.category)])].map(c=>new Option(c,c))) $('addVariant').onclick=()=>variantRow() form.onreset=()=>{editing='';$('variantRows').replaceChildren();$('adminStatus').textContent=''} form.onsubmit=async e=>{ e.preventDefault() const controls=Array.from(form.elements); const current=editing, f=new FormData(form) controls.forEach(b=>b.disabled=true) try{ const variants=f.getAll('variantLabel').map((label,i)=>({label,priceSar:f.getAll('variantPrice')[i]})) const p=makeProduct(f.get('name'),f.get('category'),f.get('price'),variants), file=f.get('photo') let photo=f.get('removePhoto')?'':state.menu.find(x=>x.id===current)?.photo||'' if(file?.size)photo=await photoFromFile(file) if(state.menu.some(x=>x.id!==current&&x.name===p.name&&x.category===p.category))throw Error('الصنف موجود بالفعل في هذا القسم') if(!current&&state.menu.length>=300)throw Error('وصلت إلى حد الأصناف في المعاينة') const next=current?state.menu.map(x=>x.id===current?{...p,id:current,photo}:x):[...state.menu,{...p,id:crypto.randomUUID(),photo}] persist(next,branchContacts);if(current)for(const id of Object.keys(cart))if(id.split(':')[0]===current)delete cart[id];form.reset();$('adminStatus').textContent='تم الحفظ في هذا المتصفح فقط؛ لم تتغير نسخة المصدر.';render() }catch(e){$('adminStatus').textContent=e.name==='QuotaExceededError'?'ذاكرة المعاينة ممتلئة. خفّض حجم الصور.':e.message} finally{controls.forEach(b=>b.disabled=false)} } $('restoreAll').onclick=()=>{ if(!confirm('استعادة القائمة والأرقام المرجعية؟ تُزال تعديلات المعاينة في هذا المتصفح.'))return try{localStorage.removeItem(key);state.menu=structuredClone(originalMenu);branchContacts=structuredClone(branchContactSeed);cart={};form.reset();render();renderContacts();$('adminStatus').textContent='استُعيدت القائمة والأرقام المرجعية.'}catch{$('adminStatus').textContent='تعذّرت الاستعادة.'} } renderContacts() } for(const x of socials){$('social').append(external(x.label,x.url))} function setupFlow(){ const video=$('flow'), ctl=$('toggleFlow'), status=$('flowStatus') const reduce=matchMedia('(prefers-reduced-motion: reduce)') const compact=matchMedia('(max-width: 680px), (hover: none), (pointer: coarse)') const connection=navigator.connection video.muted=true let requested=false function stop(){ if(!video.paused)video.pause() video.hidden=true;ctl.textContent='شغّل مشهد المشاوي' } const play=async()=>{ if(requested)return requested=true ctl.disabled=true status.textContent='جاري تحميل المشهد…' try{ if(!video.getAttribute('src'))video.src='./assets/noor-flow-hero.mp4' if(video.ended)video.currentTime=0 await video.play() if($('homeHero').hidden||document.hidden)stop() else{video.hidden=false;ctl.textContent='أوقف المشهد مؤقتًا'} status.textContent='' }catch{ stop();status.textContent='تعذّر تشغيل الفيديو؛ تُعرض صورة المشاوي.' }finally{requested=false;ctl.disabled=false} } ctl.onclick=()=>video.paused?play():stop() video.onended=stop video.onerror=()=>{stop();status.textContent='تعذّر تشغيل الفيديو؛ تُعرض صورة المشاوي.'} const settings=()=>{if(reduce.matches||compact.matches||connection?.saveData||document.hidden||$('homeHero').hidden)stop()} reduce.addEventListener?.('change',settings) compact.addEventListener?.('change',settings) connection?.addEventListener?.('change',settings) document.addEventListener('visibilitychange',settings) window.addEventListener('hashchange',settings) const saveData=connection?.saveData||/^slow-2g$|^2g$/.test(connection?.effectiveType||'') if(!reduce.matches&&!compact.matches&&!saveData&&!document.hidden&&!location.hash)void play() } setupCallouts() route(Boolean(location.hash.startsWith('#category/'))) setupFlow()