1import { state, rawData, getLang } from './state.js'; 2import { THEMES, DRINK_GROUPS } from './config.js'; 3import { getPrice, getM, esc, isItemVisibleAtLocation, svgIcon, pricedSizes, sizeWordFor, renderToCanvas, isMobile } from './utils.js'; 4import { updateSizeSwoosh, isOrderableNow } from './render.js'; 5import { pushView, replaceView, atView } from './back-stack.js'; 6 7let lastAnimatedMacros = { kcal: 0, protein: 0, fat: 0, carbs: 0, fiber: 0 }; 8let currentCircleStates = { protein: { offset: 408.4, rotate: 0 }, carbs: { offset: 408.4, rotate: 0 }, fat: { offset: 408.4, rotate: 0 } }; 9 10export function animateSynchronized(target) { 11 if (!target) return; 12 const start = Object.assign({}, lastAnimatedMacros); 13 const end = { kcal: target.kcal || 0, protein: target.protein || 0, carbs: target.carbs || 0, fat: target.fat || 0, fiber: target.fiber || 0 }; 14 const circ = 408.4; 15 const dur = 1000; 16 const total = (end.protein + end.carbs + end.fat) || 1; 17 const targetRatios = { p: end.protein / total, c: end.carbs / total, f: end.fat / total }; 18 const targetVisuals = { 19 protein: { offset: circ - (targetRatios.p * circ), rotate: 0 }, 20 carbs: { offset: circ - (targetRatios.c * circ), rotate: targetRatios.p * 360 }, 21 fat: { offset: circ - (targetRatios.f * circ), rotate: (targetRatios.p + targetRatios.c) * 360 } 22 }; 23 const startVisuals = JSON.parse(JSON.stringify(currentCircleStates)); 24 const elements = { 25 protein: document.getElementById('circle-protein'), 26 carbs: document.getElementById('circle-carbs'), 27 fat: document.getElementById('circle-fat'), 28 valK: document.getElementById('val-kcal-center') 29 }; 30 let startTime = null; 31 function step(timestamp) { 32 if (!startTime) startTime = timestamp; 33 const progress = Math.min((timestamp - startTime) / dur, 1); 34 const ease = progress < 0.5 ? 2 * progress * progress : -1 + (4 - 2 * progress) * progress; 35 if (elements.valK) elements.valK.textContent = Math.round(start.kcal + (end.kcal - start.kcal) * ease); 36 ['protein', 'carbs', 'fat', 'fiber'].forEach(k => { 37 const el = document.getElementById('val-' + k); 38 if (el) el.textContent = Math.round(start[k] + (end[k] - start[k]) * ease) + 'g'; 39 }); 40 ['protein', 'carbs', 'fat'].forEach(k => { 41 const o = startVisuals[k].offset + (targetVisuals[k].offset - startVisuals[k].offset) * ease; 42 const r = startVisuals[k].rotate + (targetVisuals[k].rotate - startVisuals[k].rotate) * ease; 43 if (elements[k]) { elements[k].style.strokeDashoffset = o; elements[k].style.transform = `rotate(${r}deg)`; } 44 currentCircleStates[k] = { offset: o, rotate: r }; 45 }); 46 if (progress < 1) requestAnimationFrame(step); 47 else lastAnimatedMacros = end; 48 } 49 requestAnimationFrame(step); 50} 51 52function resetMacroVisuals() { 53 const circ = 408.4; 54 const elements = { 55 protein: document.getElementById('circle-protein'), 56 carbs: document.getElementById('circle-carbs'), 57 fat: document.getElementById('circle-fat'), 58 valK: document.getElementById('val-kcal-center') 59 }; 60 if (elements.protein) { elements.protein.style.strokeDashoffset = circ; elements.carbs.style.strokeDashoffset = circ; elements.fat.style.strokeDashoffset = circ; } 61 if (elements.valK) elements.valK.textContent = '0'; 62 ['protein', 'carbs', 'fat', 'fiber'].forEach(k => { const el = document.getElementById('val-' + k); if (el) el.textContent = '0g'; }); 63 currentCircleStates = { protein: { offset: circ, rotate: 0 }, carbs: { offset: circ, rotate: 0 }, fat: { offset: circ, rotate: 0 } }; 64 lastAnimatedMacros = { kcal: 0, protein: 0, fat: 0, carbs: 0, fiber: 0 }; 65} 66 67export async function shareAsImage() { 68 const container = document.getElementById('export-container'); 69 const exportLogo = document.getElementById('modal-logo-export'); 70 const btns = document.querySelectorAll('.export-hide'); 71 if (exportLogo) exportLogo.classList.remove('hidden'); 72 btns.forEach(b => b.style.display = 'none'); 73 try { 74 const canvas = await renderToCanvas(container, { 75 backgroundColor: state.isDarkMode ? '#171717' : '#ffffff', 76 borderRadius: 48, scale: 3, useCORS: true, logging: false, imageTimeout: 0, dpi: 300, 77 onclone: (clonedDoc) => { const c = clonedDoc.getElementById('export-container'); c.style.borderRadius = "3rem"; c.style.overflow = "hidden"; } 78 }); 79 const link = document.createElement('a'); 80 link.download = `menu-${state.modalItem.nameIs || 'item'}.png`; 81 link.href = canvas.toDataURL('image/png'); 82 link.click(); 83 } catch (err) { console.error("Error:", err); } 84 finally { if (exportLogo) exportLogo.classList.add('hidden'); btns.forEach(b => b.style.display = ''); } 85} 86 87export function openModal(idx, opts = {}) { 88 const item = rawData[idx]; 89 if (!item) return; 90 // A plain item open always exits drink-group mode; openDrinkGroup layers it 91 // back on after calling us. 92 modalGroup = null; 93 const flavorSec = document.getElementById('modal-flavor-section'); 94 if (flavorSec) { flavorSec.classList.add('hidden'); flavorSec.replaceChildren(); } 95 // No description â hide the whole block, or the bare label hangs over nothing. 96 const ingrCont = document.getElementById('modal-ingredients-container'); 97 const desc = state.isIcelandic ? item.descriptionIs : item.descriptionEn; 98 if (ingrCont) ingrCont.classList.toggle('hidden', !(desc && desc.trim())); 99 state.modalItem = item; 100 const sizes = item.prices ? Object.keys(item.prices) : []; 101 state.selectedSize = sizes.includes('M') ? 'M' : (sizes[0] || null); 102 resetMacroVisuals(); 103 document.getElementById('modal-title').textContent = state.isIcelandic ? item.nameIs : item.nameEn; 104 document.getElementById('modal-description').textContent = state.isIcelandic ? item.descriptionIs : item.descriptionEn; 105 const theme = THEMES[item.theme] || THEMES.ALL; 106 const modalTag = document.getElementById('modal-tag'); 107 modalTag.textContent = state.isIcelandic ? theme.is : theme.en; 108 modalTag.style.backgroundColor = theme.color; 109 modalTag.style.color = '#ffffff'; 110 modalTag.style.border = 'none';
111 modalTag.style.boxShadow = 'none'; 112 modalTag.classList.toggle('hidden', item.theme === 'ALL'); 113 document.getElementById('modal-vegan').classList.toggle('hidden', !item.isVegan); 114 updateModalUI(); 115 const modal = document.getElementById('item-modal'); 116 if (modal) { 117 const wasHidden = modal.classList.contains('hidden'); 118 modal.classList.remove('hidden'); 119 modal.classList.add('flex'); 120 const c = document.getElementById('modal-content'); 121 if (c) c.style.removeProperty('--sheet-drag'); // clear any stale drag offset 122 // One history entry carrying the item id, so Back closes the sheet and 123 // Forward can reopen it. Skipped on a restore (the entry already exists) 124 // and on a re-open of an already-visible modal. 125 if (wasHidden && !opts.restore) pushView({ wcView: 'modal', itemId: item.id }); 126 if (wasHidden) returnFocus = document.activeElement; 127 setTimeout(() => { 128 // Desktop: scale in; Mobile: slide up via modal-visible class 129 if (c) { c.classList.remove('opacity-0', 'scale-95'); c.classList.add('opacity-100', 'scale-100', 'modal-visible'); } 130 if (wasHidden) document.getElementById('modal-close-btn')?.focus({ preventScroll: true }); 131 const m = item.macros ? (item.macros[state.selectedSize] || getM(item)) : null; 132 // Group mode hides the nutrition donut â don't run its 1s rAF loop 133 // into an invisible block (modalGroup is set before this timer fires). 134 if (m && !modalGroup) animateSynchronized(m); 135 }, 50); 136 } 137} 138 139export function updateModalUI() { 140 const item = state.modalItem; 141 const lang = getLang(); 142 const pCont = document.getElementById('modal-price-container'); 143 const sizesAvailable = item.prices ? Object.keys(item.prices).filter(s => item.prices[s]) : []; 144 145 pCont.className = "mb-6 export-hide"; 146 147 if (sizesAvailable.length > 1) { 148 const sizeOrder = ['S', 'M', 'L'].filter(s => item.prices[s]); 149 pCont.innerHTML = ` 150 <div class="bg-gray-100/80 dark:bg-neutral-800/80 p-1.5 rounded-[0.9rem] flex gap-1 relative shadow-inner border border-black/5 dark:border-white/5 w-full h-14"> 151 <div id="size-indicator"></div> 152 ${sizeOrder.map(s => { 153 const active = state.selectedSize === s; 154 return ` 155 <button onclick="selectSize('${s}')" 156 class="size-tab-btn flex-1 flex flex-col items-center justify-center py-1 px-1 rounded-2xl transition-all duration-300 z-10 157 ${active ? 'active-size' : 'text-neutral-400 dark:text-neutral-500 hover:bg-black/5 dark:hover:bg-white/5'}"> 158 <span class="text-[7px] uppercase tracking-widest ${active ? 'font-black' : 'font-semibold'}">${sizeWordFor(item.category, s, item.size_labels)}</span> 159 <span class="text-[14px] tracking-tighter ${active ? 'font-black' : 'font-semibold'}">${item.prices[s]}</span> 160 </button>`; 161 }).join('')} 162 </div>`; 163 setTimeout(updateSizeSwoosh, 20); 164 } else { 165 const displayPrice = getPrice(item); 166 pCont.innerHTML = `<span class="text-3xl font-black text-red-500 tracking-tight block">${displayPrice}</span>`; 167 } 168 169 const info = item.allergens; 170 const allergenCont = document.getElementById('modal-allergens-container'); 171 if (info && (info.gluten || info.milk || info.nuts || info.egg || info.other)) { 172 allergenCont.classList.remove('hidden'); 173 allergenCont.classList.add('flex'); 174 let allItems = []; 175 const labels = { gluten: 'Glúten', milk: 'Mjólk', nuts: 'Hnetur', egg: 'Egg' }; 176 Object.keys(labels).forEach(k => { if (info[k]) allItems.push(labels[k]); }); 177 if (info.other) allItems = [...allItems, ...info.other.split(',').map(s => s.trim())]; 178 document.getElementById('modal-allergens-list').innerHTML = [...new Set(allItems)].map(a => `<span class="bg-gray-50 dark:bg-neutral-800 text-gray-500 dark:text-neutral-400 text-[8px] font-bold px-2 py-0.5 rounded-md border border-gray-100 dark:border-neutral-700 uppercase tracking-tight">${esc(a)}</span>`).join(''); 179 } else { 180 allergenCont.classList.add('hidden'); 181 } 182 183 // Online ordering: refresh the add-to-cart button for the current size (no-op off ordering mode). 184 if (window.renderModalCartAction) window.renderModalCartAction(); 185 186 const m = item.macros ? (item.macros[state.selectedSize] || getM(item)) : null; 187 const nutritionSec = document.getElementById('modal-nutrition-section'); 188 // In drink-group mode the flavor picker replaces nutrition outright â a 189 // flavor row that happens to carry macros must not sneak the block back in. 190 if (m && typeof m.kcal === 'number' && !modalGroup) { 191 nutritionSec.classList.remove('hidden'); 192 const rows = [ 193 { k: 'protein', c: 'text-[#dc2626]', bg: 'bg-[#fff1f2] dark:bg-red-900/10' }, 194 { k: 'carbs', c: 'text-[#ea580c]', bg: 'bg-[#fff7ed] dark:bg-orange-900/10' }, 195 { k: 'fat', c: 'text-[#0284c7]', bg: 'bg-[#f0f9ff] dar
195k:bg-blue-900/10' }, 196 { k: 'fiber', c: 'text-[#16a34a]', bg: 'bg-[#f0fdf4] dark:bg-green-900/10' } 197 ]; 198 document.getElementById('modal-macros').innerHTML = rows.map(r => 199 `<div class="${r.bg} px-4 py-2 rounded-lg flex items-center justify-between"><span class="text-[9px] font-black uppercase tracking-[0.18em] ${r.c}">${lang[r.k]}</span><span id="val-${r.k}" class="text-sm font-black text-gray-800 dark:text-gray-200 w-12 text-right">0g</span></div>` 200 ).join(''); 201 } else { 202 nutritionSec.classList.add('hidden'); 203 } 204} 205 206export function selectSize(s) { 207 state.selectedSize = s; 208 updateModalUI(); 209 animateSynchronized(state.modalItem.macros[state.selectedSize]); 210} 211 212// ---- Drink-group flavor picker ---------------------------------------------- 213// A drink-group card (Gos, Bjór, ...) opens this same modal, but where a meal 214// shows its nutrition the customer instead picks one flavor. Every flavor is a 215// real menu_items row, so the cart, server pricing and receipt all see the 216// chosen row's own id/name â grouping is purely presentation. 217 218let modalGroup = null; // { key, members } while a group is open, else null 219// Element focused before the sheet opened; focus returns there on close. 220let returnFocus = null; 221 222export function openDrinkGroup(groupKey, opts = {}) { 223 const group = DRINK_GROUPS[groupKey]; 224 if (!group) return; 225 const members = rawData.filter(i => 226 i.drinkGroup === groupKey && 227 isItemVisibleAtLocation(i, state.activeLocation) && 228 isOrderableNow(i) 229 ); 230 if (members.length === 0) return; 231 // Reuse the full open/reset/animate path with the first flavor selected, 232 // then layer group mode on top of it. openModal pushes the modal history 233 // entry (itemId); restamp it with the group key so Forward reopens the 234 // group, not just the first flavor's plain card. 235 openModal(rawData.indexOf(members[0]), opts); 236 if (!opts.restore) replaceView({ wcView: 'modal', itemId: members[0].id, groupKey }); 237 modalGroup = { key: groupKey, members }; 238 document.getElementById('modal-title').textContent = state.isIcelandic ? group.is : group.en; 239 // Flavors carry no descriptions â the bare description label would be noise. 240 const ingrCont = document.getElementById('modal-ingredients-container'); 241 if (ingrCont) ingrCont.classList.add('hidden'); 242 // openModal ran before group mode was set â retract any nutrition block it 243 // showed (the picker replaces nutrition; updateModalUI guards later calls). 244 const nutritionSec = document.getElementById('modal-nutrition-section'); 245 if (nutritionSec) nutritionSec.classList.add('hidden'); 246 renderFlavorList(); 247} 248 249export function selectFlavor(memberIdx) { 250 if (!modalGroup) return; 251 const member = modalGroup.members[memberIdx]; 252 if (!member) return; 253 state.modalItem = member; 254 const sizes = pricedSizes(member); 255 state.selectedSize = sizes.includes('M') ? 'M' : (sizes[0] || null); 256 updateModalUI(); // refresh price, allergens and the cart button for the chosen flavor 257 applyFlavorSelection(); 258} 259 260function renderFlavorList() { 261 const sec = document.getElementById('modal-flavor-section'); 262 if (!sec || !modalGroup) return; 263 sec.replaceChildren(); 264 265 const heading = document.createElement('h4'); 266 heading.className = 'text-[7px] font-semibold uppercase tracking-widest text-gray-500 dark:text-neutral-400 mb-2'; 267 heading.textContent = state.isIcelandic ? 'Veldu tegund' : 'Choose a flavor'; 268 269 // No inner scroll: the whole sheet scrolls now (one scroll, sticky footer), 270 // so a long flavor list â e.g. orkudrykkir â reads as a single page behind 271 // the pinned Panta button instead of a cramped 15rem scroll box. 272 const list = document.createElement('div'); 273 list.className = 'flex flex-col gap-1.5'; 274 list.setAttribute('role', 'radiogroup'); 275 list.setAttribute('aria-label', heading.textContent); 276 277 modalGroup.members.forEach((member, i) => { 278 // A div, not a button: admins get real edit/delete buttons inside the 279 // row and nested buttons are invalid HTML.
280 const row = document.createElement('div'); 281 row.setAttribute('role', 'radio'); 282 row.tabIndex = 0; 283 row.dataset.flavorIndex = String(i); 284 row.onclick = () => selectFlavor(i); 285 row.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectFlavor(i); } }; 286 287 const name = document.createElement('span'); 288 name.className = 'flex-1 truncate'; 289 name.textContent = state.isIcelandic ? member.nameIs : member.nameEn; 290 row.appendChild(name); 291 292 row.appendChild(flavorCheckSvg()); 293 list.appendChild(row); 294 }); 295 296 sec.append(heading, list); 297 sec.classList.remove('hidden'); 298 applyFlavorSelection(); 299} 300 301 302const flavorCheckSvg = () => svgIcon('M5 13l4 4L19 7', 'flavor-check w-3.5 h-3.5 flex-shrink-0', 3); 303 304// Selected flavor = filled red pill (mirrors the size-tab indicator), the rest muted. 305function applyFlavorSelection() { 306 if (!modalGroup) return; 307 const base = 'flavor-option w-full flex items-center gap-3 text-left px-4 py-3 rounded-xl text-[13px] tracking-tight transition-all active:scale-[0.98] cursor-pointer'; 308 document.querySelectorAll('#modal-flavor-section [role="radio"]').forEach(btn => { 309 const member = modalGroup.members[Number(btn.dataset.flavorIndex)]; 310 const active = !!(member && state.modalItem && String(member.id) === String(state.modalItem.id)); 311 btn.className = active 312 ? base + ' bg-red-500 text-white font-black shadow-sm' 313 : base + ' bg-gray-100/80 dark:bg-neutral-800/80 text-gray-600 dark:text-neutral-300 font-semibold hover:bg-gray-200/80 dark:hover:bg-neutral-700/80'; 314 btn.setAttribute('aria-checked', active ? 'true' : 'false'); 315 const check = btn.querySelector('.flavor-check'); 316 if (check) check.style.opacity = active ? '1' : '0'; 317 }); 318} 319 320// Public close â used by the X button, tap-outside, Escape and the swipe-down 321// gesture. When the current history entry is the modal's own, route through 322// history.back() so Back/Forward stay in sync (the popstate handler then runs 323// closeModalNow); otherwise tear down directly. 324export function closeModal() { 325 if (atView('modal')) history.back(); 326 else closeModalNow(); 327} 328 329// Rebuild the modal from a history entry (open it if needed), or close it when 330// `snap` is null (we landed on a non-modal entry). Called by the popstate 331// handler in app.js; never pushes history itself (passes restore:true). 332// Returns true when this call actually tore an open modal down, so the 333// popstate handler can tell "Back just dismissed the sheet" from real 334// navigation and skip re-rendering the untouched page beneath. 335export function resolveModalHistory(snap) { 336 const open = document.getElementById('item-modal')?.classList.contains('flex'); 337 if (!snap) { 338 if (open) closeModalNow(); 339 return open === true; 340 } 341 // Already showing the right item? Leave it. Else (re)open for the snapshot. 342 if (open && state.modalItem && String(state.modalItem.id) === String(snap.itemId)) return; 343 if (snap.groupKey) { 344 openDrinkGroup(snap.groupKey, { restore: true }); 345 } else { 346 const idx = rawData.findIndex(i => String(i.id) === String(snap.itemId)); 347 if (idx >= 0) openModal(idx, { restore: true }); 348 } 349} 350 351// The actual DOM teardown â no history side effects, so the popstate handler 352// can call it safely. Runs the slide-down/scale-out then hides the dialog. 353function closeModalNow() { 354 const m = document.getElementById('item-modal'); 355 const c = document.getElementById('modal-content'); 356 if (c) { 357 c.style.removeProperty('--sheet-drag'); 358 c.classList.remove('opacity-100', 'scale-100', 'modal-visible'); 359 c.classList.add('opacity-0', 'scale-95'); 360 } 361 setTimeout(() => { if (m) { m.classList.remove('flex'); m.classList.add('hidden'); } resetMacroVisuals(); modalGroup = null; }, 300); 362 // Back to the card that opened it (skipped if a re-render replaced it). 363 if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true }); 364 returnFocus = null; 365} 366 367// Keep Tab inside the open sheet (aria-modal promises the page behind is inert). 368export function initModalFocusTrap() { 369 const modal = document.getElementById('item-modal'); 370 if (!modal) return; 371 modal.addEventListener('keydown', (e) => { 372 if (e.key !== 'Tab') return; 373 const focusable = [...modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not
373([tabindex="-1"])')] 374 .filter(el => !el.disabled && el.getClientRects().length); 375 if (focusable.length === 0) return; 376 const first = focusable[0], last = focusable[focusable.length - 1]; 377 if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } 378 else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } 379 }); 380} 381 382// Drag-down-to-dismiss for the mobile bottom sheet. From the top of the 383// scrolled content, dragging down moves the whole sheet via the --sheet-drag 384// offset and â past a distance threshold or with a downward flick â closes it; 385// otherwise it springs back. Wired once at startup against the static sheet. 386export function initSheetDrag() { 387 const sheet = document.getElementById('modal-content'); 388 if (!sheet) return; 389 // The inner scroller after the sticky-footer restructure; falls back to the 390 // sheet itself so this is safe regardless of markup order. 391 const scroller = () => document.getElementById('modal-scroll') || sheet; 392 393 let startY = 0, lastY = 0, lastT = 0, dragging = false, delta = 0, velocity = 0; 394 395 const onStart = (e) => { 396 if (!isMobile()) return; // bottom sheet is mobile-only 397 if (scroller().scrollTop > 0) return; // let the content scroll first 398 const t = e.touches[0]; 399 startY = lastY = t.clientY; lastT = e.timeStamp; 400 dragging = true; delta = 0; velocity = 0; 401 sheet.style.transition = 'none'; // follow the finger 1:1 402 }; 403 const onMove = (e) => { 404 if (!dragging) return; 405 const y = e.touches[0].clientY; 406 const dt = e.timeStamp - lastT; 407 if (dt > 0) velocity = (y - lastY) / dt; // px/ms, positive = downward 408 lastY = y; lastT = e.timeStamp; 409 delta = y - startY; 410 if (delta <= 0) { sheet.style.setProperty('--sheet-drag', '0px'); return; } 411 e.preventDefault(); // we own this downward gesture 412 sheet.style.setProperty('--sheet-drag', (delta * 0.9) + 'px'); // slight resistance 413 }; 414 const onEnd = () => { 415 if (!dragging) return; 416 dragging = false; 417 sheet.style.transition = ''; // restore the class transition 418 const dismiss = delta > 120 || (delta > 40 && velocity > 0.5); 419 if (dismiss) closeModal(); // closeModalNow clears --sheet-drag 420 else sheet.style.setProperty('--sheet-drag', '0px'); // spring back 421 }; 422 423 sheet.addEventListener('touchstart', onStart, { passive: true }); 424 sheet.addEventListener('touchmove', onMove, { passive: false }); 425 sheet.addEventListener('touchend', onEnd); 426 sheet.addEventListener('touchcancel', onEnd); 427}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.