Startup error
` + `SMOS could not start
${error.message}
const state = { config: null, orders: [], localMode: false, recognition: null, listening: false, imageTimer: null, translationTimer: null, socket: null, tableId: null, lastDetectedLanguage: null, }; const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; const staticLanguages = [ ["en", "English", "en-GB"], ["tr", "Türkçe · Turkish — optimised", "tr-TR"], ["ar", "العربية · Arabic", "ar-SA"], ["bn", "বাংলা · Bengali", "bn-BD"], ["zh-CN", "中文 · Chinese", "zh-CN"], ["cs", "Čeština · Czech", "cs-CZ"], ["da", "Dansk · Danish", "da-DK"], ["nl", "Nederlands · Dutch", "nl-NL"], ["fi", "Suomi · Finnish", "fi-FI"], ["fr", "Français · French", "fr-FR"], ["de", "Deutsch · German", "de-DE"], ["el", "Ελληνικά · Greek", "el-GR"], ["hi", "हिन्दी · Hindi", "hi-IN"], ["hu", "Magyar · Hungarian", "hu-HU"], ["id", "Bahasa Indonesia", "id-ID"], ["it", "Italiano · Italian", "it-IT"], ["ja", "日本語 · Japanese", "ja-JP"], ["ko", "한국어 · Korean", "ko-KR"], ["ms", "Bahasa Melayu · Malay", "ms-MY"], ["no", "Norsk · Norwegian", "nb-NO"], ["pl", "Polski · Polish", "pl-PL"], ["pt", "Português · Portuguese", "pt-PT"], ["ro", "Română · Romanian", "ro-RO"], ["ru", "Русский · Russian", "ru-RU"], ["es", "Español · Spanish", "es-ES"], ["sw", "Kiswahili · Swahili", "sw-KE"], ["sv", "Svenska · Swedish", "sv-SE"], ["ta", "தமிழ் · Tamil", "ta-IN"], ["th", "ไทย · Thai", "th-TH"], ["uk", "Українська · Ukrainian", "uk-UA"], ["ur", "اردو · Urdu", "ur-PK"], ["vi", "Tiếng Việt · Vietnamese", "vi-VN"], ].map(([code, name, locale]) => ({ code, name, locale })); const turkishSafetyTerms = { "tr-en": { "alerjim var": ["I have an allergy", ["allergy", "allergic"]], "glütensiz": ["gluten-free", ["gluten-free", "without gluten"]], "laktozsuz": ["lactose-free", ["lactose-free", "without lactose"]], "süt ürünü olmasın": ["no dairy", ["no dairy", "dairy-free", "without dairy"]], "fıstık alerjisi": ["peanut allergy", ["peanut allergy", "allergic to peanuts"]], "fıstık alerjim var": ["peanut allergy", ["peanut allergy", "allergic to peanuts"]], "fıstığa alerjim var": ["peanut allergy", ["peanut allergy", "allergic to peanuts"]], "kuruyemiş alerjisi": ["nut allergy", ["nut allergy", "allergic to nuts"]], "kuruyemişe alerjim var": ["nut allergy", ["nut allergy", "allergic to nuts"]], "domuz eti olmasın": ["no pork", ["no pork", "without pork"]], "soğansız": ["no onion", ["no onion", "without onion", "onion-free"]], "sarımsaksız": ["no garlic", ["no garlic", "without garlic", "garlic-free"]], "acısız": ["not spicy", ["not spicy", "non-spicy", "without spice"]], }, "en-tr": { "i have an allergy": ["alerjim var", ["alerjim var", "alerjim bulunuyor"]], "gluten-free": ["glütensiz", ["glütensiz", "glutensiz"]], "lactose-free": ["laktozsuz", ["laktozsuz"]], "no dairy": ["süt ürünü olmasın", ["süt ürünü olmasın", "süt ürünsüz"]], "peanut allergy": [ "fıstık alerjisi", ["fıstık alerjisi", "fıstığa alerjim var", "yer fıstığına alerjim var"], ], "nut allergy": ["kuruyemiş alerjisi", ["kuruyemiş alerjisi"]], "no pork": ["domuz eti olmasın", ["domuz eti olmasın", "domuz etsiz"]], "no onion": ["soğansız", ["soğansız", "soğan olmasın"]], "no garlic": ["sarımsaksız", ["sarımsaksız", "sarımsak olmasın"]], "not spicy": ["acısız", ["acısız", "acı olmasın", "baharatlı değil"]], }, }; const elements = { modeButtons: $$(".nav [data-view]"), themeToggle: $("[data-theme-toggle]"), themeMeta: $("#meta-theme-color"), guestView: $("#guest-view"), kitchenView: $("#kitchen-view"), kitchenLanguage: $("#kitchen-language"), orderForm: $("#order-form"), detectedTable: $("#detected-table"), orderText: $("#order-text"), voiceButton: $("#voice-button"), voiceLabel: $(".voice-label"), listeningStatus: $("#listening-status"), previewButton: $("#preview-button"), translatedPreview: $("#translated-preview"), translationCard: $("#translation-card"), speakTranslation: $("#speak-translation"), dishImage: $("#dish-image"), imagePlaceholder: $("#image-placeholder"), imageLoading: $("#image-loading"), submitOrder: $("#submit-order"), formMessage: $("#form-message"), kitchenLanguage: $("#kitchen-language"), includeClosed: $("#include-closed"), ordersGrid: $("#orders-grid"), emptyOrders: $("#empty-orders"), orderTemplate: $("#order-template"), statusSummary: $("#status-summary"), newCount: $("#new-count"), connection: $("#connection"), version: $("#version"), }; async function api(path, options = {}) { if (state.localMode) return localApi(path, options); const response = await fetch(path, { headers: { "Content-Type": "application/json", ...options.headers }, ...options, }); if ( path === "/api/config" && !response.headers.get("content-type")?.includes("application/json") ) { state.localMode = true; return localApi(path, options); } if (!response.ok) { if (path === "/api/config" && response.status === 404) { state.localMode = true; return localApi(path, options); } const body = await response.json().catch(() => ({})); throw new Error(body.detail || `Request failed (${response.status})`); } return response.json(); } function inferSourceLanguage(text, requested = "auto") { if (requested !== "auto") return requested; const lowered = text.normalize("NFC").toLowerCase(); const turkishMarkers = [ "ç", "ğ", "ı", "ö", "ş", "ü", " istiyorum", " olsun", " olmasın", " lütfen", ]; return turkishMarkers.some((marker) => lowered.includes(marker)) ? "tr" : "auto"; } function browserLanguageCode() { const locale = (navigator.language || "en").toLowerCase(); const exact = staticLanguages.find((language) => language.locale.toLowerCase() === locale); if (exact) return exact.code; const base = locale.split("-")[0]; return staticLanguages.find((language) => language.code.split("-")[0] === base)?.code || "en"; } function resolveTableId() { const params = new URLSearchParams(window.location.search); const supplied = params.get("table") || params.get("table_id"); if (supplied) { const safe = supplied.normalize("NFC").replace(/[^\p{L}\p{N}_-]/gu, "").slice(0, 30); if (safe) return safe.toUpperCase(); } const storageKey = "smos-generated-table-id"; try { const existing = localStorage.getItem(storageKey); if (existing) return existing; const generated = `DEMO-${crypto.randomUUID().slice(0, 6).toUpperCase()}`; localStorage.setItem(storageKey, generated); return generated; } catch { return `DEMO-${Math.random().toString(36).slice(2, 8).toUpperCase()}`; } } function preserveTurkishSafetyTerms(sourceText, translatedText, source, target) { const terms = turkishSafetyTerms[`${source}-${target}`] || {}; const sourceLower = sourceText.toLocaleLowerCase(source === "tr" ? "tr-TR" : "en-GB"); const translatedLower = translatedText.toLocaleLowerCase(target === "tr" ? "tr-TR" : "en-GB"); const missing = []; const containsWholePhrase = (value, phrase) => { const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return new RegExp(`(^|[^\\p{L}\\p{N}_])${escaped}($|[^\\p{L}\\p{N}_])`, "u").test(value); }; Object.entries(terms).forEach(([phrase, [canonical, accepted]]) => { if ( containsWholePhrase(sourceLower, phrase) && !accepted.some((term) => containsWholePhrase(translatedLower, term.toLocaleLowerCase(target === "tr" ? "tr-TR" : "en-GB")) ) ) { missing.push(canonical); } }); if (!missing.length) return translatedText; const label = target === "en" ? "Order note" : "Sipariş notu"; return `${translatedText} — ${label}: ${missing.join(", ")}`; } function correctTurkishRestaurantContext(sourceText, translatedText, source, target) { if (source === "tr" && target === "en" && sourceText.toLocaleLowerCase("tr-TR").includes("acısız")) { return translatedText.replace( /\b(?:without\s+pain|no\s+pain|pain[\s-]?free|painless)\b/gi, "not spicy", ); } return translatedText; } async function browserGoogleTranslate(text, source, target) { const effectiveSource = inferSourceLanguage(text, source); state.lastDetectedLanguage = effectiveSource; if (!text.trim() || effectiveSource === target) return text; const normalised = text.normalize("NFC").replace(/[’`]/g, "'").replace(/\s+/g, " ").trim(); const url = "https://translate.googleapis.com/translate_a/single?client=gtx&dt=t" + `&sl=${encodeURIComponent(effectiveSource)}&tl=${encodeURIComponent(target)}` + `&q=${encodeURIComponent(normalised)}`; let translated; try { const response = await fetch(url); if (!response.ok) throw new Error("Google translation failed."); const data = await response.json(); const detected = data[2]; if (typeof detected === "string") { const alias = detected.toLowerCase() === "zh-cn" ? "zh-CN" : detected; if (staticLanguages.some((language) => language.code === alias)) { state.lastDetectedLanguage = alias; } } translated = data[0].map((part) => part[0]).join(""); } catch { const memoryUrl = "https://api.mymemory.translated.net/get" + `?q=${encodeURIComponent(normalised)}&langpair=${encodeURIComponent( `${effectiveSource === "auto" ? browserLanguageCode() : effectiveSource}|${target}`, )}`; const response = await fetch(memoryUrl); if (!response.ok) throw new Error("Translation is temporarily unavailable."); const data = await response.json(); translated = data.responseData?.translatedText; if (!translated) throw new Error("Translation is temporarily unavailable."); } if ( new Set([effectiveSource, target]).has("tr") && new Set([effectiveSource, target]).has("en") ) { translated = correctTurkishRestaurantContext(normalised, translated, effectiveSource, target); return preserveTurkishSafetyTerms(normalised, translated, effectiveSource, target); } return translated; } function localOrders() { return JSON.parse(localStorage.getItem("smos-orders") || "[]"); } function saveLocalOrders(orders) { localStorage.setItem("smos-orders", JSON.stringify(orders)); window.dispatchEvent(new CustomEvent("smos-orders-changed")); } async function localApi(path, options = {}) { if (path === "/api/config") { return { name: "SMOS", version: "0.1.5", languages: staticLanguages }; } if (path === "/api/translate") { const body = JSON.parse(options.body); const translatedText = await browserGoogleTranslate(body.text, body.source, body.target); return { translated_text: translatedText, source: state.lastDetectedLanguage || body.source, target: body.target, }; } if (path.startsWith("/api/image")) { const params = new URL(path, window.location.origin).searchParams; const description = params.get("description"); const language = params.get("language") || "en"; const english = language === "en" ? description : await browserGoogleTranslate(description, language, "en"); const prompt = `professional appetizing restaurant food photography, plated dish, ${english}, ` + "warm natural light, realistic, no text, no logo"; return { url: `https://image.pollinations.ai/prompt/${encodeURIComponent(prompt)}` + "?width=768&height=512&nologo=true&enhance=true", prompt: english, }; } if (path === "/api/orders" && options.method === "POST") { const body = JSON.parse(options.body); const orders = localOrders(); const inferredLanguage = inferSourceLanguage(body.order_text, body.language); const english = await browserGoogleTranslate(body.order_text, inferredLanguage, "en"); const detectedLanguage = state.lastDetectedLanguage || inferredLanguage; const now = new Date().toISOString(); const order = { id: crypto.randomUUID(), order_number: Math.max(100, ...orders.map((item) => item.order_number)) + 1, customer_name: body.customer_name || "Guest", table_number: body.table_number, original_text: body.order_text, original_language: detectedLanguage, kitchen_text: english, display_text: english, status: "new", created_at: now, updated_at: now, }; saveLocalOrders([order, ...orders]); return order; } if (path.startsWith("/api/orders?")) { const params = new URL(path, window.location.origin).searchParams; const language = params.get("language") || "en"; const includeClosed = params.get("include_closed") === "true"; const orders = localOrders().filter( (order) => includeClosed || !["served", "cancelled"].includes(order.status), ); return Promise.all( orders.map(async (order) => ({ ...order, display_text: language === "en" ? order.kitchen_text : await browserGoogleTranslate(order.kitchen_text, "en", language), })), ); } if (path.startsWith("/api/orders/") && options.method === "PATCH") { const id = path.split("/").pop(); const { status } = JSON.parse(options.body); const orders = localOrders(); const order = orders.find((item) => item.id === id); if (!order) throw new Error("Order not found."); order.status = status; order.updated_at = new Date().toISOString(); saveLocalOrders(orders); return order; } throw new Error("This action is unavailable in static demo mode."); } function fillLanguageSelect(select, preferred = "en") { select.innerHTML = ""; state.config.languages.forEach(({ code, name, locale }) => { const option = new Option(name, code, code === preferred, code === preferred); option.dataset.locale = locale; select.add(option); }); } function selectedLocale(select) { return select.selectedOptions[0]?.dataset.locale || "en-GB"; } function speak(text, locale) { if (!("speechSynthesis" in window)) { showMessage("Text-to-speech is not supported by this browser.", "error"); return; } window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); utterance.lang = locale; utterance.rate = 0.95; window.speechSynthesis.speak(utterance); } function showMessage(message, type = "success") { elements.formMessage.textContent = message; elements.formMessage.className = `form-message ${type}`; } function switchView(view) { elements.modeButtons.forEach((button) => { button.classList.toggle("is-active", button.dataset.view === view); }); elements.guestView.classList.toggle("is-active", view === "guest"); elements.kitchenView.classList.toggle("is-active", view === "kitchen"); if (view === "kitchen") loadOrders(); const url = new URL(window.location.href); url.hash = view === "kitchen" ? "kitchen" : "order"; window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`); } const THEME_KEY = "smos-theme"; function setTheme(theme) { const next = theme === "light" ? "light" : "dark"; document.documentElement.setAttribute("data-theme", next); try { localStorage.setItem(THEME_KEY, next); } catch { /* Private browsing blocks persistence; the theme still applies for this session. */ } elements.themeMeta?.setAttribute("content", next === "light" ? "#f6f3ee" : "#070707"); const goLight = next === "dark"; elements.themeToggle?.setAttribute( "aria-label", goLight ? "Switch to light theme" : "Switch to dark theme", ); elements.themeToggle?.setAttribute("title", goLight ? "Light theme" : "Dark theme"); } function setupTheme() { setTheme(document.documentElement.getAttribute("data-theme")); elements.themeToggle?.addEventListener("click", () => { const current = document.documentElement.getAttribute("data-theme"); setTheme(current === "dark" ? "light" : "dark"); }); } function setupSpeechRecognition() { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { elements.voiceButton.disabled = true; elements.voiceLabel.textContent = "Voice unavailable"; elements.listeningStatus.textContent = "Try Chrome, Edge, or Safari"; return; } state.recognition = new SpeechRecognition(); state.recognition.continuous = true; state.recognition.interimResults = true; state.recognition.onstart = () => { state.listening = true; elements.voiceButton.classList.add("listening"); elements.voiceLabel.textContent = "Stop listening"; elements.listeningStatus.textContent = "Listening…"; }; state.recognition.onresult = (event) => { let finalText = ""; let interimText = ""; for (let index = event.resultIndex; index < event.results.length; index += 1) { const transcript = event.results[index][0].transcript; if (event.results[index].isFinal) finalText += transcript; else interimText += transcript; } if (finalText) { const spacer = elements.orderText.value.trim() ? " " : ""; elements.orderText.value = `${elements.orderText.value.trim()}${spacer}${finalText.trim()}`; elements.orderText.dispatchEvent(new Event("input")); } elements.listeningStatus.textContent = interimText || (state.listening ? "Listening…" : ""); }; state.recognition.onerror = (event) => { elements.listeningStatus.textContent = event.error === "not-allowed" ? "Microphone permission was denied" : `Voice error: ${event.error}`; }; state.recognition.onend = () => { state.listening = false; elements.voiceButton.classList.remove("listening"); elements.voiceLabel.textContent = "Tap to speak"; if (elements.listeningStatus.textContent === "Listening…") { elements.listeningStatus.textContent = ""; } }; } function toggleListening() { if (!state.recognition) return; if (state.listening) { state.recognition.stop(); return; } state.recognition.lang = navigator.language || "en-GB"; try { state.recognition.start(); } catch { state.recognition.stop(); } } async function previewTranslation() { const text = elements.orderText.value.trim(); const source = "auto"; if (text.length < 2) { elements.translationCard.classList.add("hidden"); return ""; } if (source === "en") { elements.translatedPreview.textContent = text; elements.translationCard.classList.remove("hidden"); return text; } try { const result = await api("/api/translate", { method: "POST", body: JSON.stringify({ text, source, target: "en" }), }); elements.translatedPreview.textContent = result.translated_text; elements.translationCard.classList.remove("hidden"); return result.translated_text; } catch (error) { showMessage(error.message, "error"); return ""; } } async function visualiseDish() { const description = elements.orderText.value.trim(); if (description.length < 4) return; elements.imageLoading.classList.remove("hidden"); elements.previewButton.disabled = true; try { const result = await api( `/api/image?description=${encodeURIComponent(description)}&language=auto`, ); elements.dishImage.onload = () => { elements.imageLoading.classList.add("hidden"); elements.imagePlaceholder.classList.add("hidden"); elements.dishImage.classList.add("visible"); elements.previewButton.disabled = false; }; elements.dishImage.onerror = () => { elements.imageLoading.classList.add("hidden"); elements.previewButton.disabled = false; showMessage("The food visual could not be generated. Your order can still be sent.", "error"); }; elements.dishImage.src = `${result.url}&seed=${Date.now()}`; } catch (error) { elements.imageLoading.classList.add("hidden"); elements.previewButton.disabled = false; showMessage(error.message, "error"); } } async function submitOrder(event) { event.preventDefault(); const originalLabel = elements.submitOrder.innerHTML; elements.submitOrder.disabled = true; elements.submitOrder.innerHTML = "Translating & sending…···"; try { const order = await api("/api/orders", { method: "POST", body: JSON.stringify({ customer_name: "Guest", table_number: state.tableId, order_text: elements.orderText.value.trim(), language: "auto", }), }); showMessage(`Order #${order.order_number} is with the kitchen.`, "success"); elements.orderText.value = ""; elements.translationCard.classList.add("hidden"); } catch (error) { showMessage(error.message, "error"); } finally { elements.submitOrder.disabled = false; elements.submitOrder.innerHTML = originalLabel; } } const statusLabels = { new: "New", accepted: "Accepted", preparing: "Preparing", ready: "Ready", served: "Served", cancelled: "Cancelled", }; function relativeTime(timestamp) { const minutes = Math.max(0, Math.round((Date.now() - new Date(timestamp).getTime()) / 60000)); if (minutes < 1) return "just now"; if (minutes === 1) return "1 min ago"; if (minutes < 60) return `${minutes} mins ago`; return new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } function renderOrders() { elements.ordersGrid.innerHTML = ""; const counts = {}; state.orders.forEach((order) => { counts[order.status] = (counts[order.status] || 0) + 1; const card = elements.orderTemplate.content.firstElementChild.cloneNode(true); card.dataset.status = order.status; $(".order-number", card).textContent = `Order #${order.order_number}`; $("time", card).textContent = relativeTime(order.created_at); $("time", card).dateTime = order.created_at; $(".status-pill", card).textContent = statusLabels[order.status]; $(".status-pill", card).dataset.status = order.status; $(".table-value", card).textContent = `Table ${order.table_number}`; $(".order-copy", card).textContent = order.display_text || order.kitchen_text; $(".original-copy", card).textContent = order.original_text; const statusSelect = $(".status-select", card); statusSelect.value = order.status; statusSelect.addEventListener("change", () => updateStatus(order.id, statusSelect.value)); $(".speak-order", card).addEventListener("click", () => { speak(order.display_text || order.kitchen_text, selectedLocale(elements.kitchenLanguage)); }); elements.ordersGrid.append(card); }); const newOrders = counts.new || 0; elements.newCount.textContent = newOrders; elements.newCount.classList.toggle("hidden", newOrders === 0); elements.statusSummary.innerHTML = Object.entries(statusLabels) .filter(([key]) => counts[key]) .map(([key, label]) => `${counts[key]}${label}`) .join(""); elements.emptyOrders.classList.toggle("hidden", state.orders.length !== 0); } async function loadOrders() { try { state.orders = await api( `/api/orders?language=${encodeURIComponent(elements.kitchenLanguage.value)}&include_closed=${ elements.includeClosed.checked }`, ); renderOrders(); } catch (error) { elements.ordersGrid.innerHTML = `
`; } } async function updateStatus(orderId, status) { try { await api(`/api/orders/${orderId}`, { method: "PATCH", body: JSON.stringify({ status }), }); await loadOrders(); } catch (error) { alert(error.message); await loadOrders(); } } function connectSocket() { if (state.localMode) { elements.connection.innerHTML = " Live demo"; window.addEventListener("storage", loadOrders); window.addEventListener("smos-orders-changed", loadOrders); return; } const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; state.socket = new WebSocket(`${protocol}//${window.location.host}/ws`); state.socket.onopen = () => { elements.connection.classList.remove("offline"); elements.connection.innerHTML = " Live"; }; state.socket.onmessage = (event) => { const message = JSON.parse(event.data); if (message.type?.startsWith("order.")) loadOrders(); }; state.socket.onclose = () => { elements.connection.classList.add("offline"); elements.connection.innerHTML = " Reconnecting"; window.setTimeout(connectSocket, 2500); }; } async function initialise() { state.config = await api("/api/config"); elements.version.textContent = `v${state.config.version}`; state.tableId = resolveTableId(); elements.detectedTable.textContent = state.tableId; fillLanguageSelect(elements.kitchenLanguage, "en"); setupTheme(); setupSpeechRecognition(); connectSocket(); await loadOrders(); elements.modeButtons.forEach((button) => button.addEventListener("click", () => switchView(button.dataset.view)), ); elements.voiceButton.addEventListener("click", toggleListening); elements.previewButton.addEventListener("click", visualiseDish); elements.orderForm.addEventListener("submit", submitOrder); elements.speakTranslation.addEventListener("click", () => speak(elements.translatedPreview.textContent, "en-GB"), ); elements.kitchenLanguage.addEventListener("change", loadOrders); elements.includeClosed.addEventListener("change", loadOrders); elements.orderText.addEventListener("input", () => { clearTimeout(state.translationTimer); clearTimeout(state.imageTimer); state.translationTimer = setTimeout(previewTranslation, 700); if (elements.orderText.value.trim().length >= 12) { state.imageTimer = setTimeout(visualiseDish, 1800); } }); if (window.location.hash === "#kitchen") switchView("kitchen"); } initialise().catch((error) => { document.body.innerHTML = `Startup error
` + `${error.message}