diff --git a/frontend/modules/settings_write.html b/frontend/modules/settings_write.html index 02798f855..ff95aabcd 100644 --- a/frontend/modules/settings_write.html +++ b/frontend/modules/settings_write.html @@ -3412,6 +3412,13 @@
Let the software write and read for +
+
+
+ Talks to order +
+ +
+ + +
+
+
Both work as soon as online ordering is on. The menu address never shows a cart, so it stays useful when you stop taking orders. Print whichever one you want on the table or in the window. + The third lands the customer straight in the assistant's conversation, ready to talk where you have switched live voice on (AI page); the menu is right behind it. Add ?ai=talk to any table or room address for the same effect, or ?ai=ask to open the typed assistant. diff --git a/frontend/static/script/js/modules/js/settings.js b/frontend/static/script/js/modules/js/settings.js index f29521d21..f69896427 100644 --- a/frontend/static/script/js/modules/js/settings.js +++ b/frontend/static/script/js/modules/js/settings.js @@ -8629,6 +8629,9 @@ PosnicPro.settings.storefrontLinks = function () { if (!base) { base = String(window.location.origin || '').replace(/\/+$/, ''); } $('#storefront_order_url').val(base + '/order/' + id); $('#storefront_menu_url').val(base + '/menu/' + id); + /* Lands in the conversation; harmless on a shop with the assistant off, + where it is the ordering page. */ + $('#storefront_talk_url').val(base + '/order/' + id + '?ai=talk'); row.show(); }; @@ -9284,6 +9287,7 @@ PosnicPro.settings.ai = { if (!PosnicPro.settings.ai._switchWired) { PosnicPro.settings.ai._switchWired = true; $(document).on('change', '#ai_ordering_assistant', function () { PosnicPro.settings.ai.syncRows(); }); + $(document).on('change', '#ai_live_voice', function () { PosnicPro.settings.ai.syncRows(); }); } var on = !!$('#ai_provider').val(); var where = PosnicPro.settings.ai.KEY_PAGES[$('#ai_provider').val() || '']; @@ -9311,6 +9315,18 @@ PosnicPro.settings.ai = { door is open: a shop that has not switched it on is not asked to write for it. */ $('#ai_assistant_config').toggle(on && $('#ai_ordering_assistant').is(':checked')); + /* Say in one word what the microphone will do, so nobody has to + guess from a page of hints why it only transcribed. */ + var liveOn = $('#ai_live_voice').is(':checked'); + var openai = $('#ai_provider').val() === 'openai'; + $('#ai_live_voice_state') + .toggleClass('badge-success', liveOn && openai) + .toggleClass('badge-secondary', !(liveOn && openai)) + .text(liveOn && openai + ? PosnicPro.i18n.t('lang_ai_live_voice_on', 'Live: the microphone opens a voice call with the assistant') + : liveOn + ? PosnicPro.i18n.t('lang_ai_live_voice_needs_openai', 'Live voice needs an OpenAI key; with this provider the microphone works turn by turn') + : PosnicPro.i18n.t('lang_ai_live_voice_off', 'Off: the microphone works turn by turn, with the phone\'s own voice')); /* A saved key and no key must not look the same. The key never comes back to the browser, so "saved" is a badge and two buttons, and the empty box only appears when somebody asks to replace it. */ diff --git a/languages/_english.json b/languages/_english.json index 363cd280f..daeabe79f 100644 --- a/languages/_english.json +++ b/languages/_english.json @@ -161,8 +161,11 @@ "lang_ai_key_removed": "Key removed. The AI buttons are hidden until a new one is saved.", "lang_ai_key_replace": "Replace", "lang_ai_key_saved": "A key is saved. Type a new one to replace it.", - "lang_ai_live_voice": "Let customers talk to it, live", + "lang_ai_live_voice": "Let customers talk to it, live: it listens and talks back", "lang_ai_live_voice_hint": "A microphone appears beside the assistant. The customer speaks and hears it answer, can interrupt it, and it adds to the order as they talk, like a phone call with a waiter. The sound goes from the phone straight to your provider; our servers never hear it. Billed by your provider per minute of audio, which costs several times a typed question; it counts against the monthly limit above. Live voice needs an OpenAI key today. With another provider the microphone still works turn by turn, using the phone's own voice, at the price of a typed question.", + "lang_ai_live_voice_needs_openai": "Live voice needs an OpenAI key; with this provider the microphone works turn by turn", + "lang_ai_live_voice_off": "Off: the microphone works turn by turn, with the phone\\", + "lang_ai_live_voice_on": "Live: the microphone opens a voice call with the assistant", "lang_ai_module_desc": "Writes item descriptions for you. You bring your own AI account and pay that provider directly; we charge nothing. Set it up on the AI assistance page.", "lang_ai_no_limit": "none", "lang_ai_not_subscription": "A Claude Pro or ChatGPT Plus subscription does not work here. Those pay for the chat website; an API key is billed separately and, for this kind of use, usually costs far less.", @@ -2736,6 +2739,8 @@ "lang_storefront_home_banner": "Home banner", "lang_storefront_link_menu": "Menu only", "lang_storefront_link_order": "Takes orders", + "lang_storefront_link_talk": "Talks to order", + "lang_storefront_link_talk_help": "The third lands the customer straight in the assistant's conversation, ready to talk where you have switched live voice on (AI page); the menu is right behind it. Add ?ai=talk to any table or room address for the same effect, or ?ai=ask to open the typed assistant.", "lang_storefront_links": "Your two addresses", "lang_storefront_links_help": "Both work as soon as online ordering is on. The menu address never shows a cart, so it stays useful when you stop taking orders. Print whichever one you want on the table or in the window.", "lang_storefront_logo": "Logo", diff --git a/languages/ta.json b/languages/ta.json index 1a2a31058..369b0a08d 100644 --- a/languages/ta.json +++ b/languages/ta.json @@ -2968,6 +2968,11 @@ "lang_ai_assistant_how_3": "வாடிக்கையாளர் ஏதாவது சேர்க்க அல்லது நீக்கக் கேட்கும்போது, \"சேர்\" தட்டுவது போலவே பக்கம் அதைச் செய்து, உரையாடலில் அதைச் சொல்லும். உதவியாளர் ஒருபோதும் ஆர்டர் செய்யாது, பணம் பெறாது, தொலைபேசி எண்ணையோ முகவரியையோ பார்க்காது.", "lang_ai_assistant_how_4": "ஒவ்வொரு கேள்வியும் உங்கள் வழங்குநருக்கு ஒரு அழைப்பு; உங்கள் கணக்கில் கட்டணமாகி, மேலே உள்ள மாதாந்திர வரம்பில் கணக்கிடப்படும்; ஒவ்வொரு போனுக்கும் நிமிடத்திற்கு பன்னிரண்டு கேள்விகள் வரை.", "lang_ai_assistant_how_5": "முயற்சிக்க: சேமித்து, உங்கள் ஆர்டர் பக்கத்தை போனில் திறந்து, தலைப்பில் உள்ள மின்னல் குறியைத் தட்டுங்கள். இங்கே அணைத்தால் ஒரு நிமிடத்திற்குள் மின்னல் குறி மறையும்.", - "lang_ai_live_voice": "வாடிக்கையாளர்கள் அதனுடன் நேரடியாகப் பேச அனுமதிக்கவும்", - "lang_ai_live_voice_hint": "உதவியாளருக்கு அருகில் ஒரு மைக்ரோஃபோன் தோன்றும். வாடிக்கையாளர் பேசுவார், பதிலைக் கேட்பார், இடையில் குறுக்கிடலாம்; பேசும்போதே அது ஆர்டரில் சேர்க்கும், ஒரு வெயிட்டருடன் தொலைபேசியில் பேசுவது போல. ஒலி போனிலிருந்து நேரடியாக உங்கள் வழங்குநருக்குச் செல்கிறது; எங்கள் சர்வர்கள் அதைக் கேட்பதில்லை. உங்கள் வழங்குநர் ஒலி நிமிடத்திற்குக் கட்டணம் விதிப்பார்; தட்டச்சு கேள்வியை விடப் பல மடங்கு அதிகம்; மேலே உள்ள மாதாந்திர வரம்பில் கணக்கிடப்படும். நேரடிக் குரலுக்கு இன்று OpenAI கீ தேவை. வேறு வழங்குநருடன் மைக்ரோஃபோன் முறை முறையாக, போனின் சொந்தக் குரலில், தட்டச்சு கேள்வியின் விலையில் வேலை செய்யும்." + "lang_ai_live_voice": "வாடிக்கையாளர்கள் அதனுடன் நேரடியாகப் பேச அனுமதிக்கவும்: அது கேட்டுப் பதில் பேசும்", + "lang_ai_live_voice_hint": "உதவியாளருக்கு அருகில் ஒரு மைக்ரோஃபோன் தோன்றும். வாடிக்கையாளர் பேசுவார், பதிலைக் கேட்பார், இடையில் குறுக்கிடலாம்; பேசும்போதே அது ஆர்டரில் சேர்க்கும், ஒரு வெயிட்டருடன் தொலைபேசியில் பேசுவது போல. ஒலி போனிலிருந்து நேரடியாக உங்கள் வழங்குநருக்குச் செல்கிறது; எங்கள் சர்வர்கள் அதைக் கேட்பதில்லை. உங்கள் வழங்குநர் ஒலி நிமிடத்திற்குக் கட்டணம் விதிப்பார்; தட்டச்சு கேள்வியை விடப் பல மடங்கு அதிகம்; மேலே உள்ள மாதாந்திர வரம்பில் கணக்கிடப்படும். நேரடிக் குரலுக்கு இன்று OpenAI கீ தேவை. வேறு வழங்குநருடன் மைக்ரோஃபோன் முறை முறையாக, போனின் சொந்தக் குரலில், தட்டச்சு கேள்வியின் விலையில் வேலை செய்யும்.", + "lang_ai_live_voice_on": "நேரடி: மைக்ரோஃபோன் உதவியாளருடன் ஒரு குரல் அழைப்பைத் திறக்கும்", + "lang_ai_live_voice_off": "அணைப்பு: மைக்ரோஃபோன் முறை முறையாக, போனின் சொந்தக் குரலில் வேலை செய்யும்", + "lang_ai_live_voice_needs_openai": "நேரடிக் குரலுக்கு OpenAI கீ தேவை; இந்த வழங்குநருடன் மைக்ரோஃபோன் முறை முறையாக வேலை செய்யும்", + "lang_storefront_link_talk": "பேசி ஆர்டர்", + "lang_storefront_link_talk_help": "மூன்றாவது முகவரி வாடிக்கையாளரை நேரடியாக உதவியாளரின் உரையாடலில் இறக்கும்; நீங்கள் நேரடிக் குரலை (AI பக்கம்) இயக்கியிருந்தால் பேசத் தயாராக இருக்கும்; மெனு அதற்குப் பின்னால் இருக்கும். அதே விளைவுக்கு எந்த மேசை அல்லது அறை முகவரியிலும் ?ai=talk சேர்க்கவும், அல்லது தட்டச்சு உதவியாளரைத் திறக்க ?ai=ask." } diff --git a/menu/i18n.js b/menu/i18n.js index 9c7662362..8af49ba12 100644 --- a/menu/i18n.js +++ b/menu/i18n.js @@ -264,6 +264,11 @@ /* the assistant */ "Ask about the menu": "மெனு பற்றிக் கேளுங்கள்", + "New": "புதிது", + "Ask me what's good, or just talk": "எது நன்றாக இருக்கும் என்று கேளுங்கள், அல்லது பேசுங்கள்", + "Ask me what's good": "எது நன்றாக இருக்கும் என்று கேளுங்கள்", + "Dismiss": "மூடு", + "Or tap the microphone and just talk.": "அல்லது மைக்ரோஃபோனைத் தட்டிப் பேசுங்கள்.", "What do you recommend?": "நீங்கள் எதைப் பரிந்துரைக்கிறீர்கள்?", "Something vegetarian and spicy": "சைவமும் காரமுமான ஏதாவது", "A meal for two": "இரண்டு பேருக்கு ஒரு உணவு", @@ -274,6 +279,7 @@ "Thinking": "யோசிக்கிறது", "Talk to order": "பேசி ஆர்டர் செய்யுங்கள்", "Stop talking": "பேச்சை நிறுத்து", + "Tap to talk": "பேசத் தட்டுங்கள்", "Connecting...": "இணைக்கிறது...", "Listening...": "கேட்கிறது...", "Speaking...": "பேசுகிறது...", @@ -281,6 +287,8 @@ "Go ahead, I'm listening. Say what you feel like, and I'll answer out loud and add to your order.": "சொல்லுங்கள், கேட்கிறேன். உங்களுக்கு என்ன வேண்டும் என்று சொல்லுங்கள்; சத்தமாகப் பதிலளித்து உங்கள் ஆர்டரில் சேர்க்கிறேன்.", "The microphone was not allowed. You can still type.": "மைக்ரோஃபோன் அனுமதிக்கப்படவில்லை. நீங்கள் தட்டச்சு செய்யலாம்.", "Could not connect the voice line. You can still type.": "குரல் இணைப்பை ஏற்படுத்த முடியவில்லை. நீங்கள் தட்டச்சு செய்யலாம்.", + "Live voice is switched off for this shop, so I'll answer turn by turn.": "இந்தக் கடையில் நேரடிக் குரல் அணைக்கப்பட்டுள்ளது, எனவே முறை முறையாகப் பதிலளிக்கிறேன்.", + "The live voice line did not open ({why}), so I'll answer turn by turn.": "நேரடிக் குரல் இணைப்பு திறக்கவில்லை ({why}), எனவே முறை முறையாகப் பதிலளிக்கிறேன்.", "Answers come from this shop's menu. Please confirm allergies at the counter.": "பதில்கள் இந்தக் கடையின் மெனுவிலிருந்து வருகின்றன. ஒவ்வாமை பற்றி கவுண்டரில் உறுதிப்படுத்துங்கள்.", "Hi! Tell me what you feel like, or ask what's good here. I'll suggest from the menu and can add it to your order.": "வணக்கம்! உங்களுக்கு என்ன வேண்டும் என்று சொல்லுங்கள், அல்லது இங்கே எது நன்றாக இருக்கும் என்று கேளுங்கள். மெனுவிலிருந்து பரிந்துரைத்து உங்கள் ஆர்டரில் சேர்க்கிறேன்.", "Added {n} × {name}": "{name} {n} சேர்க்கப்பட்டது", diff --git a/order/assets/assistant/script.js b/order/assets/assistant/script.js index b6ac0cc87..91fa6c1b4 100644 --- a/order/assets/assistant/script.js +++ b/order/assets/assistant/script.js @@ -22,7 +22,7 @@ "use strict"; var MAX_TURNS_SENT = 12; - var state = { messages: [], busy: false, greeted: false }; + var state = { messages: [], busy: false, greeted: false, landed: false }; function el(id) { return document.getElementById(id); @@ -65,6 +65,100 @@ var on = !!(current && current.assistant); spark.hidden = !on; document.body.classList.toggle("has-assistant", on); + if (on) offerHint(current); + else hideHint(false); + if (on) landInConversation(current); + } + + /* ------------------------------------------------ a code for the talk */ + + var AI_FIRST_KEY = "posnic_ai_first"; + + /* What the link asked for: "talk", "ask", or nothing. From the query on + this page, or from what the arrival page kept across its redirect. */ + function aiFirstWish() { + var wish = ""; + try { + wish = String(new URLSearchParams(window.location.search).get("ai") || "").toLowerCase(); + } catch (e) { + wish = ""; + } + if (wish === "1") wish = "ask"; + if (wish !== "talk" && wish !== "ask") { + try { + wish = String(sessionStorage.getItem(AI_FIRST_KEY) || ""); + } catch (e) { + wish = ""; + } + } + return wish === "talk" || wish === "ask" ? wish : ""; + } + + /* Once, the moment the shop is known: open the sheet; where the shop lets + people talk and the code asked for it, stand ready with "Tap to talk". */ + function landInConversation(current) { + if (state.landed) return; + var wish = aiFirstWish(); + if (!wish) return; + state.landed = true; + try { + sessionStorage.removeItem(AI_FIRST_KEY); + } catch (e) { + /* nothing kept */ + } + hideHint(true); + open(); + if (wish === "talk" && current && current.voice && window.OrderingVoice && window.OrderingVoice.standReady) { + window.OrderingVoice.standReady(); + } + } + + /* ------------------------------------------------------- the callout */ + + var HINT_KEY = "posnic_assistant_seen"; + var hintTimer = 0; + + function hintSeen() { + try { + return localStorage.getItem(HINT_KEY) === "1"; + } catch (e) { + return false; + } + } + + function markHintSeen() { + try { + localStorage.setItem(HINT_KEY, "1"); + } catch (e) { + /* a browser that keeps nothing sees it again next time; fine */ + } + } + + /* Once per phone, for a few seconds: "Ask me what's good, or just talk." */ + function offerHint(current) { + var hint = el("assistant-hint"); + if (!hint) return; + if (hintSeen() || state.greeted) { + /* Seen already, here or in another tab: nothing to offer, and one + that is somehow up comes down. */ + hideHint(false); + return; + } + var text = el("assistant-hint-text"); + if (text) text.textContent = current && current.voice ? say("Ask me what's good, or just talk") : say("Ask me what's good"); + if (!hint.hidden) return; + hint.hidden = false; + clearTimeout(hintTimer); + hintTimer = setTimeout(function () { + hideHint(false); + }, 9000); + } + + function hideHint(forGood) { + var hint = el("assistant-hint"); + if (hint) hint.hidden = true; + clearTimeout(hintTimer); + if (forGood) markHintSeen(); } /* --------------------------------------------------------- the log */ @@ -126,10 +220,14 @@ function greet() { if (state.greeted) return; state.greeted = true; - /* The shop's own opening line when it wrote one, else the plain one. */ + hideHint(true); + /* The shop's own opening line when it wrote one, else the plain one; + and where the shop lets people talk, the microphone gets a mention. */ var current = shopNow(); var own = current && current.assistantGreeting ? String(current.assistantGreeting).trim() : ""; - bubble("ai", own || say("Hi! Tell me what you feel like, or ask what's good here. I'll suggest from the menu and can add it to your order.")); + var line = own || say("Hi! Tell me what you feel like, or ask what's good here. I'll suggest from the menu and can add it to your order."); + if (current && current.voice) line += " " + say("Or tap the microphone and just talk."); + bubble("ai", line); } /* ------------------------------------------------- applying an answer */ @@ -280,6 +378,10 @@ var spark = el("ask-ai"); if (!spark) return; spark.addEventListener("click", open); + var hintOpen = el("assistant-hint-open"); + if (hintOpen) hintOpen.addEventListener("click", open); + var hintClose = el("assistant-hint-close"); + if (hintClose) hintClose.addEventListener("click", function () { hideHint(true); }); var closeButton = el("assistant-close"); if (closeButton) closeButton.addEventListener("click", close); var form = el("assistant-form"); diff --git a/order/assets/assistant/voice.js b/order/assets/assistant/voice.js index a70fdeed7..e317fa19b 100644 --- a/order/assets/assistant/voice.js +++ b/order/assets/assistant/voice.js @@ -95,11 +95,28 @@ /* --------------------------------------------------------- the tools */ + /* + * A dish by id, from the page's own catalogue. allProducts() is the + * page-wide list (indexedDB.js, global); findProduct() lives inside the + * products script's closure and is NOT visible here, which is how every + * add once came back "not on this menu" on the real page. + */ function findItem(id) { + var wanted = String(id); try { - if (typeof findProduct === "function") return findProduct(id) || null; // eslint-disable-line no-undef + if (typeof allProducts === "function") { // eslint-disable-line no-undef + var all = allProducts() || []; // eslint-disable-line no-undef + for (var i = 0; i < all.length; i++) { + if (all[i] && String(all[i].id) === wanted) return all[i]; + } + } + } catch (e) { + /* no catalogue on this page */ + } + try { + if (typeof findProduct === "function") return findProduct(wanted) || null; // eslint-disable-line no-undef } catch (e) { - /* no catalogue helper on this page */ + /* not on this page either */ } return null; } @@ -241,12 +258,18 @@ } catch (e) { body = null; } - if (response.status === 403) { - /* The shop switched it off since the page loaded; the turn-by-turn way still works. */ + if (!response.ok || !body || body.type !== "success" || !body.data || !body.data.sdp) { + /* No live line from this shop right now: say why in one line, then + talk turn by turn rather than leave the customer with silence. The + owner tested with his key and got only transcripts, and nothing + told him the live switch was off. */ stopLine(); + var why = body && body.message ? String(body.message) : ""; + note(response.status === 403 + ? say("Live voice is switched off for this shop, so I'll answer turn by turn.") + : say("The live voice line did not open ({why}), so I'll answer turn by turn.", { why: why || response.status })); return startTurns(); } - if (!response.ok || !body || body.type !== "success" || !body.data || !body.data.sdp) throw new Error("no line"); await pc.setRemoteDescription({ type: "answer", sdp: body.data.sdp }); return true; } catch (e) { @@ -392,7 +415,14 @@ } function stop() { - if (!live.active && !live.pc) return; + var go = el("voice-start"); + if (go) go.hidden = true; + var stopButton = el("voice-stop"); + if (stopButton) stopButton.hidden = false; + if (!live.active && !live.pc) { + status("", ""); + return; + } live.active = false; stopLine(); try { @@ -405,12 +435,75 @@ status("", ""); } + /* + * An iPhone lets a page speak only once speech has been started inside a + * tap. A silent utterance in the tap handler unlocks it for the answers + * that come later, after the network. Harmless everywhere else. + */ + function unlockSpeech() { + try { + var synth = window.speechSynthesis; + if (!synth || !window.SpeechSynthesisUtterance) return; + var u = new SpeechSynthesisUtterance(" "); + u.volume = 0; + synth.speak(u); + if (synth.getVoices && !synth.getVoices().length && synth.addEventListener) { + synth.addEventListener("voiceschanged", function () {}, { once: true }); + } + } catch (e) { + /* no speech on this browser */ + } + } + + /* The speaker element, touched inside the tap so iOS lets the line's audio + play later. play() may return nothing where media is not implemented. */ + function warmSpeaker() { + var out = el("voice-out"); + if (!out || typeof out.play !== "function") return; + try { + var p = out.play(); + if (p && typeof p.catch === "function") p.catch(function () {}); + } catch (e) { + /* nothing to play yet */ + } + } + + /* + * A code printed for the talk lands here: the panel is up, the orb is + * still, and one big button says "Tap to talk". One tap, because no + * browser opens a microphone without a finger on the screen. + */ + function standReady() { + if (!voiceMode()) return; + status("ready", say("Tap to talk")); + var go = el("voice-start"); + if (go) go.hidden = false; + var stopButton = el("voice-stop"); + if (stopButton) stopButton.hidden = true; + } + function wire() { var button = el("assistant-talk"); if (!button) return; + var go = el("voice-start"); + if (go) { + go.addEventListener("click", function () { + go.hidden = true; + var stopButton = el("voice-stop"); + if (stopButton) stopButton.hidden = false; + unlockSpeech(); + warmSpeaker(); + start(); + }); + } button.addEventListener("click", function () { - if (live.active) stop(); - else start(); + if (live.active) { + stop(); + return; + } + unlockSpeech(); + warmSpeaker(); + start(); }); var stopButton = el("voice-stop"); if (stopButton) stopButton.addEventListener("click", stop); @@ -426,5 +519,5 @@ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", wire); else wire(); - window.OrderingVoice = { start: start, stop: stop, runTool: runTool, onEvent: onEvent, voiceMode: voiceMode, paintTalk: paintTalk, live: live }; + window.OrderingVoice = { start: start, stop: stop, standReady: standReady, runTool: runTool, onEvent: onEvent, voiceMode: voiceMode, paintTalk: paintTalk, live: live }; })(); diff --git a/order/assets/i18n.js b/order/assets/i18n.js index 9c7662362..8af49ba12 100644 --- a/order/assets/i18n.js +++ b/order/assets/i18n.js @@ -264,6 +264,11 @@ /* the assistant */ "Ask about the menu": "மெனு பற்றிக் கேளுங்கள்", + "New": "புதிது", + "Ask me what's good, or just talk": "எது நன்றாக இருக்கும் என்று கேளுங்கள், அல்லது பேசுங்கள்", + "Ask me what's good": "எது நன்றாக இருக்கும் என்று கேளுங்கள்", + "Dismiss": "மூடு", + "Or tap the microphone and just talk.": "அல்லது மைக்ரோஃபோனைத் தட்டிப் பேசுங்கள்.", "What do you recommend?": "நீங்கள் எதைப் பரிந்துரைக்கிறீர்கள்?", "Something vegetarian and spicy": "சைவமும் காரமுமான ஏதாவது", "A meal for two": "இரண்டு பேருக்கு ஒரு உணவு", @@ -274,6 +279,7 @@ "Thinking": "யோசிக்கிறது", "Talk to order": "பேசி ஆர்டர் செய்யுங்கள்", "Stop talking": "பேச்சை நிறுத்து", + "Tap to talk": "பேசத் தட்டுங்கள்", "Connecting...": "இணைக்கிறது...", "Listening...": "கேட்கிறது...", "Speaking...": "பேசுகிறது...", @@ -281,6 +287,8 @@ "Go ahead, I'm listening. Say what you feel like, and I'll answer out loud and add to your order.": "சொல்லுங்கள், கேட்கிறேன். உங்களுக்கு என்ன வேண்டும் என்று சொல்லுங்கள்; சத்தமாகப் பதிலளித்து உங்கள் ஆர்டரில் சேர்க்கிறேன்.", "The microphone was not allowed. You can still type.": "மைக்ரோஃபோன் அனுமதிக்கப்படவில்லை. நீங்கள் தட்டச்சு செய்யலாம்.", "Could not connect the voice line. You can still type.": "குரல் இணைப்பை ஏற்படுத்த முடியவில்லை. நீங்கள் தட்டச்சு செய்யலாம்.", + "Live voice is switched off for this shop, so I'll answer turn by turn.": "இந்தக் கடையில் நேரடிக் குரல் அணைக்கப்பட்டுள்ளது, எனவே முறை முறையாகப் பதிலளிக்கிறேன்.", + "The live voice line did not open ({why}), so I'll answer turn by turn.": "நேரடிக் குரல் இணைப்பு திறக்கவில்லை ({why}), எனவே முறை முறையாகப் பதிலளிக்கிறேன்.", "Answers come from this shop's menu. Please confirm allergies at the counter.": "பதில்கள் இந்தக் கடையின் மெனுவிலிருந்து வருகின்றன. ஒவ்வாமை பற்றி கவுண்டரில் உறுதிப்படுத்துங்கள்.", "Hi! Tell me what you feel like, or ask what's good here. I'll suggest from the menu and can add it to your order.": "வணக்கம்! உங்களுக்கு என்ன வேண்டும் என்று சொல்லுங்கள், அல்லது இங்கே எது நன்றாக இருக்கும் என்று கேளுங்கள். மெனுவிலிருந்து பரிந்துரைத்து உங்கள் ஆர்டரில் சேர்க்கிறேன்.", "Added {n} × {name}": "{name} {n} சேர்க்கப்பட்டது", diff --git a/order/assets/index/script.js b/order/assets/index/script.js index cd5135b52..46280c605 100644 --- a/order/assets/index/script.js +++ b/order/assets/index/script.js @@ -44,6 +44,15 @@ async function checkBranchFromURL() { const note = urlParams.get("notes"); if (note) localStorage.setItem('note', note); else localStorage.removeItem('note'); + /* ?ai=talk or ?ai=ask: a code printed for the conversation. Kept for + the products page, because the redirect below drops the query. */ + const aiFirst = String(urlParams.get("ai") || "").toLowerCase(); + try { + if (aiFirst === "talk" || aiFirst === "ask" || aiFirst === "1") sessionStorage.setItem("posnic_ai_first", aiFirst === "1" ? "ask" : aiFirst); + else sessionStorage.removeItem("posnic_ai_first"); + } catch (e) { + /* a browser that keeps nothing lands on the menu, which still works */ + } if (branchId) { console.log("🔗 Branch from QR URL:", branchId); diff --git a/order/assets/order.css b/order/assets/order.css index 22976f76e..d0b0209ba 100644 --- a/order/assets/order.css +++ b/order/assets/order.css @@ -2137,6 +2137,102 @@ html[lang="ta"] .attract-note { /* ------------------------------------------------------ the assistant */ +/* + * The first-time callout: a small card under the spark, ink on paper, with + * a pointer. It rises once, waits a few seconds, and leaves; a tap opens + * the sheet, a cross dismisses it for good on this phone. + */ +.top-row { + position: relative; +} + +.assistant-hint { + position: absolute; + top: calc(100% - 4px); + right: 88px; + z-index: 12; + display: flex; + align-items: stretch; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface); + box-shadow: var(--shadow-2); +} + +.assistant-hint::before { + content: ""; + position: absolute; + top: -7px; + right: 18px; + width: 12px; + height: 12px; + border-left: 1px solid var(--line); + border-top: 1px solid var(--line); + background: var(--surface); + transform: rotate(45deg); +} + +.assistant-hint-open { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 6px 10px 12px; + border: 0; + background: transparent; + color: var(--ink); + font: inherit; + font-size: 14px; + text-align: left; +} + +.assistant-hint-title { + padding: 2px 8px; + border-radius: 999px; + background: var(--accent); + color: var(--on-accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.assistant-hint-close { + width: 36px; + border: 0; + border-left: 1px solid var(--line); + border-radius: 0 12px 12px 0; + background: transparent; + color: var(--ink-soft); + font-size: 20px; + line-height: 1; +} + +@media (prefers-reduced-motion: no-preference) { + .assistant-hint:not([hidden]) { + animation: hint-in 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) both; + } +} + +@keyframes hint-in { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: none; + } +} + +@media (max-width: 420px) { + .assistant-hint { + right: 8px; + } + .assistant-hint::before { + right: 98px; + } +} + /* * The spark: the one thing in the header that is not the shop's own. Drawn * only where the shop opened its assistant, and once, when it appears, it @@ -2317,6 +2413,39 @@ dialog.assistant[data-voice="on"] .assistant-chips { font-size: 14px; } +.voice-orb[data-state="ready"] { + border-style: dashed; + border-color: var(--ink-soft); +} + +.voice-start { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 56px; + padding: 0 26px; + border: 0; + border-radius: 999px; + background: var(--accent); + color: var(--on-accent); + font: inherit; + font-size: 17px; + font-weight: 700; + box-shadow: var(--shadow-2); +} + +.voice-start svg { + width: 24px; + height: 24px; + fill: currentColor; +} + +@media (prefers-reduced-motion: no-preference) { + .voice-start:not([hidden]) { + animation: orb-breathe 1.8s ease-in-out infinite; + } +} + .voice-stop { min-height: 40px; padding: 0 18px; diff --git a/order/products.html b/order/products.html index d40c9e3d9..0f7fd46a2 100644 --- a/order/products.html +++ b/order/products.html @@ -80,6 +80,15 @@

