widget: single-bubble home screen for chat+whatsapp both mode
Browse files- app/static/widget.js +66 -6
- tests/test_widget_assets.py +18 -0
app/static/widget.js
CHANGED
|
@@ -58,6 +58,8 @@
|
|
| 58 |
speak: "Hablar por voz",
|
| 59 |
listening: "Escuchando\u2026",
|
| 60 |
micDenied: "Permiso de micr\u00f3fono denegado",
|
|
|
|
|
|
|
| 61 |
starters: ["\u00bfQu\u00e9 ofrec\u00e9is?", "Quiero m\u00e1s informaci\u00f3n", "Hablar con una persona"]
|
| 62 |
},
|
| 63 |
en: {
|
|
@@ -89,6 +91,8 @@
|
|
| 89 |
speak: "Speak",
|
| 90 |
listening: "Listening\u2026",
|
| 91 |
micDenied: "Microphone permission denied",
|
|
|
|
|
|
|
| 92 |
starters: ["What do you offer?", "I want more information", "Talk to a person"]
|
| 93 |
},
|
| 94 |
fr: {
|
|
@@ -120,6 +124,8 @@
|
|
| 120 |
speak: "Parler",
|
| 121 |
listening: "\u00c9coute\u2026",
|
| 122 |
micDenied: "Acc\u00e8s au microphone refus\u00e9",
|
|
|
|
|
|
|
| 123 |
starters: ["Que proposez-vous ?", "Je souhaite plus d'informations", "Parler \u00e0 une personne"]
|
| 124 |
},
|
| 125 |
de: {
|
|
@@ -151,6 +157,8 @@
|
|
| 151 |
speak: "Sprechen",
|
| 152 |
listening: "H\u00f6re zu\u2026",
|
| 153 |
micDenied: "Mikrofonzugriff verweigert",
|
|
|
|
|
|
|
| 154 |
starters: ["Was bieten Sie an?", "Ich m\u00f6chte mehr Informationen", "Mit einer Person sprechen"]
|
| 155 |
},
|
| 156 |
it: {
|
|
@@ -182,6 +190,8 @@
|
|
| 182 |
speak: "Parla",
|
| 183 |
listening: "In ascolto\u2026",
|
| 184 |
micDenied: "Accesso al microfono negato",
|
|
|
|
|
|
|
| 185 |
starters: ["Cosa offrite?", "Vorrei maggiori informazioni", "Parlare con una persona"]
|
| 186 |
},
|
| 187 |
pt: {
|
|
@@ -213,6 +223,8 @@
|
|
| 213 |
speak: "Falar",
|
| 214 |
listening: "A ouvir\u2026",
|
| 215 |
micDenied: "Permiss\u00e3o de microfone negada",
|
|
|
|
|
|
|
| 216 |
starters: ["O que oferecem?", "Quero mais informa\u00e7\u00f5es", "Falar com uma pessoa"]
|
| 217 |
}
|
| 218 |
};
|
|
@@ -301,6 +313,14 @@
|
|
| 301 |
"#ssb-wa.solo{bottom:20px;right:20px;width:auto;height:60px;border-radius:30px;padding:0 22px 0 18px;box-shadow:0 8px 24px rgba(37,211,102,.45)}" +
|
| 302 |
"#ssb-wa.solo svg{width:30px;height:30px}" +
|
| 303 |
"#ssb-wa .ssb-wa-lbl{color:#fff;font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-weight:700;font-size:15px}" +
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
"@keyframes ssbpulse{0%{box-shadow:0 0 0 0 " + c + "66}70%{box-shadow:0 0 0 16px " + c + "00}100%{box-shadow:0 0 0 0 " + c + "00}}" +
|
| 305 |
"#ssb-panel{position:fixed;bottom:20px;right:20px;width:380px;max-width:94vw;height:600px;max-height:80vh;background:#fff;border-radius:18px;box-shadow:0 18px 60px rgba(0,0,0,.3);display:none;flex-direction:column;overflow:hidden;z-index:99999;font-family:system-ui,-apple-system,Segoe UI,sans-serif;opacity:0;transform:translateY(16px) scale(.98);transition:opacity .22s,transform .22s}" +
|
| 306 |
"#ssb-panel.open{display:flex;opacity:1;transform:none}" +
|
|
@@ -396,13 +416,18 @@
|
|
| 396 |
// Embed mode chosen by the store: "chat" | "whatsapp" | "both".
|
| 397 |
var mode = cfg.widget_mode || "chat";
|
| 398 |
var waDigits = cfg.whatsapp_number ? String(cfg.whatsapp_number).replace(/[^0-9]/g, "") : "";
|
| 399 |
-
var showWa = (mode === "whatsapp" || mode === "both") && waDigits;
|
| 400 |
var wantChat = (mode === "chat" || mode === "both");
|
| 401 |
-
var
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
|
| 403 |
// WhatsApp launcher: direct link to wa.me. "solo" = primary spot when it's
|
| 404 |
// the only launcher; otherwise it sits stacked above the chat bubble.
|
| 405 |
-
if (
|
| 406 |
var wab = document.createElement("a");
|
| 407 |
wab.id = "ssb-wa";
|
| 408 |
if (!showChat) wab.className = "solo";
|
|
@@ -419,6 +444,16 @@
|
|
| 419 |
launch.id = "ssb-launch"; launch.setAttribute("aria-label", T.open);
|
| 420 |
launch.innerHTML = '<span class="ssb-pulse"></span>' + ICON_AGENT + '<span class="ssb-dot"></span><span class="ssb-badge"></span>';
|
| 421 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
var panel = document.createElement("div");
|
| 423 |
panel.id = "ssb-panel";
|
| 424 |
panel.innerHTML =
|
|
@@ -427,6 +462,8 @@
|
|
| 427 |
'<div class="ssb-status"><i></i>' + T.status + '</div></div>' +
|
| 428 |
'<button id="ssb-mute" type="button"></button>' +
|
| 429 |
'<button class="ssb-x" aria-label="' + T.close + '">×</button></div>' +
|
|
|
|
|
|
|
| 430 |
'<div id="ssb-msgs"></div>' +
|
| 431 |
'<div id="ssb-att"></div>' +
|
| 432 |
'<form id="ssb-form">' +
|
|
@@ -435,7 +472,7 @@
|
|
| 435 |
'<input id="ssb-input" type="text" autocomplete="off" placeholder="' + T.input + '" />' +
|
| 436 |
(HAS_SR ? '<button id="ssb-mic" type="button" aria-label="' + T.speak + '" title="' + T.speak + '">' + ICON_MIC + '</button>' : '') +
|
| 437 |
'<button id="ssb-send" type="submit" aria-label="' + T.send + '">' + ICON_SEND + '</button></form>' +
|
| 438 |
-
'<div id="ssb-foot">' + T.footer + '</div>';
|
| 439 |
|
| 440 |
document.body.appendChild(launch);
|
| 441 |
document.body.appendChild(panel);
|
|
@@ -445,6 +482,25 @@
|
|
| 445 |
var form = panel.querySelector("#ssb-form");
|
| 446 |
var input = panel.querySelector("#ssb-input");
|
| 447 |
var greeted = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
|
| 449 |
// ---- Visitor uploads (Phase 3): paperclip -> chip -> ids on next send ----
|
| 450 |
var attBox = panel.querySelector("#ssb-att");
|
|
@@ -744,8 +800,12 @@
|
|
| 744 |
panel.classList.toggle("open");
|
| 745 |
if (panel.classList.contains("open")) {
|
| 746 |
setBadge(0); stopFlash();
|
| 747 |
-
|
| 748 |
-
input
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
}
|
| 750 |
}
|
| 751 |
launch.addEventListener("click", toggle);
|
|
|
|
| 58 |
speak: "Hablar por voz",
|
| 59 |
listening: "Escuchando\u2026",
|
| 60 |
micDenied: "Permiso de micr\u00f3fono denegado",
|
| 61 |
+
homeTitle: "\u00bfC\u00f3mo prefieres escribirnos?",
|
| 62 |
+
homeChat: "Chatear aqu\u00ed", homeWa: "Seguir por WhatsApp",
|
| 63 |
starters: ["\u00bfQu\u00e9 ofrec\u00e9is?", "Quiero m\u00e1s informaci\u00f3n", "Hablar con una persona"]
|
| 64 |
},
|
| 65 |
en: {
|
|
|
|
| 91 |
speak: "Speak",
|
| 92 |
listening: "Listening\u2026",
|
| 93 |
micDenied: "Microphone permission denied",
|
| 94 |
+
homeTitle: "How would you like to talk to us?",
|
| 95 |
+
homeChat: "Chat here", homeWa: "Continue on WhatsApp",
|
| 96 |
starters: ["What do you offer?", "I want more information", "Talk to a person"]
|
| 97 |
},
|
| 98 |
fr: {
|
|
|
|
| 124 |
speak: "Parler",
|
| 125 |
listening: "\u00c9coute\u2026",
|
| 126 |
micDenied: "Acc\u00e8s au microphone refus\u00e9",
|
| 127 |
+
homeTitle: "Comment pr\u00e9f\u00e9rez-vous nous \u00e9crire ?",
|
| 128 |
+
homeChat: "Discuter ici", homeWa: "Continuer sur WhatsApp",
|
| 129 |
starters: ["Que proposez-vous ?", "Je souhaite plus d'informations", "Parler \u00e0 une personne"]
|
| 130 |
},
|
| 131 |
de: {
|
|
|
|
| 157 |
speak: "Sprechen",
|
| 158 |
listening: "H\u00f6re zu\u2026",
|
| 159 |
micDenied: "Mikrofonzugriff verweigert",
|
| 160 |
+
homeTitle: "Wie m\u00f6chten Sie uns schreiben?",
|
| 161 |
+
homeChat: "Hier chatten", homeWa: "Weiter auf WhatsApp",
|
| 162 |
starters: ["Was bieten Sie an?", "Ich m\u00f6chte mehr Informationen", "Mit einer Person sprechen"]
|
| 163 |
},
|
| 164 |
it: {
|
|
|
|
| 190 |
speak: "Parla",
|
| 191 |
listening: "In ascolto\u2026",
|
| 192 |
micDenied: "Accesso al microfono negato",
|
| 193 |
+
homeTitle: "Come preferisci scriverci?",
|
| 194 |
+
homeChat: "Chatta qui", homeWa: "Continua su WhatsApp",
|
| 195 |
starters: ["Cosa offrite?", "Vorrei maggiori informazioni", "Parlare con una persona"]
|
| 196 |
},
|
| 197 |
pt: {
|
|
|
|
| 223 |
speak: "Falar",
|
| 224 |
listening: "A ouvir\u2026",
|
| 225 |
micDenied: "Permiss\u00e3o de microfone negada",
|
| 226 |
+
homeTitle: "Como prefere falar connosco?",
|
| 227 |
+
homeChat: "Conversar aqui", homeWa: "Continuar no WhatsApp",
|
| 228 |
starters: ["O que oferecem?", "Quero mais informa\u00e7\u00f5es", "Falar com uma pessoa"]
|
| 229 |
}
|
| 230 |
};
|
|
|
|
| 313 |
"#ssb-wa.solo{bottom:20px;right:20px;width:auto;height:60px;border-radius:30px;padding:0 22px 0 18px;box-shadow:0 8px 24px rgba(37,211,102,.45)}" +
|
| 314 |
"#ssb-wa.solo svg{width:30px;height:30px}" +
|
| 315 |
"#ssb-wa .ssb-wa-lbl{color:#fff;font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-weight:700;font-size:15px}" +
|
| 316 |
+
"#ssb-home{padding:20px 16px;display:flex;flex-direction:column;gap:10px;flex:1;justify-content:center}" +
|
| 317 |
+
"#ssb-home .ssb-home-title{font:inherit;font-size:14.5px;color:#555;text-align:center;margin-bottom:6px}" +
|
| 318 |
+
".ssb-home-btn{display:flex;align-items:center;justify-content:center;gap:9px;border-radius:14px;padding:14px 16px;font:inherit;font-size:14.5px;font-weight:700;cursor:pointer;border:none;transition:transform .15s}" +
|
| 319 |
+
".ssb-home-btn:hover{transform:scale(1.02)}" +
|
| 320 |
+
".ssb-home-btn svg{width:20px;height:20px;flex:none;fill:#fff}" +
|
| 321 |
+
".ssb-home-btn-chat{background:" + c + ";color:#fff}" +
|
| 322 |
+
".ssb-home-btn-wa{background:#25D366;color:#fff}" +
|
| 323 |
+
"#ssb-chatarea{display:flex;flex-direction:column;flex:1;min-height:0}" +
|
| 324 |
"@keyframes ssbpulse{0%{box-shadow:0 0 0 0 " + c + "66}70%{box-shadow:0 0 0 16px " + c + "00}100%{box-shadow:0 0 0 0 " + c + "00}}" +
|
| 325 |
"#ssb-panel{position:fixed;bottom:20px;right:20px;width:380px;max-width:94vw;height:600px;max-height:80vh;background:#fff;border-radius:18px;box-shadow:0 18px 60px rgba(0,0,0,.3);display:none;flex-direction:column;overflow:hidden;z-index:99999;font-family:system-ui,-apple-system,Segoe UI,sans-serif;opacity:0;transform:translateY(16px) scale(.98);transition:opacity .22s,transform .22s}" +
|
| 326 |
"#ssb-panel.open{display:flex;opacity:1;transform:none}" +
|
|
|
|
| 416 |
// Embed mode chosen by the store: "chat" | "whatsapp" | "both".
|
| 417 |
var mode = cfg.widget_mode || "chat";
|
| 418 |
var waDigits = cfg.whatsapp_number ? String(cfg.whatsapp_number).replace(/[^0-9]/g, "") : "";
|
|
|
|
| 419 |
var wantChat = (mode === "chat" || mode === "both");
|
| 420 |
+
var hasWa = (mode === "whatsapp" || mode === "both") && waDigits;
|
| 421 |
+
// "both" with a real WhatsApp number -> ONE bubble that opens a home screen
|
| 422 |
+
// where the visitor picks the channel, instead of two separate floating
|
| 423 |
+
// launchers. "whatsapp"-only keeps the standalone floating WA button.
|
| 424 |
+
var showHome = mode === "both" && hasWa;
|
| 425 |
+
var showWaStandalone = hasWa && !showHome;
|
| 426 |
+
var showChat = wantChat || !hasWa; // always show something (fallback to chat)
|
| 427 |
|
| 428 |
// WhatsApp launcher: direct link to wa.me. "solo" = primary spot when it's
|
| 429 |
// the only launcher; otherwise it sits stacked above the chat bubble.
|
| 430 |
+
if (showWaStandalone) {
|
| 431 |
var wab = document.createElement("a");
|
| 432 |
wab.id = "ssb-wa";
|
| 433 |
if (!showChat) wab.className = "solo";
|
|
|
|
| 444 |
launch.id = "ssb-launch"; launch.setAttribute("aria-label", T.open);
|
| 445 |
launch.innerHTML = '<span class="ssb-pulse"></span>' + ICON_AGENT + '<span class="ssb-dot"></span><span class="ssb-badge"></span>';
|
| 446 |
|
| 447 |
+
// "both" mode: a home screen inside the panel lets the visitor pick chat vs
|
| 448 |
+
// WhatsApp, instead of two separate floating buttons on the page.
|
| 449 |
+
var homeHtml = showHome
|
| 450 |
+
? '<div id="ssb-home"><div class="ssb-home-title">' + esc(T.homeTitle) + '</div>' +
|
| 451 |
+
'<button id="ssb-home-chat" type="button" class="ssb-home-btn ssb-home-btn-chat">' +
|
| 452 |
+
ICON_AGENT + '<span>' + esc(T.homeChat) + '</span></button>' +
|
| 453 |
+
'<button id="ssb-home-wa" type="button" class="ssb-home-btn ssb-home-btn-wa">' +
|
| 454 |
+
ICON_WA + '<span>' + esc(T.homeWa) + '</span></button></div>'
|
| 455 |
+
: '';
|
| 456 |
+
|
| 457 |
var panel = document.createElement("div");
|
| 458 |
panel.id = "ssb-panel";
|
| 459 |
panel.innerHTML =
|
|
|
|
| 462 |
'<div class="ssb-status"><i></i>' + T.status + '</div></div>' +
|
| 463 |
'<button id="ssb-mute" type="button"></button>' +
|
| 464 |
'<button class="ssb-x" aria-label="' + T.close + '">×</button></div>' +
|
| 465 |
+
homeHtml +
|
| 466 |
+
'<div id="ssb-chatarea"' + (showHome ? ' style="display:none"' : '') + '>' +
|
| 467 |
'<div id="ssb-msgs"></div>' +
|
| 468 |
'<div id="ssb-att"></div>' +
|
| 469 |
'<form id="ssb-form">' +
|
|
|
|
| 472 |
'<input id="ssb-input" type="text" autocomplete="off" placeholder="' + T.input + '" />' +
|
| 473 |
(HAS_SR ? '<button id="ssb-mic" type="button" aria-label="' + T.speak + '" title="' + T.speak + '">' + ICON_MIC + '</button>' : '') +
|
| 474 |
'<button id="ssb-send" type="submit" aria-label="' + T.send + '">' + ICON_SEND + '</button></form>' +
|
| 475 |
+
'<div id="ssb-foot">' + T.footer + '</div></div>';
|
| 476 |
|
| 477 |
document.body.appendChild(launch);
|
| 478 |
document.body.appendChild(panel);
|
|
|
|
| 482 |
var form = panel.querySelector("#ssb-form");
|
| 483 |
var input = panel.querySelector("#ssb-input");
|
| 484 |
var greeted = false;
|
| 485 |
+
var homeEl = panel.querySelector("#ssb-home");
|
| 486 |
+
var chatarea = panel.querySelector("#ssb-chatarea");
|
| 487 |
+
|
| 488 |
+
// "both" mode: visitor picks a channel on open. Chat -> reveal the normal
|
| 489 |
+
// chat area (and greet, same as chat-only mode). WhatsApp -> open wa.me;
|
| 490 |
+
// the panel stays open behind it so they can still switch to chat.
|
| 491 |
+
function chooseChat() {
|
| 492 |
+
if (homeEl) homeEl.style.display = "none";
|
| 493 |
+
if (chatarea) chatarea.style.display = "flex";
|
| 494 |
+
if (!greeted) { botMsg(cfg.welcome); renderStarters(); greeted = true; }
|
| 495 |
+
input.focus();
|
| 496 |
+
}
|
| 497 |
+
function chooseWa() {
|
| 498 |
+
window.open("https://wa.me/" + waDigits, "_blank", "noopener");
|
| 499 |
+
}
|
| 500 |
+
if (homeEl) {
|
| 501 |
+
panel.querySelector("#ssb-home-chat").addEventListener("click", chooseChat);
|
| 502 |
+
panel.querySelector("#ssb-home-wa").addEventListener("click", chooseWa);
|
| 503 |
+
}
|
| 504 |
|
| 505 |
// ---- Visitor uploads (Phase 3): paperclip -> chip -> ids on next send ----
|
| 506 |
var attBox = panel.querySelector("#ssb-att");
|
|
|
|
| 800 |
panel.classList.toggle("open");
|
| 801 |
if (panel.classList.contains("open")) {
|
| 802 |
setBadge(0); stopFlash();
|
| 803 |
+
// With a home screen, wait for the visitor to pick a channel before
|
| 804 |
+
// greeting/focusing the input (avoids a wasted bot turn pre-choice).
|
| 805 |
+
if (!homeEl) {
|
| 806 |
+
if (!greeted) { botMsg(cfg.welcome); renderStarters(); greeted = true; }
|
| 807 |
+
input.focus();
|
| 808 |
+
}
|
| 809 |
}
|
| 810 |
}
|
| 811 |
launch.addEventListener("click", toggle);
|
tests/test_widget_assets.py
CHANGED
|
@@ -17,6 +17,24 @@ def test_widget_js_supports_signed_proxy_mode():
|
|
| 17 |
assert "localStorage" in js
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
def test_block_is_app_embed_loading_widget_in_proxy_mode():
|
| 21 |
liquid = BLOCK.read_text(encoding="utf-8")
|
| 22 |
assert "{% schema %}" in liquid
|
|
|
|
| 17 |
assert "localStorage" in js
|
| 18 |
|
| 19 |
|
| 20 |
+
def test_widget_both_mode_shows_a_single_bubble_with_channel_picker():
|
| 21 |
+
"""'both' (chat+WhatsApp) must be ONE bubble that opens a home screen letting
|
| 22 |
+
the visitor pick a channel -- not two separate floating launchers. Verified
|
| 23 |
+
behaviorally in a real headless browser during development (22/22 assertions:
|
| 24 |
+
single bubble, no premature greet, correct wa.me resolution, chat/whatsapp-only
|
| 25 |
+
modes unaffected); this structural test guards the markers that behavior relies
|
| 26 |
+
on so a future edit can't silently remove them."""
|
| 27 |
+
js = WIDGET.read_text(encoding="utf-8")
|
| 28 |
+
assert 'var showHome = mode === "both" && hasWa' in js
|
| 29 |
+
assert 'var showWaStandalone = hasWa && !showHome' in js
|
| 30 |
+
assert 'id="ssb-home"' in js and 'id="ssb-chatarea"' in js
|
| 31 |
+
assert 'id="ssb-home-chat"' in js and 'id="ssb-home-wa"' in js
|
| 32 |
+
assert "chooseChat" in js and "chooseWa" in js
|
| 33 |
+
# every language block carries the picker copy (real i18n, not just Spanish)
|
| 34 |
+
for key in ("homeTitle", "homeChat", "homeWa"):
|
| 35 |
+
assert js.count(key + ":") == 6, f"{key} must be defined in all 6 languages"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
def test_block_is_app_embed_loading_widget_in_proxy_mode():
|
| 39 |
liquid = BLOCK.read_text(encoding="utf-8")
|
| 40 |
assert "{% schema %}" in liquid
|