PageSourceSearch

https://worldclassplus.vercel.app/js/modal.js

js worldclassplus.vercel.app collected 2026-10-03 08:03:17 UTC 23,045 bytes, 427 lines download raw bytes

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.