Menu

+ + @@ -255,6 +264,10 @@

diff --git a/tests/online-ordering-ux.test.js b/tests/online-ordering-ux.test.js index 37ac131d4..243c82cfa 100644 --- a/tests/online-ordering-ux.test.js +++ b/tests/online-ordering-ux.test.js @@ -1049,7 +1049,9 @@ function voicePage({ voice = 'live', reply } = {}) { window.CONFIG = { API_BASE_URL: '' }; window.knownBranchId = async () => 'AZ100'; window.getCartData = async () => JSON.parse(JSON.stringify(cart)); - window.findProduct = (id) => catalogue[id] || null; + /* The real page has allProducts() (global) and NOT findProduct() (inside + the products script's closure); the harness mirrors that. */ + window.allProducts = () => Object.values(catalogue); window.updateQuantity = async (id, change) => { calls.applied.push([id, change]); }; window.setCartItemNote = async () => {}; window.HTMLDialogElement.prototype.showModal = function () { this.open = true; }; @@ -1173,3 +1175,95 @@ test('turn by turn: a refused microphone is said, not swallowed', async () => { assert.strictEqual(calls.fetch.length, 0, 'the assistant was asked with nothing heard'); assert.strictEqual(document.getElementById('assistant').getAttribute('data-voice'), 'off'); }); + +test('a refused live line says why and talks turn by turn; the tap unlocks speech for the iPhone', async () => { + const { window, document, calls } = voicePage({ + voice: 'live', + reply: () => (calls.fetch.length === 1 + ? { status: 403, body: { type: 'error', message: 'This shop has not switched on live voice' } } + : { status: 200, body: { type: 'success', data: { reply: 'Try the biryani.', actions: [] } } }), + }); + let heard = ['what is good', '', '']; + window.SpeechRecognition = function () { + this.start = () => { const said = heard.shift() || ''; setTimeout(() => { if (said) this.onresult({ resultIndex: 0, results: [[{ transcript: said }]] }); this.onend(); }, 0); }; + this.abort = () => {}; + this.stop = () => {}; + }; + const spokenInTap = []; + const speak = window.speechSynthesis.speak; + window.speechSynthesis.speak = function (u) { spokenInTap.push(u.text); return speak.call(this, u); }; + document.getElementById('assistant-talk').click(); + assert.deepStrictEqual(spokenInTap.slice(0, 1), [' '], 'nothing was spoken inside the tap to unlock the iPhone'); + await settle(); + await new Promise((r) => setTimeout(r, 40)); + const log = document.getElementById('assistant-log').textContent; + assert.match(log, /Live voice is switched off for this shop/, 'the refusal was swallowed'); + assert.strictEqual(calls.fetch[0].url, '/online-ordering/AZ100/voice'); + assert.strictEqual(calls.fetch[1].url, '/online-ordering/AZ100/assistant', 'turn by turn did not follow'); + assert.ok(calls.spoken.includes('Try the biryani.'), 'the fallback answer was not spoken'); +}); + +test('the live switch is the first thing under the assistant, and says what the microphone will do', () => { + const html = fs.readFileSync(path.join(__dirname, '..', 'frontend', 'modules', 'settings_write.html'), 'utf8'); + const config = html.indexOf('id="ai_assistant_config"'); + const live = html.indexOf('id="ai_live_voice_row"'); + const greeting = html.indexOf('id="ai_assistant_greeting"'); + assert.ok(config > 0 && live > config && live < greeting, 'the live switch is buried below the writing boxes'); + assert.match(html, /id="ai_live_voice_state"/, 'no word on what the microphone will do'); + const js = fs.readFileSync(path.join(__dirname, '..', 'frontend', 'static', 'script', 'js', 'modules', 'js', 'settings.js'), 'utf8'); + assert.match(js, /lang_ai_live_voice_on/); + assert.match(js, /\$\(document\)\.on\('change', '#ai_live_voice'/, 'flipping the switch does not update the word'); +}); + +test('the page says once that you can ask or talk, and the greeting mentions the microphone where there is one', async () => { + const first = voicePage({ voice: 'live', reply: { status: 200, body: {} } }); + const hint = first.document.getElementById('assistant-hint'); + assert.strictEqual(hint.hidden, false, 'a first visit gets no callout'); + assert.strictEqual(first.document.getElementById('assistant-hint-text').textContent, "Ask me what's good, or just talk"); + first.document.getElementById('assistant-hint-open').click(); + assert.strictEqual(hint.hidden, true, 'opening the sheet left the callout up'); + assert.strictEqual(first.window.localStorage.getItem('posnic_assistant_seen'), '1', 'the callout is not remembered as seen'); + assert.match(first.document.getElementById('assistant-log').textContent, /Or tap the microphone and just talk\./); + + const again = voicePage({ voice: 'live', reply: { status: 200, body: {} } }); + again.window.localStorage.setItem('posnic_assistant_seen', '1'); + again.window.OrderingAssistant.paintSpark(); + assert.strictEqual(again.document.getElementById('assistant-hint').hidden, true, 'a phone that has seen it is shown it again'); + + const typed = voicePage({ voice: '', reply: { status: 200, body: {} } }); + assert.strictEqual(typed.document.getElementById('assistant-hint-text').textContent, "Ask me what's good"); + typed.document.getElementById('assistant-hint-close').click(); + assert.strictEqual(typed.document.getElementById('assistant-hint').hidden, true); + typed.document.getElementById('ask-ai').click(); + assert.ok(!/microphone/.test(typed.document.getElementById('assistant-log').textContent), 'a shop with no voice is told about a microphone'); +}); + +test('a code printed for the talk lands the customer in the conversation, ready to talk', async () => { + /* Owner: "Order with AI required special QR. if user scan then directly + land AI talk." */ + const talk = voicePage({ voice: 'live', reply: { status: 200, body: {} } }); + talk.window.sessionStorage.setItem('posnic_ai_first', 'talk'); + talk.window.OrderingAssistant.paintSpark(); + assert.strictEqual(talk.document.getElementById('assistant').open, true, 'the sheet did not open on landing'); + assert.strictEqual(talk.document.getElementById('voice').hidden, false, 'the voice panel is not up'); + assert.strictEqual(talk.document.getElementById('voice-start').hidden, false, 'no "Tap to talk"'); + assert.strictEqual(talk.document.getElementById('voice-status').textContent, 'Tap to talk'); + assert.strictEqual(talk.window.sessionStorage.getItem('posnic_ai_first'), null, 'the wish is not spent'); + assert.strictEqual(talk.document.getElementById('assistant-hint').hidden, true, 'the callout competes with the open sheet'); + + const ask = voicePage({ voice: '', reply: { status: 200, body: {} } }); + ask.window.sessionStorage.setItem('posnic_ai_first', 'ask'); + ask.window.OrderingAssistant.paintSpark(); + assert.strictEqual(ask.document.getElementById('assistant').open, true); + assert.strictEqual(ask.document.getElementById('voice').hidden, true, 'a shop with no voice was stood ready to talk'); + + const plain = voicePage({ voice: 'live', reply: { status: 200, body: {} } }); + assert.strictEqual(plain.document.getElementById('assistant').open, false, 'a plain link opened the sheet'); + + const arrival = read('assets/index/script.js'); + assert.match(arrival, /sessionStorage\.setItem\("posnic_ai_first"/, 'the arrival page drops ?ai= with its redirect'); + const js = fs.readFileSync(path.join(__dirname, '..', 'frontend', 'static', 'script', 'js', 'modules', 'js', 'settings.js'), 'utf8'); + assert.match(js, /storefront_talk_url'\)\.val\(base \+ '\/order\/' \+ id \+ '\?ai=talk'\)/, 'the console prints no talk address'); + const html = fs.readFileSync(path.join(__dirname, '..', 'frontend', 'modules', 'settings_write.html'), 'utf8'); + assert.match(html, /id="storefront_talk_url"/); +});