1// Instant POS category/product panel swaps + table-open via HTML cache. 2 3import { 4 persistPosScreen, 5 loadPersistedPosScreens, 6 trimPersistedPosScreens, 7 clearPersistedPosScreens, 8 dropPersistedPosScreen 9} from "lib/pos_offline_store" 10 11const cache = new Map() 12const screenCache = new Map() 13const MAX_CACHE = 80 14const MAX_PERSISTED_AGE_MS = 12 * 60 * 60 * 1000 15const MAX_SCREEN_CACHE = 80 16const inflight = new Map() 17let hydration = null 18let menuPanelsCache = null 19let menuPanelsCacheVersion = null 20let menuPanelsInflight = null 21let tableOpenInflight = null 22 23function menuCacheVersion() { 24 return document.querySelector('meta[name="pos-menu-version"]')?.content || "" 25} 26 27export function invalidatePosMenuCaches() { 28 clearPersistedPosScreens() 29 hydration = null 30 cache.clear() 31 screenCache.clear() 32 inflight.clear() 33 menuPanelsCache = null 34 menuPanelsCacheVersion = null 35 menuPanelsInflight = null 36} 37 38function menuPanelsUrl(tableId, baseUrl = window.location.href) { 39 const u = new URL(baseUrl, window.location.origin) 40 const out = new URL(u.pathname.replace(/\/restaurant-pos(\/.*)?$/, "/restaurant-pos/menu-panels"), u.origin) 41 out.searchParams.set("table_id", String(tableId)) 42 const loc = u.searchParams.get("booking_location_id") 43 if (loc) out.searchParams.set("booking_location_id", loc) 44 const ver = menuCacheVersion() 45 if (ver) out.searchParams.set("v", ver) 46 return out.toString() 47} 48 49export function prefetchMenuPanels(tableId, baseUrl = window.location.href) { 50 if (!tableId) return Promise.resolve(null) 51 const ver = menuCacheVersion() 52 if (menuPanelsCache && menuPanelsCacheVersion === ver) return Promise.resolve(menuPanelsCache) 53 if (menuPanelsCache && menuPanelsCacheVersion !== ver) { 54 invalidatePosMenuCaches() 55 } 56 if (menuPanelsInflight) return menuPanelsInflight 57 58 const url = menuPanelsUrl(tableId, baseUrl) 59 menuPanelsInflight = fetch(url, { 60 headers: { Accept: "application/json", "X-Pos-Request": "1" }, 61 credentials: "same-origin", 62 cache: "no-store" 63 }) 64 .then((r) => { 65 if (!r.ok) throw new Error("menu panels failed") 66 return r.json() 67 }) 68 .then((panels) => { 69 menuPanelsCache = panels 70 menuPanelsCacheVersion = (panels && panels._v) || ver 71 menuPanelsInflight = null 72 seedPosMenuPanels(panels, baseUrl) 73 // Keep meta in sync when server bumped the menu version. 74 if (panels && panels._v) { 75 const meta = document.querySelector('meta[name="pos-menu-version"]') 76 if (meta) meta.content = String(panels._v) 77 } 78 return panels 79 }) 80 .catch(() => { 81 menuPanelsInflight = null 82 return null 83 }) 84 85 return menuPanelsInflight 86} 87 88let applyMenuVersionTimer = null 89 90/** Server pushed a new catalog version â drop local cache and reload panels. */ 91export function applyPosMenuVersion(version) { 92 const next = String(version || "") 93 if (!next) return 94 clearTimeout(applyMenuVersionTimer) 95 applyMenuVersionTimer = setTimeout(() => applyPosMenuVersionNow(next), 400) 96} 97 98function applyPosMenuVersionNow(next) { 99 const meta = document.querySelector("meta[name='pos-menu-version']") 100 const current = (meta && meta.content) || "" 101 if (current === next && menuPanelsCacheVersion === next) return 102 if (meta) meta.content = next 103 invalidatePosMenuCaches() 104 105 const screen = document.querySelector(".pos-screen") 106 const tableId = 107 (screen && screen.dataset.restaurantPosScreenTableIdValue) || 108 (document.querySelector(".pos-table-group[data-table-id]") || {}).dataset?.tableId 109 if (!tableId) return 110 111 prefetchMenuPanels(tableId).then((panels) => { 112 if (!panels) return 113 if (document.querySelector(".pos-screen.pos-on-table")) { 114 let catId = null 115 try { catId = new URL(window.location.href).searchParams.get("category_id") } catch (_e) { /* ignore */ } 116 swapPosMenuByCategoryId(catId, window.location.href) 117 } 118 }) 119} 120 121function trimMap(map, max) { 122 while (map.size > max) { 123 const first = map.keys().next().value 124 map.delete(first) 125 } 126} 127 128function isTableScreenHtml(html) { 129 if (typeof html !== "string" || !html.includes("pos-on-table")) return false 130 try { 131 const doc = new DOMParser().parseFromString(html, "text/html") 132 return Boolean(doc.querySelector(".pos-screen.pos-on-table")) 133 } catch (_e) { 134 return false 135 } 136} 137 138export function isPosTableScreenHtml(html) { 139 return isTableScreenHtml(html) 140} 141 142// Ein echter Tischplan hat den Plan-Behaelter und ist kein Tischbildschirm. 143// Ohne diese Pruefung landet ein kaputter Eintrag aus dem Speicher als 144// schwarze Seite auf dem Schirm. 145function isFloorPageHtml(html) { 146 if (typeof html !== "string" || !html) return false 147 // Nicht nach Woertern suchen: "pos-on-table" steht im Stilblock JEDER 148 // Kassenseite, also auch im Tischplan. Eine Textsuche hielt damit jeden 149 // Tischplan fuer einen Tischbildschirm - der Knopf "Quitter" fiel deshalb 150 // immer auf das Neuladen zurueck, und der Tischplan wurde nie im Geraet 151 // abgelegt. Nur der geparste Seitenbaum entscheidet. 152 if (!html.includes("pos-floor-plan-host")) return false 153 154 try { 155 const doc = new DOMParser().parseFromString(html, "text/html") 156 // Ein Tischbildschirm traegt den Plan nur in seinen Dialogen - das zaehlt 157 // nicht als Tischplan. 158 if (doc.querySelector(".pos-screen.pos-on-table")) return false 159 return Boolean(doc.querySelector(".pos-floor-plan-host")) 160 } catch (_e) { 161 return false 162 } 163} 164 165function storedHtml(key) { 166 const screen = screenCache.get(key) 167 if (typeof screen === "string" && screen !== "loading") return screen 168 const value = cache.get(key) 169 if (typeof value === "string" && value !== "loading") return value 170 return null 171} 172 173function putHtml(key, html) { 174 if (isTableScreenHtml(html)) { 175 screenCache.set(key, html) 176 trimMap(screenCache, MAX_SCREEN_CACHE) 177 cache.delete(key) 178 keepOnDevice(key, html, "screen") 179 return 180 } 181 cache.set(key, html) 182 trimMap(cache, MAX_CACHE) 183 // Nur echte Seiten gehoeren ins Geraet. Eine Menue-Tafel wurde bisher unter 184 // der Adresse abgelegt, auf der man gerade stand - und hat dort Tischplan 185 // oder Tischbildschirm ueberschrieben. Beim naechsten Start ohne Netz war 186 // die Seite weg. Tafeln liegen ohnehin im Offline-Paket. 187 if (isFloorPageHtml(html)) keepOnDevice(key, html, "floor") 188} 189 190// Dieselbe Seite zusaetzlich in die IndexedDB legen. Die Maps oben sind nach 191// jedem Neuladen leer - ohne diesen Schritt steht der Kellner ohne Netz vor 192// einem toten Bildschirm.
193let persistCounter = 0 194function keepOnDevice(key, html, kind) { 195 if (typeof html !== "string" || html === "loading") return 196 persistPosScreen(key, html, kind) 197 persistCounter += 1 198 if (persistCounter % 25 === 0) trimPersistedPosScreens() 199} 200 201// Beim Start die gespeicherten Seiten zurueck in den Arbeitsspeicher holen. 202// Frisch geladene Seiten haben Vorrang, die sind naeher an der Wahrheit. 203export function hydratePosCachesFromDevice() { 204 if (hydration) return hydration 205 hydration = loadPersistedPosScreens().then((rows) => { 206 const oldest = Date.now() - MAX_PERSISTED_AGE_MS 207 rows.forEach((row) => { 208 if (!row || !row.key || typeof row.html !== "string") return 209 // Vorlage und Karte sind keine Seiten - die holt sich der Tischschirm. 210 if (row.kind === "template" || row.kind === "panels") return 211 // Nur echte Kassenseiten zurueckholen. Alte, kaputte Eintraege (etwa eine 212 // Menue-Tafel unter der Adresse des Tischplans) fuehren sonst zu einer 213 // leeren Seite. 214 if (!isTableScreenHtml(row.html) && !isFloorPageHtml(row.html)) { 215 dropPersistedPosScreen(row.key) 216 return 217 } 218 if (row.kind === "screen" && Number(row.at || 0) < oldest) return 219 // "floor" ist die neue Art fuer Tischplaene; "panel" stand frueher an 220 // derselben Stelle und wird weiter gelesen. 221 const map = row.kind === "screen" ? screenCache : cache 222 if (map.has(row.key)) return 223 if (row.kind === "screen" ? cache.has(row.key) : screenCache.has(row.key)) return 224 map.set(row.key, row.html) 225 }) 226 return rows.length 227 }).catch(() => 0) 228 return hydration 229} 230 231// Irgendein Tischbildschirm aus dem Speicher - Vorlage fuer einen Tisch, den 232// dieses Geraet noch nie offen hatte. 233function currentPosProjectId() { 234 const match = window.location.pathname.match(/\/projects\/(\d+)\//) 235 return match ? match[1] : null 236} 237 238// Irgendein Tischbildschirm - aber nur aus dem eigenen Betrieb. Sonst oeffnet 239// Pavillon einen Tisch mit dem Bildschirm von Los Monos. 240export function anyCachedPosTableScreen() { 241 const projectId = currentPosProjectId() 242 243 for (const [key, html] of screenCache.entries()) { 244 if (!isTableScreenHtml(html)) continue 245 if (projectId && !String(key).includes("/projects/" + projectId + "/")) continue 246 247 return html 248 } 249 return null 250} 251 252export function normalizePosUrl(url) { 253 try { 254 const u = new URL(url, window.location.origin) 255 // Covers only matters when opening a table â category links omit it, so keeping 256 // it in the cache key caused every tap to miss the seeded Redis panels. 257 u.searchParams.delete("covers") 258 const params = Array.from(u.searchParams.entries()).sort((a, b) => { 259 if (a[0] === b[0]) return String(a[1]).localeCompare(String(b[1])) 260 return a[0].localeCompare(b[0]) 261 }) 262 const qs = new URLSearchParams(params).toString() 263 return u.pathname + (qs ? `?${qs}` : "") 264 } catch (_e) { 265 return String(url) 266 } 267} 268 269/** Sync swap from embedded Redis panels (preferred â no network). */ 270export function swapPosMenuByCategoryId(categoryId, historyUrl) { 271 ensureMenuPanelsFromDom() 272 const key = categoryId ? String(categoryId) : "root" 273 const fragment = menuPanelsCache && menuPanelsCache[key] 274 if (typeof fragment !== "string" || !fragment) return false 275 276 const wrapped = `<div class="pos-panel pos-main-panel flex-grow-1">${fragment}</div>`
277 const url = normalizePosUrl(historyUrl || menuUrlForKey(key, window.location.href) || window.location.href) 278 const html = wrapAsDocument(wrapped, url) 279 rememberPosMenu(url, html) 280 return swapPosMainFromHtml(html, url) 281} 282 283function ensureMenuPanelsFromDom() { 284 if (menuPanelsCache) return 285 hydratePosMenuPanelsFromDom() 286} 287 288export function prefetchPosMenu(url, { force = false } = {}) { 289 if (!url) return Promise.resolve(null) 290 const key = normalizePosUrl(url) 291 if (!force) { 292 const existing = storedHtml(key) 293 if (existing) return Promise.resolve(existing) 294 if (inflight.has(key)) return inflight.get(key) 295 } else { 296 cache.delete(key) 297 screenCache.delete(key) 298 inflight.delete(key) 299 } 300 301 const promise = fetch(key, { 302 headers: { Accept: "text/html", "X-Pos-Request": "1" }, 303 credentials: "same-origin", 304 cache: "no-store" 305 }) 306 .then((r) => { 307 if (r.status === 409) throw new Error("locked") 308 if (!r.ok) throw new Error("prefetch failed") 309 return r.text() 310 }) 311 .then((html) => { 312 putHtml(key, html) 313 inflight.delete(key) 314 return html 315 }) 316 .catch((err) => { 317 cache.delete(key) 318 screenCache.delete(key) 319 inflight.delete(key) 320 if (err && err.message === "locked") throw err 321 return null 322 }) 323 324 inflight.set(key, promise) 325 return promise 326} 327 328export function getCachedPosMenu(url) { 329 return storedHtml(normalizePosUrl(url)) 330} 331 332export function rememberPosMenu(url, html) { 333 if (!url || !html) return 334 putHtml(normalizePosUrl(url), html) 335} 336 337export function dropPosTablePreviews(tableId) { 338 if (!tableId) return 339 const needle = "table_id=" + String(tableId) 340 ;[cache, screenCache].forEach((map) => { 341 Array.from(map.keys()).forEach((key) => { 342 if (String(key).includes(needle)) map.delete(key) 343 }) 344 }) 345} 346 347export function posTablesBasePath() { 348 const path = window.location.pathname 349 if (path.includes("/restaurant-pos")) { 350 return path.replace(/\/restaurant-pos(\/.*)?$/, "/restaurant-pos/tables") 351 } 352 return "/tables" 353} 354 355export function posTableOpenUrl(tableId) { 356 return posTablesBasePath() + "/" + encodeURIComponent(String(tableId)) + "/open" 357} 358 359export function releasePosTableClaim() { 360 const url = posTablesBasePath() + "/leave" 361 const token = document.querySelector("meta[name='csrf-token']")?.content || "" 362 return fetch(url, { 363 method: "POST", 364 credentials: "same-origin", 365 // keepalive: sonst bricht der Browser die Freigabe ab, sobald die Seite 366 // wechselt - der Tisch bliebe fuer die anderen gesperrt. 367 keepalive: true, 368 headers: { 369 Accept: "application/json", 370 "X-Pos-Request": "1", 371 "X-CSRF-Token": token 372 } 373 }).catch(() => null) 374} 375 376export function warmPosMenuLinks(root = document) { 377 const links = Array.from(root.querySelectorAll("a.pos-category-btn, .pos-back-bar a")) 378 links.forEach((a, idx) => { 379 if (!a.href) return 380 window.setTimeout(() => prefetchPosMenu(a.href), Math.min(idx * 15, 250)) 381 }) 382} 383 384/** Prefetch table category shell while waiter picks covers (covers applied on final URL). */ 385export function prefetchTableOpenVariants(tableId, baseUrl = window.location.href) { 386 if (!tableId) return 387 const base = new URL(baseUrl, window.location.origin) 388 base.searchParams.set("table_id", String(tableId)) 389 base.searchParams.delete("category_id") 390 base.searchParams.delete("covers") 391 prefetchPosMenu(base.toString()) 392 prefetchMenuPanels(tableId, baseUrl) 393} 394 395function bootPosScreen(root) { 396 if (window.Stimulus && root) { 397 try { window.Stimulus.load(root) } catch (_e) { /* optional */ } 398 } 399 if (menuPanelsCache) { 400 seedPosMenuPanels(menuPanelsCache, window.location.href) 401 } else { 402 hydratePosMenuPanelsFromDom(root || document) 403 } 404 // Warm only a few visible category links â never stampede all grids at once. 405 const links = Array.from((root || document).querySelectorAll("a.pos-category-btn, .pos-back-bar a")) 406 .filter((a) => a.href) 407 .slice(0, 4) 408 links.forEach((a, idx) => { 409 // Skip network if already seeded from Redis snapshot. 410 if (getCachedPosMenu(a.href)) return 411 window.setTimeout(() => prefetchPosMenu(a.href), idx * 40) 412 }) 413 // Category carousel may mount before flex layout settles â relayout after paint. 414 requestAnimationFrame(() => { 415 requestAnimationFrame(() => { 416 ;(root || document).querySelectorAll("[data-controller~='restaurant-pos-category-groups']").forEach((el) => { 417 el.dispatchEvent(new CustomEvent("pos-category-groups:relayout", { bubbles: false })) 418 }) 419 window.dispatchEvent(new CustomEvent("pos-menu:ready")) 420 }) 421 }) 422} 423 424export function swapPosMainFromHtml(html, url) { 425 const doc = new DOMParser().parseFromString(html, "text/html") 426 const newPanel = doc.querySelector(".pos-main-panel") 427 const curPanel = document.querySelector(".pos-main-panel") 428 if (!newPanel || !curPanel) return false 429 430 // Only swap the menu panel â never copy data-restaurant-pos-screen-* from cache. 431 // Cached pages can carry a stale order_id / add-item URL and break adds (404). 432 curPanel.replaceWith(newPanel) 433 try { 434 history.pushState({ posInstantMenu: true }, "", url) 435 } catch (_e) { /* optional */ } 436 437 // Ensure at least one group pane accepts taps (pointer-events only on .is-active-pane). 438 const groups = document.querySelector(".pos-category-groups") 439 if (groups) { 440 const panes = groups.querySelectorAll(".pos-category-group-pane") 441 if (panes.length && !groups.querySelector(".pos-category-group-pane.is-active-pane")) { 442 panes[0].classList.add("is-active-pane") 443 } 444 } 445 446 bootPosScreen(document.querySelector(".pos-screen")) 447 return true 448} 449
450/** Full table open from cache (floor â categories) without waiting on network. */ 451function disposePosModal(el) { 452 if (!el) return 453 if (window.bootstrap?.Modal) { 454 const inst = window.bootstrap.Modal.getInstance(el) 455 try { inst?.hide() } catch (_e) { /* already gone */ } 456 try { inst?.dispose() } catch (_e2) { /* already gone */ } 457 } 458 el.remove() 459} 460 461// Payment modal lives OUTSIDE .pos-screen in index.html. Instant table 462// swaps only replace the screen, so TH1's modal stayed behind on table 5. 463function swapPosPaymentModal(doc, newScreen) { 464 const incoming = doc.getElementById("pos-payment-modal") 465 const goingToTable = !!(newScreen && newScreen.classList.contains("pos-on-table")) 466 if (incoming) { 467 document.querySelectorAll("#pos-payment-modal").forEach((el) => disposePosModal(el)) 468 const node = document.importNode(incoming, true) 469 document.body.appendChild(node) 470 if (window.Stimulus) { 471 try { window.Stimulus.load(node) } catch (_e) { /* optional */ } 472 } 473 return 474 } 475 // Floor / cache fragment without a modal: do not wipe a working dialog. 476 // Only drop it when leaving the table. 477 if (!goingToTable) { 478 document.querySelectorAll("#pos-payment-modal").forEach((el) => disposePosModal(el)) 479 } 480} 481 482export function swapPosScreenFromHtml(html, url) { 483 const doc = new DOMParser().parseFromString(html, "text/html") 484 const newScreen = doc.querySelector(".pos-screen") 485 const curScreen = document.querySelector(".pos-screen") 486 if (!newScreen || !curScreen) return false 487 488 // Keep bill/actions shell if present in new HTML â replace whole screen node. 489 curScreen.replaceWith(newScreen) 490 swapPosPaymentModal(doc, newScreen) 491 492 // Copy any action bar that lives outside .pos-screen in some layouts â usually inside. 493 try { 494 history.replaceState({ posInstantTable: true }, "", url) 495 } catch (_e) { /* optional */ } 496 497 document.body.classList.remove("pos-opening-table") 498 const veil = document.getElementById("pos-opening-veil") 499 if (veil) veil.remove() 500 501 bootPosScreen(newScreen) 502 return true 503} 504 505export async function openPosMenuInstant(url) { 506 const key = normalizePosUrl(url) 507 // Prefer embedded panel snapshot by category_id (avoids URL/covers mismatches). 508 try { 509 const u = new URL(url, window.location.origin) 510 const catId = u.searchParams.get("category_id") 511 if (swapPosMenuByCategoryId(catId, key)) return true 512 } catch (_e) { /* fall through */ } 513 514 let html = getCachedPosMenu(key) 515 if (!html) { 516 html = await prefetchPosMenu(key) 517 } 518 if (!html) throw new Error("menu load failed") 519 // Ohne Netz kommt hier auch mal der Tischplan oder eine Notfallseite an - 520 // die darf nie im Produktraster landen. 521 if (!looksLikeMenuPanel(html)) throw new Error("not a menu panel") 522 if (!swapPosMainFromHtml(html, key)) throw new Error("panel missing") 523 return true 524} 525 526// Dieselbe Falle: "pos-floor-plan-host" steht ebenfalls im Stilblock jeder 527// Kassenseite. Eine Textsuche verwarf damit auch echte Produktseiten, und die 528// Kasse lud die Kategorie neu, statt sie sofort zu zeigen. Entscheidend ist, 529// was im Hauptbereich steht - der Plan in einem Dialog zaehlt nicht. 530function looksLikeMenuPanel(html) { 531 if (typeof html !== "string" || !html) return false 532 533 try { 534 const doc = new DOMParser().parseFromString(html, "text/html") 535 const main = doc.querySelector(".pos-main-panel") 536 if (!main) return false 537 if (main.querySelector(".pos-floor-plan-host")) return false 538 return Boolean(main.querySelector(".pos-product-grid, .pos-category-btn")) 539 } catch (_e) { 540 return false 541 } 542} 543 544export async function openPosTableInstant(url) { 545 const key = normalizePosUrl(url) 546 if (tableOpenInflight && tableOpenInflight.key === key) { 547 return tableOpenInflight.promise 548 } 549 550 const promise = openPosTableInstantInner(url, key) 551 tableOpenInflight = { key, promise } 552 try { 553 return await promise 554 } finally { 555 if (tableOpenInflight?.key === key) tableOpenInflight = null 556 } 557} 558 559async function openPosTableInstantInner(url, key) { 560 let html = getCachedPosMenu(key) 561 562 if (!html) { 563 const shell = new URL(url, window.location.origin) 564 shell.searchParams.delete("covers") 565 const shellKey = normalizePosUrl(shell.toString()) 566 html = getCachedPosMenu(shellKey) || await prefetchPosMenu(shellKey) 567 } 568 569 if (!html) throw new Error("table load failed") 570 if (!html.includes("pos-on-table")) throw new Error("locked") 571 if (!swapPosScreenFromHtml(html, key)) throw new Error("screen missing") 572 573 const shell = new URL(url, window.location.origin) 574 shell.searchParams.delete("covers") 575 const shellKey = normalizePosUrl(shell.toString()) 576 if (shellKey !== key) { 577 prefetchPosMenu(key).then((fresh) => { 578 if (fresh) refreshPosOrderContext(fresh) 579 }).catch(() => {}) 580 } 581 return true 582} 583 584export function refreshPosOrderContext(html) { 585 const doc = new DOMParser().parseFromString(html, "text/html") 586 const fresh = doc.querySelector(".pos-screen") 587 const cur = document.querySelector(".pos-screen") 588 if (!fresh || !cur) return false 589 590 // Refresh order URLs only â do not replace the bill panel (breaks line select/dblclick).
591 Array.from(fresh.attributes).forEach((attr) => { 592 if (attr.name.startsWith("data-restaurant-pos-screen-")) { 593 cur.setAttribute(attr.name, attr.value) 594 } 595 }) 596 597 // Der Zahlungsdialog haengt an derselben Bestellung. Bleibt er auf einer 598 // alten Bestell-Nummer stehen, laufen Bezahlen und Nachladen ins Leere. 599 const freshPay = doc.querySelector("#pos-payment-modal") 600 const curPay = document.querySelector("#pos-payment-modal") 601 if (freshPay && curPay) { 602 Array.from(freshPay.attributes).forEach((attr) => { 603 if (attr.name.startsWith("data-restaurant-pos-payment-")) { 604 curPay.setAttribute(attr.name, attr.value) 605 } 606 }) 607 } else if (freshPay && !curPay) { 608 // Der Bildschirm kam aus dem Speicher und stammt aus einer Zeit, in der 609 // der Tisch noch leer war - ohne Bon rendert der Server keinen 610 // Zahlungsdialog. Beim Bezahlen kam dann "Ecran de paiement 611 // indisponible" (06.09.2026). Der frische Dialog wird jetzt nachgereicht. 612 document.body.appendChild(freshPay.cloneNode(true)) 613 } 614 615 return true 616} 617 618/** Floor URL without table/category/covers â used to leave a table. */ 619export function floorShellUrl(baseUrl = window.location.href) { 620 try { 621 const u = new URL(baseUrl, window.location.origin) 622 u.searchParams.delete("table_id") 623 u.searchParams.delete("category_id") 624 u.searchParams.delete("covers") 625 return normalizePosUrl(u.toString()) 626 } catch (_e) { 627 return normalizePosUrl(baseUrl) 628 } 629} 630 631/** Snapshot the floor plan screen so Leave can paint instantly. */ 632export function rememberPosFloorShell(baseUrl = window.location.href) { 633 const screen = document.querySelector(".pos-screen") 634 if (!screen || screen.classList.contains("pos-on-table")) return false 635 try { 636 rememberPosMenu(floorShellUrl(baseUrl), document.documentElement.outerHTML) 637 return true 638 } catch (_e) { 639 return false 640 } 641} 642 643/** 644 * Leave table â floor without a full page reload when the floor shell is cached. 645 * Always re-fetches the floor URL in the background so the session releases the table claim. 646 */ 647export async function leavePosTableInstant(floorUrl) { 648 const key = normalizePosUrl(floorUrl || floorShellUrl()) 649 let tableId = null 650 try { tableId = new URL(window.location.href).searchParams.get("table_id") } catch (_e) { /* ignore */ } 651 const onTable = document.querySelector(".pos-screen.pos-on-table") 652 tableId = tableId || (onTable && onTable.dataset.restaurantPosScreenTableIdValue) 653 if (tableId) dropPosTablePreviews(tableId) 654 releasePosTableClaim() 655 656 const cached = getCachedPosMenu(key) 657 658 if (cached && isFloorPageHtml(cached) && swapPosScreenFromHtml(cached, key)) { 659 prefetchPosMenu(key, { force: true }).then((fresh) => { 660 if (!fresh) return 661 const screen = document.querySelector(".pos-screen") 662 if (screen && !screen.classList.contains("pos-on-table")) { 663 swapPosScreenFromHtml(fresh, key) 664 } else { 665 rememberPosMenu(key, fresh) 666 } 667 }).catch(() => {}) 668 return true 669 } 670 671 const html = await prefetchPosMenu(key, { force: true }) 672 if (html && isFloorPageHtml(html) && swapPosScreenFromHtml(html, key)) return true 673 674 if (window.Turbo && typeof window.Turbo.visit === "function") { 675 window.Turbo.visit(key) 676 } else { 677 window.location.assign(key) 678 } 679 return false 680} 681 682/** Fetch live table shell and refresh order_id / add-item URLs (fixes stale cache). */ 683export async function resyncPosOrderContext(baseUrl = window.location.href) { 684 const url = new URL(baseUrl, window.location.origin) 685 url.searchParams.delete("category_id") 686 const key = normalizePosUrl(url.toString()) 687 const html = await prefetchPosMenu(key, { force: true }) 688 if (!html) throw new Error("resync failed") 689 refreshPosOrderContext(html) 690 return document.querySelector("[data-controller~='restaurant-pos-screen']") 691} 692 693// Nur den Arbeitsspeicher fuellen. Die grosse Fassung legt jede Tafel 694// zusaetzlich unter einer Adresse ab - die erste Tafel landet dabei unter der 695// Adresse der aktuellen Seite und ueberschreibt damit Tischplan oder 696// Tischbildschirm. Fuer das Paket ist das falsch: dort wollen wir die Tafeln 697// kennen, aber keine Seite ersetzen. 698export function seedPosMenuPanelsInMemory(panels) { 699 if (!panels || typeof panels !== "object") return false 700 701 menuPanelsCache = panels 702 menuPanelsCacheVersion = (panels && panels._v) || menuCacheVersion() 703 return true 704} 705 706export function seedPosMenuPanels(panels, baseUrl = window.location.href) { 707 if (!panels || typeof panels !== "object") return 708 menuPanelsCache = panels 709 menuPanelsCacheVersion = (panels && panels._v) || menuCacheVersion()
710 const here = normalizePosUrl(baseUrl) 711 Object.keys(panels).forEach((key) => { 712 if (key === "_v" || String(key).startsWith("_")) return 713 const html = panels[key] 714 if (typeof html !== "string" || !html) return 715 // Wrap fragment as a full main panel for the swapper. 716 const wrapped = `<div class="pos-panel pos-main-panel flex-grow-1">${html}</div>` 717 const url = menuUrlForKey(key, baseUrl) 718 if (!url) return 719 // Die Tafel "root" ergibt genau die Adresse der Seite, auf der man steht - 720 // auf dem Tischplan also dessen eigene Adresse. Wer sie dort ablegt, 721 // loescht den Tischplan. Nie eine echte Seite ersetzen. 722 if (url === here) return 723 const existing = storedHtml(url) 724 if (isTableScreenHtml(existing) || isFloorPageHtml(existing)) return 725 rememberPosMenu(url, wrapAsDocument(wrapped, url)) 726 }) 727} 728 729/** Drop stale category from URL and reload root menu after corrupt/stale product tap. */ 730export async function recoverStalePosMenu() { 731 invalidatePosMenuCaches() 732 try { 733 const u = new URL(window.location.href) 734 u.searchParams.delete("category_id") 735 const tableId = u.searchParams.get("table_id") 736 if (tableId) { 737 await prefetchMenuPanels(tableId, u.toString()) 738 } 739 window.location.assign(u.pathname + u.search) 740 } catch (_e) { 741 window.location.reload() 742 } 743} 744 745function menuUrlForKey(key, baseUrl) { 746 try { 747 const u = new URL(baseUrl, window.location.origin) 748 u.searchParams.delete("covers") 749 if (key === "root") { 750 u.searchParams.delete("category_id") 751 } else { 752 u.searchParams.set("category_id", String(key)) 753 } 754 return normalizePosUrl(u.toString()) 755 } catch (_e) { 756 return null 757 } 758} 759 760function wrapAsDocument(mainPanelHtml, url) { 761 // swapPosMainFromHtml parses a document and looks for .pos-main-panel 762 return `<!doctype html><html><body><div class="pos-screen" data-url="${url}">${mainPanelHtml}</div></body></html>` 763} 764 765export function hydratePosMenuPanelsFromDom(root = document) { 766 const el = (root || document).querySelector("#pos-menu-panels") 767 if (!el) return false 768 try { 769 const panels = JSON.parse(el.textContent || "{}") 770 seedPosMenuPanels(panels, window.location.href) 771 return true 772 } catch (_e) { 773 return false 774 } 775} 776 777export default { 778 prefetchPosMenu, 779 getCachedPosMenu, 780 rememberPosMenu, 781 warmPosMenuLinks, 782 prefetchTableOpenVariants, 783 prefetchMenuPanels, 784 swapPosMainFromHtml, 785 swapPosScreenFromHtml, 786 openPosMenuInstant, 787 openPosTableInstant, 788 leavePosTableInstant, 789 rememberPosFloorShell, 790 floorShellUrl, 791 seedPosMenuPanels, 792 hydratePosMenuPanelsFromDom, 793 seedPosMenuPanelsInMemory, 794 swapPosMenuByCategoryId, 795 resyncPosOrderContext, 796 normalizePosUrl, 797 invalidatePosMenuCaches, 798 applyPosMenuVersion, 799 dropPosTablePreviews, 800 releasePosTableClaim, 801 posTableOpenUrl, 802 isPosTableScreenHtml, 803 recoverStalePosMenu 804} 805;
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.