PageSourceSearch

https://snooze.pub/assets/lib/pos_menu_cache-9c66ee07f5c93e327e059…157ed3fe39ef365da1cfbe2961e232393450a53b.js

js snooze.pub collected 2026-10-07 06:56:56 UTC 28,241 bytes, 805 lines download raw bytes

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.