SMOS / static /app.js
0001AMA's picture
Deploy automatic table and language context v0.1.5
7d8e7bd verified
Raw
History Blame Contribute Delete
26.5 kB
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 = "<span>Translating &amp; sending…</span><b>···</b>";
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]) => `<span class="summary-chip"><b>${counts[key]}</b>${label}</span>`)
.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 = `<p class="form-message error">${error.message}</p>`;
}
}
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 = "<i></i> 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 = "<i></i> 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 = "<i></i> 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 =
`<main class="section"><div class="container"><p class="eyebrow">Startup error</p>` +
`<h1>SMOS could not start</h1><p class="section-lede">${error.message}</p></div></main>`;
});