1(function() { 2 "use strict"; 3 4 // Prevent double-initialisation if both the liquid tag and bootstrap load this file 5 if (window.__sgLoaded) return; 6 window.__sgLoaded = true; 7 8 var WIDGET_VERSION = "8.44.2"; 9 var WIDGET_ID = "style-guru-widget"; 10 var API_BASE = ""; 11 var PROXY_PATH = ""; 12 var shopDomain = ""; 13 var config = { 14 primaryColor: "#C6A85B", 15 fontColor: "#333333", 16 fontSize: "14", 17 backgroundColor: "#ffffff", 18 borderRadius: "24", 19 borderColor: "#e0e0e0", 20 borderWidth: "1", 21 widgetHeight: "400", 22 widgetWidth: "100", 23 widgetPadding: "10", 24 boxShadow: "none", 25 stylistName: "Style Guru", 26 welcomeMessage: "Good evening. What's the occasion?", 27 interactionMode: "text_only", 28 activationMode: "both", 29 topButtonLabel: null, 30 topButtonColor: null, 31 bellPosition: "bottom_left", 32 _version: "v8.3.0" 33 }; 34 var messages = []; 35 var isLoading = false; 36 var isModalOpen = false; 37 var shownProductIds = []; 38 var isListening = false; 39 var isSpeaking = false; 40 var currentAudio = null; 41 var recognition = null; 42 var sessionId = ""; 43 var detectedCurrency = ""; 44 var autoTriggeredThisSession = false; 45 // Scope bridges: implemented inside the widget bind closure (they capture 46 // closure state), but called from top-level functions like sendMessage and 47 // renderProducts. No-ops until the widget has been opened and bound. 48 var updateScrollFade = function() {}; 49 var detectAndApplyProactiveFilter = function() {}; 50 // Complete-look zone renderers live in the bind closure but are called from 51 // the top-level response handlers â bridge them like the vars above. 52 var hideCompleteLook = function() {}; 53 var showCompleteLook = function() {}; 54 // AI grid filters â bound when the filter pane initialises; the streaming 55 // reply handler lives outside that closure and calls it through this bridge. 56 var applyAIFilters = function() {}; 57 var devCustomerId = ""; 58 // True when the client-side proactive filter updated the product grid for 59 // the in-flight message â the mobile chip must fire for this path too. 60 var proactiveFilterApplied = false; 61 // Set when an AI grid command is applied: the next grid load echoes its top 62 // results as slim thumbnails inside the assistant's chat reply. 63 var pendingChatPreview = false; 64 // Last user text sent to the API â the "Try again" chip re-sends it after 65 // an error without making the customer retype. 66 var lastSentText = ""; 67 var feedbackVotes = {}; // message index -> "up"/"down" (survives re-renders) 68 69 // ââ Locale & chrome translations âââââââââââââââââââââââââââââââââââââââââ 70 // The AI replies in the shopper's language on its own; this table localises 71 // the widget CHROME (tabs, buttons, placeholders). Product names, sizes and 72 // prices are never translated. 73 var sgLocale = "en"; 74 try { 75 sgLocale = String((window.Shopify && window.Shopify.locale) || document.documentElement.lang || "en").toLowerCase().split("-")[0]; 76 } catch (e) {} 77 var I18N = { 78 en: { storeReply:"from the store team", teaserSizing:"Not sure about sizing? Ask me", chat:"Chat", outfits:"Outfits", browse:"Browse", results:"Results", searchProducts:"Search products...", category:"Category", colour:"Colour", size:"Size", price:"Price", addToBag:"Add to Bag", add:"Add", added:"Added", askPlaceholder:"Ask about outfits, sizes, styles...", micPlaceholder:"Type or use the mic...", starterOutfit:"Build me an outfit", starterSize:"What's my size?", starterOrder:"Where's my order?", welcomeBack:"Welcome back â what else can I help you with?", viewOutfit:"View the outfit", viewProducts:"View the products", tryAgain:"Try again", thanksFeedback:"Thanks for the feedback", helpful:"Helpful", notRight:"Not right", noProducts:"No products found", adjustFilters:"Try adjusting your filters.", noRecs:"No recommendations yet", noRecsSub:"Ask me about outfits, sizes, or styles and I'll show you matching products here.", leadGateTitle:"Your look is ready \u2014 who shall I show it to?", leadGateBody:"Leave your name and email and I\u2019ll reveal it, plus keep your looks and sizes saved for next time.", leadGateName:"Your name", leadGateEmail:"Your email address", leadGateCta:"Show my look" }, 79 fr: { storeReply:"de l'équipe de la boutique", teaserSizing:"Un doute sur la taille ? Demandez-moi", chat:"Discussion", outfits:"Tenues", browse:"Parcourir", results:"Résultats", searchProducts:"Rechercher des produits...", category:"Catégorie", colour:"Couleur", size:"Taille", price:"Prix", addToBag:"Ajouter au panier", add:"Ajouter", added:"Ajouté", askPlaceholder:"Tenues, tailles, styles â demandez-moi...", micPlaceholder:"Ãcrivez ou utilisez le micro...", starterOutfit:"Composez-moi une tenue", starterSize:"Quelle est ma taille ?", starterOrder:"Où est ma commande ?", welcomeBack:"Ravi de vous revoir â que puis-je faire pour vous ?", viewOutfit:"Voir la tenue", viewProducts:"Voir les produits", tryAgain:"Réessayer", thanksFeedback:"Merci pour votre retour", helpful:"Utile", notRight:"Inexact", noProducts:"Aucun produit trouvé", adjustFilters:"Essayez d'ajuster vos filtres.", noRecs:"Pas encore de recommandations", noRecsSub:"Demandez-moi des tenues, des tailles ou des styles et je vous montrerai des produits ici.", leadGateTitle:"Votre tenue est pr\u00eate \u2014 \u00e0 qui dois-je la montrer ?", leadGateBody:"Laissez votre nom et votre e-mail et je vous la r\u00e9v\u00e8le \u2014 vos tenues et tailles seront gard\u00e9es pour la prochaine fois.", leadGateName:"Votre nom", leadGateEmail:"Votre adresse e-mail", leadGateCta:"Voir ma tenue" }, 80 de: { storeReply:"vom Team des Geschäfts", teaserSizing:"Unsicher bei der GröÃe? Fragen Sie mich", chat:"Chat", outfits:"Outfits", browse:"Stöbern", results:"Ergebnisse", searchProducts:"Produkte suchen...", category:"Kategorie", colour:"Farbe", size:"GröÃe", price:"Preis", addToBag:"In den Warenkorb", add:"Hinzufügen", added:"Hinzugefügt", askPlaceholder:"Fragen Sie nach Outfits, GröÃen, Stilen...", micPlaceholder:"Tippen oder Mikrofon nutzen...", starterOutfit:"Stell mir ein Outfit zusammen", starterSize:"Welche GröÃe habe ich?", starterOrder:"Wo ist meine Bestellung?", welcomeBack:"Schön, Sie wiederzusehen â womit kann ich noch helfen?", viewOutfit:"Outfit ansehen", viewProducts:"Produkte ansehen", tryAgain:"Erneut versuchen", thanksFeedback:"Danke für Ihr Feedback", helpful:"Hilfreich", notRight:"Nicht richtig", noProducts:"Keine Produkte gefunden", adjustFilters:"Passen Sie Ihre Filter an.", noRecs:"Noch keine Empfehlungen", noRecsSub:"Fragen Sie mich nach Outfits, GröÃen oder Stilen â passende Produkte erscheinen hier.", leadGateTitle:"Ihr Outfit ist fertig \u2014 wem darf ich es zeigen?", leadGateBody:"Hinterlassen Sie Name und E-Mail und ich zeige es Ihnen \u2014 Outfits und Gr\u00f6\u00dfen bleiben f\u00fcrs n\u00e4chste Mal gespeichert.", leadGateName:"Ihr Name", leadGateEmail:"Ihre E-Mail-Adresse", leadGateCta:"Outfit anzeigen" }, 81 es: { storeReply:"del equipo de la tienda", teaserSizing:"¿Dudas con la talla? Pregúntame", chat:"Chat", outfits:"Conjuntos", browse:"Explorar", results:"Resultados", searchProducts:"Buscar productos...", category:"CategorÃa", colour:"Color", size:"Talla", price:"Precio", addToBag:"Añadir a la cesta", add:"Añadir", added:"Añ
81adido", askPlaceholder:"Pregunta por conjuntos, tallas, estilos...", micPlaceholder:"Escribe o usa el micrófono...", starterOutfit:"Créame un conjunto", starterSize:"¿Cuál es mi talla?", starterOrder:"¿Dónde está mi pedido?", welcomeBack:"Qué bueno verte de nuevo â ¿en qué más puedo ayudarte?", viewOutfit:"Ver el conjunto", viewProducts:"Ver los productos", tryAgain:"Reintentar", thanksFeedback:"Gracias por tu opinión", helpful:"Ãtil", notRight:"Incorrecto", noProducts:"No se encontraron productos", adjustFilters:"Prueba a ajustar los filtros.", noRecs:"Aún no hay recomendaciones", noRecsSub:"Pregúntame por conjuntos, tallas o estilos y te mostraré productos aquÃ.", leadGateTitle:"Tu conjunto est\u00e1 listo \u2014 \u00bfa qui\u00e9n se lo muestro?", leadGateBody:"Deja tu nombre y tu correo y te lo revelo \u2014 guardamos tus conjuntos y tallas para la pr\u00f3xima vez.", leadGateName:"Tu nombre", leadGateEmail:"Tu correo electr\u00f3nico", leadGateCta:"Ver mi conjunto" }, 82 it: { storeReply:"dal team del negozio", teaserSizing:"Dubbi sulla taglia? Chiedimi", chat:"Chat", outfits:"Outfit", browse:"Sfoglia", results:"Risultati", searchProducts:"Cerca prodotti...", category:"Categoria", colour:"Colore", size:"Taglia", price:"Prezzo", addToBag:"Aggiungi al carrello", add:"Aggiungi", added:"Aggiunto", askPlaceholder:"Chiedimi outfit, taglie, stili...", micPlaceholder:"Scrivi o usa il microfono...", starterOutfit:"Creami un outfit", starterSize:"Qual è la mia taglia?", starterOrder:"Dov'è il mio ordine?", welcomeBack:"Bentornato â come posso aiutarla ancora?", viewOutfit:"Vedi l'outfit", viewProducts:"Vedi i prodotti", tryAgain:"Riprova", thanksFeedback:"Grazie per il feedback", helpful:"Utile", notRight:"Non corretto", noProducts:"Nessun prodotto trovato", adjustFilters:"Prova a modificare i filtri.", noRecs:"Ancora nessun consiglio", noRecsSub:"Chiedimi outfit, taglie o stili e ti mostrerò i prodotti qui.", leadGateTitle:"Il tuo look \u00e8 pronto \u2014 a chi lo mostro?", leadGateBody:"Lascia nome ed email e te lo rivelo \u2014 look e taglie restano salvati per la prossima volta.", leadGateName:"Il tuo nome", leadGateEmail:"La tua email", leadGateCta:"Mostra il mio look" }, 83 nl: { storeReply:"van het winkelteam", teaserSizing:"Twijfel over je maat? Vraag het mij", chat:"Chat", outfits:"Outfits", browse:"Bladeren", results:"Resultaten", searchProducts:"Producten zoeken...", category:"Categorie", colour:"Kleur", size:"Maat", price:"Prijs", addToBag:"In winkelwagen", add:"Toevoegen", added:"Toegevoegd", askPlaceholder:"Vraag naar outfits, maten, stijlen...", micPlaceholder:"Typ of gebruik de microfoon...", starterOutfit:"Stel een outfit voor me samen", starterSize:"Welke maat heb ik?", starterOrder:"Waar is mijn bestelling?", welcomeBack:"Welkom terug â waarmee kan ik u nog meer helpen?", viewOutfit:"Bekijk de outfit", viewProducts:"Bekijk de producten", tryAgain:"Opnieuw", thanksFeedback:"Bedankt voor je feedback", helpful:"Nuttig", notRight:"Niet juist", noProducts:"Geen producten gevonden", adjustFilters:"Pas je filters aan.", noRecs:"Nog geen aanbevelingen", noRecsSub:"Vraag me naar outfits, maten of stijlen en ik toon hier passende producten.", leadGateTitle:"Je look is klaar \u2014 aan wie zal ik hem laten zien?", leadGateBody:"Laat je naam en e-mail achter en ik onthul hem \u2014 je looks en maten blijven bewaard voor de volgende keer.", leadGateName:"Je naam", leadGateEmail:"Je e-mailadres", leadGateCta:"Toon mijn look" } 84 }; 85 function T(k) { var d = I18N[sgLocale]; return (d && d[k]) || I18N.en[k] || k; } 86 87 // Non-fashion stores (jewellery, health & supplements, general retail): 88 // the fashion wording above would misfire ("Build me an outfit" in a 89 // vitamin shop), so these keys overlay I18N when storeVertical says so. 90 var GENERIC_I18N = { 91 en: { teaserSizing:"Questions? I know every product here", outfits:"Picks", askPlaceholder:"Ask about products, gifts, prices...", starterOutfit:"What do you recommend?", starterSize:"Help me choose a gift", viewOutfit:"View the picks", noRecsSub:"Ask me about products, gifts or prices and I'll show you matching products here." }, 92 fr: { teaserSizing:"Des questions ? Je connais tous nos produits", outfits:"S\u00e9lection", askPlaceholder:"Produits, id\u00e9es cadeaux, prix â demandez-moi...", starterOutfit:"Que me conseillez-vous ?", starterSize:"Aidez-moi \u00e0 choisir un cadeau", viewOutfit:"Voir la s\u00e9lection", noRecsSub:"Demandez-moi des produits, des id\u00e9es cadeaux ou des prix et je vous montrerai des produits ici." }, 93 de: { teaserSizing:"Fragen? Ich kenne jedes Produkt hier", outfits:"Auswahl", askPlaceholder:"Fragen Sie nach Produkten, Geschenken, Preisen...", starterOutfit:"Was empfehlen Sie mir?", starterSize:"Helfen Sie mir, ein Geschenk zu finden", viewOutfit:"Auswahl ansehen", noRecsSub:"Fragen Sie mich nach Produkten, Geschenken oder Preisen â passende Produkte erscheinen hier." }, 94 es: { teaserSizing:"\u00bfPreguntas? Conozco todos los productos", outfits:"Selecci\u00f3n", askPlaceholder:"Pregunta por productos, regalos, precios...", starterOutfit:"\u00bfQu\u00e9 me recomiendas?", starterSize:"Ay\u00fadame a elegir un regalo", viewOutfit:"Ver la selecci\u00f3n", noRecsSub:"Preg\u00fantame por productos, regalos o precios y te mostrar\u00e9 productos aqu\u00ed." }, 95 it: { teaserSizing:"Domande? Conosco ogni prodotto qui", outfits:"Selezione", askPlaceholder:"Chiedimi prodotti, regali, prezzi...", starterOutfit:"Cosa mi consigli?", starterSize:"Aiutami a scegliere un regalo", viewOutfit:"Vedi la selezione", noRecsSub:"Chiedimi prodotti, regali o prezzi e ti mostrer\u00f2 i prodotti qui." }, 96 nl: { teaserSizing:"Vragen? Ik ken elk product hier", outfits:"Selectie", askPlaceholder:"Vraag naar producten, c
96adeaus, prijzen...", starterOutfit:"Wat raad je aan?", starterSize:"Help me een cadeau kiezen", viewOutfit:"Bekijk de selectie", noRecsSub:"Vraag me naar producten, cadeaus of prijzen en ik toon hier passende producten." } 97 }; 98 function applyGenericStrings() { 99 for (var loc in GENERIC_I18N) { 100 if (!I18N[loc]) continue; 101 var o = GENERIC_I18N[loc]; 102 for (var k in o) I18N[loc][k] = o[k]; 103 } 104 } 105 106 107 function detectStoreCurrency() { 108 try { 109 if (window.Shopify && window.Shopify.currency && window.Shopify.currency.active) { 110 return window.Shopify.currency.active; 111 } 112 if (window.ShopifyAnalytics && window.ShopifyAnalytics.meta && window.ShopifyAnalytics.meta.currency) { 113 return window.ShopifyAnalytics.meta.currency; 114 } 115 var metaTag = document.querySelector('meta[property="og:price:currency"]'); 116 if (metaTag && metaTag.getAttribute("content")) { 117 return metaTag.getAttribute("content"); 118 } 119 var moneyEl = document.querySelector('[data-currency]'); 120 if (moneyEl && moneyEl.getAttribute("data-currency")) { 121 return moneyEl.getAttribute("data-currency"); 122 } 123 var priceEl = document.querySelector('.money[data-currency-code]'); 124 if (priceEl && priceEl.getAttribute("data-currency-code")) { 125 return priceEl.getAttribute("data-currency-code"); 126 } 127 } catch(e) {} 128 return ""; 129 } 130 131 function reDetectCurrency() { 132 var fresh = detectStoreCurrency(); 133 if (fresh && fresh.length === 3) { 134 detectedCurrency = fresh; 135 } 136 } 137 138 function generateSessionId() { 139 var chars = "abcdefghijklmnopqrstuvwxyz0123456789"; 140 var id = ""; 141 for (var i = 0; i < 32; i++) { 142 id += chars.charAt(Math.floor(Math.random() * chars.length)); 143 } 144 return id; 145 } 146 147 var SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours 148 149 function getSessionId() { 150 var idKey = "sg_session_" + shopDomain; 151 var tsKey = "sg_session_ts_" + shopDomain; 152 try { 153 var stored = localStorage.getItem(idKey); 154 var storedTs = parseInt(localStorage.getItem(tsKey) || "0", 10); 155 var age = Date.now() - storedTs; 156 // Reuse session if it exists and is less than 24 hours old 157 if (stored && stored.length >= 8 && storedTs && age < SESSION_TTL_MS) { 158 return stored; 159 } 160 // Expired or missing â clear old data and start fresh 161 if (stored) { 162 localStorage.removeItem(idKey); 163 localStorage.removeItem(tsKey); 164 } 165 } catch(e) {} 166 var newId = generateSessionId(); 167 try { 168 localStorage.setItem(idKey, newId); 169 localStorage.setItem(tsKey, String(Date.now())); 170 } catch(e) {} 171 return newId; 172 } 173 174 function refreshSessionTimestamp() { 175 try { 176 localStorage.setItem("sg_session_ts_" + shopDomain, String(Date.now())); 177 } catch(e) {} 178 } 179 180 function parseUrlParams(url) { 181 var params = {}; 182 var queryString = url.split("?")[1]; 183 if (!queryString) return params; 184 var pairs = queryString.split("&"); 185 for (var i = 0; i < pairs.length; i++) { 186 var pair = pairs[i].split("="); 187 params[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || ""); 188 } 189 return params; 190 } 191 192 function hexToRgb(hex) { 193 hex = hex.replace("#", ""); 194 if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; 195 var r = parseInt(hex.substring(0,2), 16); 196 var g = parseInt(hex.substring(2,4), 16); 197 var b = parseInt(hex.substring(4,6), 16); 198 return r + ", " + g + ", " + b; 199 } 200 201 // Lighten (positive pct) or darken (negative) a hex colour â used to build 202 // the bell's gradient from the merchant's chosen colour. 203 function shadeHex(hex, pct) { 204 hex = hex.replace("#", ""); 205 if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; 206 var out = "#"; 207 for (var i = 0; i < 3; i++) { 208 var v = parseInt(hex.substring(i*2, i*2+2), 16); 209 v = Math.round(pct > 0 ? v + (255 - v) * (pct/100) : v * (1 + pct/100)); 210 out += ("0" + Math.max(0, Math.min(255, v)).toString(16)).slice(-2); 211 } 212 return out; 213 } 214 215 function luminance(hex) { 216 hex = hex.replace("#", ""); 217 if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
218 var r = parseInt(hex.substring(0,2), 16) / 255; 219 var g = parseInt(hex.substring(2,4), 16) / 255; 220 var b = parseInt(hex.substring(4,6), 16) / 255; 221 return 0.299 * r + 0.587 * g + 0.114 * b; 222 } 223 224 function isLightColor(hex) { 225 return luminance(hex) > 0.6; 226 } 227 228 // Form surfaces (email capture, message form) follow the brand colour: a 229 // faint tint of primaryColor rather than the old hardcoded cream, so a 230 // black/grey brand gets grey forms while brass keeps its warm look. 231 function sgFormTint() { 232 var rgbStr = hexToRgb(config.primaryColor || "#C6A85B"); 233 return "background:linear-gradient(135deg, rgba(" + rgbStr + ",0.08), rgba(" + rgbStr + ",0.03));border:1px solid rgba(" + rgbStr + ",0.25);"; 234 } 235 236 function apiUrl(path) { 237 if (PROXY_PATH) { 238 return PROXY_PATH + path; 239 } 240 return API_BASE + "/api/widget" + path; 241 } 242 243 function audioApiUrl(path) { 244 if (PROXY_PATH) { 245 return PROXY_PATH + path; 246 } 247 return API_BASE + path; 248 } 249 250 // Once anything is added via the stylist, stamp the cart with the session id 251 // (hidden "_sg_session" attribute). Shopify carries cart attributes onto the 252 // order, where the orders/create webhook turns them into attributed revenue. 253 var cartAttributed = false; 254 function markCartAttributed() { 255 if (cartAttributed) return; 256 if (!sessionId || sessionId.length < 8) return; 257 cartAttributed = true; 258 try { 259 var xhr = new XMLHttpRequest(); 260 xhr.open("POST", "/cart/update.js"); 261 xhr.setRequestHeader("Content-Type", "application/json"); 262 xhr.send(JSON.stringify({ attributes: { _sg_session: sessionId } })); 263 } catch (e) { cartAttributed = false; } 264 } 265 266 // ââ Live merchant replies ââââââââââââââââââââââââââââââââââââââââââââââ 267 // After a handoff, poll for the store's reply and drop it into the chat as 268 // a distinct "store team" bubble. The email copy is the guaranteed channel; 269 // this is the delight when the shopper is still browsing. 270 var replyPollTimer = null; 271 function startReplyPolling() { 272 try { sessionStorage.setItem("sg-await-reply", "1"); } catch (e) {} 273 if (replyPollTimer) return; 274 replyPollTimer = setInterval(pollForReplies, 15000); 275 setTimeout(pollForReplies, 4000); 276 } 277 function stopReplyPolling() { 278 if (replyPollTimer) { clearInterval(replyPollTimer); replyPollTimer = null; } 279 try { sessionStorage.removeItem("sg-await-reply"); } catch (e) {} 280 } 281 function pollForReplies() { 282 if (!sessionId || sessionId.length < 8) return; 283 try { 284 var xhr = new XMLHttpRequest(); 285 xhr.open("GET", apiUrl("/replies?shop=" + encodeURIComponent(shopDomain) + "&sessionId=" + encodeURIComponent(sessionId))); 286 xhr.onreadystatechange = function () { 287 if (xhr.readyState !== 4 || xhr.status !== 200) return; 288 var data; try { data = JSON.parse(xhr.responseText); } catch (e) { return; } 289 var replies = (data && data.replies) || []; 290 if (!replies.length) return; 291 stopReplyPolling(); 292 var container = document.getElementById("sg-messages"); 293 for (var i = 0; i < replies.length; i++) { 294 messages.push({ role: "assistant", content: replies[i].message, storeReply: true }); 295 } 296 if (container) { renderMessages(); } 297 try { if (!isModalOpen) { var fabEl = document.getElementById("sg-fab"); if (fabEl) { var badge = fabEl.querySelector(".sg-fab-badge"); if (badge) badge.style.display = "block"; } } } catch (e) {} 298 }; 299 xhr.send(); 300 } catch (e) {} 301 } 302 303 function trackEvent(type, payload) { 304 try { 305 var url = apiUrl("/events"); 306 var body = { type: type }; 307 if (sessionId && sessionId.length >= 8) body.anonId = sessionId; 308 if (payload) body.payload = payload; 309 var xhr = new XMLHttpRequest(); 310 xhr.open("POST", url); 311 xhr.setRequestHeader("Content-Type", "application/json"); 312 xhr.send(JSON.stringify(body)); 313 } catch(e) {} 314 } 315 316 function init() { 317 var scriptTag = null; 318 319 // Highest priority: a script tag loaded from our server (set by bootstrap v2.0) 320 var ourScripts = document.querySelectorAll("script[src*='style-guru.replit.app']"); 321 if (ourScripts.length > 0) { 322 scriptTag = ourScripts[ourScripts.length - 1]; 323 } 324 325 // Fallback: explicit ID tag or current script 326 if (!scriptTag) { 327 scriptTag = document.getElementById("style-guru-script") || document.currentScript; 328 } 329 330 // Last resort: any widget.js script tag 331 if (!scriptTag) { 332 var scripts = document.querySelectorAll("script[src*='widget.js']"); 333 if (scripts.length > 0) { 334 scriptTag = scripts[scripts.length - 1]; 335 } 336 } 337 338 if (scriptTag) { 339 var srcUrl = scriptTag.src || "";
340 var urlParams = parseUrlParams(srcUrl); 341 var explicitApi = scriptTag.getAttribute("data-api") || urlParams.api || ""; 342 343 shopDomain = scriptTag.getAttribute("data-shop") || urlParams.shop || ""; 344 devCustomerId = scriptTag.getAttribute("data-customer-id") || urlParams.customer || ""; 345 API_BASE = explicitApi || srcUrl.replace(/\/widget\.js.*$/, ""); 346 347 if (explicitApi && /^https?:\/\//.test(explicitApi)) { 348 PROXY_PATH = ""; 349 } else if (srcUrl.includes("style-guru.replit.app")) { 350 PROXY_PATH = ""; 351 API_BASE = "https://style-guru.replit.app"; 352 } else if (window.Shopify && window.Shopify.shop) { 353 PROXY_PATH = "/apps/style-guru"; 354 } 355 } else { 356 if (window.Shopify && window.Shopify.shop) { 357 PROXY_PATH = "/apps/style-guru"; 358 } 359 } 360 361 if (!shopDomain) return; 362 363 if (!PROXY_PATH && !API_BASE) return; 364 365 console.log("[StyleGuru] Widget v" + WIDGET_VERSION + " loaded | shop=" + shopDomain + " | " + (PROXY_PATH ? "proxy=" + PROXY_PATH : "api=" + API_BASE)); 366 367 sessionId = getSessionId(); 368 detectedCurrency = detectStoreCurrency(); 369 370 if (!detectedCurrency) { 371 var currencyRetries = 0; 372 var currencyInterval = setInterval(function() { 373 reDetectCurrency(); 374 currencyRetries++; 375 if (detectedCurrency || currencyRetries >= 10) clearInterval(currencyInterval); 376 }, 1000); 377 } 378 379 loadSettings(function() { 380 applyContainerOverrides(); 381 injectStyles(); 382 383 // Page-URL override for testing activation modes without touching the 384 // merchant's saved setting (?sg-mode=top_button etc.). 385 try { 386 var sgModeOverride = new URLSearchParams(window.location.search).get("sg-mode"); 387 if (sgModeOverride) config.activationMode = sgModeOverride; 388 } catch (e) {} 389 var mode = config.activationMode || "both"; 390 var showBell = mode === "bell_icon" || mode === "both" || mode === "top_button_bell"; 391 var useSearch = mode === "search_trigger" || mode === "both"; 392 var showTopButton = mode === "top_button" || mode === "top_button_bell"; 393 if (showTopButton) renderTopButton(); 394 395 if (useSearch) { 396 hookSearchBox(); 397 initSearchTrigger(); 398 } 399 if (showBell || useSearch) { 400 createFAB(); 401 } 402 403 fetchWelcomeAudio(); 404 restoreConversation(); 405 }); 406 } 407 408 function applyContainerOverrides() { 409 var container = document.getElementById("style-guru-container"); 410 if (!container) return; 411 412 var map = { 413 "primary-color": "primaryColor", 414 "font-color": "fontColor", 415 "background-color": "backgroundColor", 416 "border-color": "borderColor", 417 "font-size": "fontSize", 418 "border-radius": "borderRadius", 419 "border-width": "borderWidth", 420 "widget-height": "widgetHeight", 421 "widget-width": "widgetWidth", 422 "widget-padding": "widgetPadding", 423 "box-shadow": "boxShadow", 424 "stylist-name": "stylistName", 425 "bell-position": "bellPosition", 426 "activation-mode": "activationMode", 427 "top-button-label": "topButtonLabel", 428 "top-button-color": "topButtonColor" 429 }; 430 431 for (var attr in map) { 432 var val = container.getAttribute("data-" + attr); 433 if (val && val.trim() !== "") { 434 config[map[attr]] = val.trim(); 435 } 436 } 437 } 438 439 function loadSettings(callback) { 440 if (!shopDomain) { callback(); return; } 441 var xhr = new XMLHttpRequest(); 442 xhr.open("GET", apiUrl("/settings?shop=" + encodeURIComponent(shopDomain))); 443 xhr.onreadystatechange = function() { 444 if (xhr.readyState === 4) { 445 if (xhr.status === 200) { 446 try { 447 var data = JSON.parse(xhr.responseText); 448 if (data.primaryColor) config.primaryColor = data.primaryColor; 449 if (data.fontColor) config.fontColor = data.fontColor; 450 if (data.fontSize) config.fontSize = data.fontSize; 451 if (data.backgroundColor) config.backgroundColor = data.backgroundColor; 452 if (data.borderRadius) config.borderRadius = data.borderRadius; 453 if (data.borderColor) config.borderColor = data.borderColor; 454 if (data.borderWidth) config.borderWidth = data.borderWidth; 455 if (data.widgetHeight) config.widgetHeight = data.widgetHeight; 456 if (data.widgetWidth) config.widgetWidth = data.widgetWidth; 457 if (data.widgetPadding) config.widgetPadding = data.widgetPadding; 458 if (data.boxShadow) config.boxShadow = data.boxShadow; 459 if (data.stylistName) config.stylistName = data.stylistName; 460 if (data.welcomeMessage) config.welcomeMessage = data.welcomeMessage; 461 if (data.interactionMode) config.interactionMode = data.interactionMode; 462 if (data.activationMode) config.activationMode = data.activationMode; 463 if (data.topButtonLabel !== undefined && data.topButtonLabel !== null) config.topButtonLabel = data.topButtonLabel; 464 if (data.topButtonColor) config.topButtonColor = data.topButtonColor; 465 if (data.bellColor) config.bellColor = data.bellColor; 466 if (data.catalogLanding) config.catalogLanding = data.catalogLanding; 467 if (data.bellReRing !== undefined) config.bellReRing = data.bellReRing; 468 if (data.bellOpenChime !== undefined) config.bellOpenChime = data.bellOpenChime; 469 if (data.sizingTeaser !== undefined) config.sizingTeaser = data.sizingTeaser; 470 if (data.bellPosition) config.bellPosition = data.bellPosition; 471 if (data.hideBranding !== undefined) config.hideBranding = data.hideBranding; 472 if (data.emailCapture !== undefined) config.emailCapture = data.emailCapture; 473 if (data.whatsappCapture !== undefined) config.whatsappCapture = data.whatsappCapture; 474 if (data.leadGate !== undefined) config.leadGate = data.leadGate; 475 if (data.prechatSatisfied !== undefined) config.prechatSatisfied = data.prechatSatisfied; 476 if (data.serverVoice !== undefined) config.serverVoice = data.serverVoice; 477 if (data.fitcoreEnabled !== undefined) config.fitcoreEnabled = data.fitcoreEnabled; 478 if (data.fitcoreEmbedUrl) config.fitcoreEmbedUrl = data.fitcoreEmbedUrl; 479 if (data.storeVertical) config.storeVertical = data.storeVertical; 480 if (data.speechEngine !== undefined) config.speechEngine = sgResolveVoiceMode(d
480ata.speechEngine); 481 if (config.storeVertical && config.storeVertical !== "fashion") applyGenericStrings(); 482 if (data.enabled === false) return; 483 } catch(e) {} 484 } 485 callback(); 486 } 487 }; 488 xhr.send(); 489 } 490 491 // Voice preview mode: server sends speechEngine "live" | "preview" | false. 492 // "preview" means voice is fully wired for this shop but must stay invisible 493 // to shoppers â it only switches on in a browser where the merchant has set 494 // the local preview flag via ?sg_voice_preview=1 (?sg_voice_preview=0 clears). 495 function sgResolveVoiceMode(v) { 496 if (v !== "preview") return v; // "live", true, false pass through 497 try { 498 var m = (window.location.search || "").match(/[?&]sg_voice_preview=(1|0)/); 499 if (m) { 500 if (m[1] === "1") localStorage.setItem("sgVoicePreview", "1"); 501 else localStorage.removeItem("sgVoicePreview"); 502 } 503 return localStorage.getItem("sgVoicePreview") === "1" ? "preview" : false; 504 } catch (e) { return false; } 505 } 506 507 function getThemeFont() { 508 var body = document.body; 509 if (!body) return "'Inter', system-ui, -apple-system, sans-serif"; 510 var computed = window.getComputedStyle(body); 511 return computed.fontFamily || "'Inter', system-ui, -apple-system, sans-serif"; 512 } 513 514 function escapeHtml(str) { 515 if (!str) return ""; 516 return String(str) 517 .replace(/&/g, "&") 518 .replace(/</g, "<") 519 .replace(/>/g, ">") 520 .replace(/"/g, """) 521 .replace(/'/g, "'"); 522 } 523 524 function formatMessageText(str) { 525 if (!str) return ""; 526 var escaped = escapeHtml(str); 527 escaped = escaped.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" style="color:' + config.primaryColor + ';text-decoration:underline;">$1</a>'); 528 escaped = escaped.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); 529 escaped = escaped.replace(/\n/g, '<br>'); 530 return escaped; 531 } 532 533 var PRICE_SYMBOLS = { GBP:"£", USD:"$", EUR:"â¬", JPY:"Â¥", AUD:"A$", CAD:"C$", 534 CHF:"CHF ", CNY:"Â¥", SEK:"kr", NZD:"NZ$", MXN:"MX$", SGD:"S$", HKD:"HK$", 535 NOK:"kr", KRW:"â©", INR:"â¹", BRL:"R$", ZAR:"R", AED:"AED " }; 536 537 function formatDisplayPrice(val, currencyCode) { 538 if (val === null || val === undefined || val === "") return ""; 539 var str = String(val).trim(); 540 if (!str || str === "0" || str === "0.00") return ""; 541 // Already has a recognised currency symbol â return as-is 542 if (/^[£$â¬Â¥â©â¹âºâ½à¸¿]/.test(str) || /^[A-Z]{2,4}\s*[\d]/.test(str)) return str; 543 var num = parseFloat(str.replace(/[^0-9.]/g, "")); 544 if (isNaN(num) || num === 0) return ""; 545 var code = ((currencyCode || config.currency || detectedCurrency || "GBP") + "").toUpperCase(); 546 var sym = PRICE_SYMBOLS[code] || (code + " "); 547 var noDecimal = ["JPY","KRW","HUF","CLP","VND","ISK"].indexOf(code) >= 0; 548 return sym + (noDecimal ? Math.round(num) : num.toFixed(2)); 549 } 550 551 function sparklesIcon() { 552 return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l1.5 5.5L19 10l-5.5 1.5L12 17l-1.5-5.5L5 10l5.5-1.5L12 3z"/><path d="M19 15l.5 2L22 17.5l-2.5.5L19 20l-.5-2L16 17.5l2.5-.5L19 15z"/></svg>'; 553 } 554 555 function sendIcon(color) { 556 var c = color || "currentColor"; 557 return '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" style="display:block"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" fill="' + c + '"/></svg>'; 558 } 559 560 function closeIcon(color) { 561 var c = color || "#555"; 562 // Use <path> not <line> â Shopify theme CSS sometimes resets <line> display/stroke 563 return '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="18" height="18" style="display:block;overflow:visible" fill="none" stroke="' + c + '" stroke-width="2.5" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>'; 564 } 565 566 function micIcon() { 567 // Inline width/height (attribute + style) so no store stylesheet can 568 // collapse it; stroke follows the button state colour. 569 return '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;width:18px;height:18px;min-width:18px;min-height:18px;flex-shrink:0"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>'; 570 } 571 572 function speakerIcon(color) { 573 var c = color || "currentColor"; 574 return '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" style="display:block"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" fill="' + c + '"/></svg>'; 575 } 576 577 function speakerMutedIcon(color) { 578 var c = color || "currentColor";
579 return '<svg width="16" height="16" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" style="display:block;width:16px;height:16px"><path d="M3 9v6h4l5 5V4L7 9H3z" fill="' + c + '"/><line x1="16" y1="9" x2="22" y2="15" stroke="' + c + '" stroke-width="2" stroke-linecap="round"/><line x1="22" y1="9" x2="16" y2="15" stroke="' + c + '" stroke-width="2" stroke-linecap="round"/></svg>'; 580 } 581 582 function searchIcon() { 583 return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'; 584 } 585 586 function bellIcon() { 587 return '<svg viewBox="1.75 0.75 20.5 22.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>'; 588 } 589 590 function mailIcon(color) { 591 var c = color || "currentColor"; 592 return '<svg viewBox="0 0 24 24" fill="none" stroke="' + c + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>'; 593 } 594 595 function rulerIcon(color) { 596 var c = color || "currentColor"; 597 return '<svg viewBox="0 0 24 24" fill="none" stroke="' + c + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/><path d="m14.5 12.5 2-2"/><path d="m11.5 9.5 2-2"/><path d="m8.5 6.5 2-2"/><path d="m17.5 15.5 2-2"/></svg>'; 598 } 599 600 // "Add to Bag" on desktop, plain "Add" on narrow screens (CSS swaps them â 601 // the full label wraps and misaligns inside mobile product cards). 602 function cartLabel() { 603 return '<span class="sg-cart-full">' + T("addToBag") + '</span><span class="sg-cart-short">' + T("add") + '</span>'; 604 } 605 606 function cartIcon(color) { 607 var c = color || "#fff"; 608 return '<svg viewBox="0 0 24 24" style="display:inline-block;vertical-align:middle" fill="none" stroke="' + c + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>'; 609 } 610 611 function checkIcon(color) { 612 var c = color || "#fff"; 613 return '<svg viewBox="0 0 24 24" style="display:inline-block;vertical-align:middle" fill="none" stroke="' + c + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; 614 } 615 616 function externalIcon(color) { 617 var c = color || "#555"; 618 return '<svg viewBox="0 0 24 24" style="display:inline-block;vertical-align:middle" fill="none" stroke="' + c + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>'; 619 } 620 621 function hasSpeechRecognition() { 622 return !!(window.SpeechRecognition || window.webkitSpeechRecognition); 623 } 624 625 function injectStyles() { 626 var existing = document.getElementById("style-guru-styles"); 627 if (existing) existing.remove(); 628 var style = document.createElement("style"); 629 style.id = "style-guru-styles"; 630 631 var pc = config.primaryColor; 632 var rgb = hexToRgb(pc); 633 var addedFg = isLightColor(pc) ? "#111" : "#fff"; 634 var bellBg = config.bellColor || pc; 635 var bellFg = isLightColor(bellBg) ? "#111" : "#fff"; 636 var bellRgb = hexToRgb(bellBg); 637 var fc = config.fontColor; 638 var bg = config.backgroundColor; 639 var bc = config.borderColor; 640 var fs = parseInt(config.fontSize) || 14; 641 var themeFont = getThemeFont(); 642 var primaryOnBg = isLightColor(pc) ? "#111" : "#fff"; 643 var bgDark = isLightColor(bg); 644 var surfaceBg = bgDark ? "rgba(0,0,0,0.02)" : "rgba(255,255,255,0.05)"; 645 var surfaceBorder = bgDark ? "rgba(0,0,0,0.08)" : "rgba(255,255,255,0.1)"; 646 var textSec = bgDark ? "rgba(0,0,0,0.55)" : "rgba(255,255,255,0.6)"; 647 var textTert = bgDark ? "rgba(0,0,0,0.38)" : "rgba(255,255,255,0.4)"; 648 649 style.textContent = '\ 650 .sg-fab {\ 651 position: fixed !important; z-index: 2147483646 !important;\ 652 width: 64px; height: 64px; border-radius: 50%;\ 653 background: ' + bellBg + '; color: ' + bellFg + ';\ 654 border: none; cursor: pointer;\ 655 display: flex; align-items: center; justify-content: center;\ 656 box-shadow: 0 4px 16px rgba(0,0,0,0.18), 0 2px 4px rgba(0,0,0,0.1);\ 657 transition: transform 0.2s ease, box-shadow 0.2s ease;\ 658 font-family: ' + themeFont + ';\ 659 }\ 660 .sg-fab.sg-bell {\ 661 background: linear-gradient(135deg, ' + bellBg + ' 0%, ' + shadeHex(bellBg, 14) + ' 50%, ' + shadeHex(bellBg, -16) + ' 100%);\ 662 color: ' + bellFg + ';\ 663 box-shadow: 0 4px 16px rgba(' + bellRgb + ',0.35), 0 2px 4px rgba(0,0,0,0.1);\ 664 }\ 665 .sg-fab:hover { transform: scale(1.06); box-shadow: 0 6px 24px rgba(0,0,0,0.22); }\ 666 .sg-fab.sg-bell:hover {\ 667 box-shadow: 0 6px 24px rgba(' + bellRgb + ',0.45), 0 2px 8px rgba(0,0,0,0.15);\ 668 }\ 669 .sg-fab.sg-bell:hover svg { animation: sg-bell-ring 0.6s ease; }\ 670 .sg-fab.sg-fab-waiting { opacity: 0; transform: scale(0.5); pointer-events: none; }\ 671 .sg-fab.sg-fab-enter { animation: sg-fab-enter 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both; }\ 672 .sg-fab.sg-fab-enter svg { animation: sg-bell-ring 0.7s ease 0.45s; }\ 673 @keyframes sg-fab-enter {\ 674 from { opacity: 0; transform: scale(0.5) translateY(14px); }\ 675 to { opacity: 1; transform: scale(1) translateY(0); }\ 676 }\ 677 .sg-fab.sg-fab-fade-enter { animation: sg-fab-fade-enter 0.5s ease both; }\ 678 .sg-fab.sg-fab-rering svg { animation: sg-bell-ring 0.7s ease; }\ 679 @keyframes sg-fab-fade-enter { from { opacity: 0; } to { opacity: 1; } }\ 680 @keyframes sg-bell-ring {\ 681 0% { transform: rotate(0); }\ 682 15% { transform: rotate(14deg); }\ 683 30% { transform: rotate(-12deg); }\ 684 45% { transform: rotate(10deg); }\ 685 60% { transform: rotate(-8deg); }\ 686 75% { transform: rotate(4deg); }\ 687 100% { transform: rotate(0); }\ 688 }\ 689 .sg-fab svg { width: 52px !important; height: 52px !important; max-width: none !important; max-height: none !important; display: block !important; flex-shrink: 0 !important; }\ 690 .sg-fab-badge {\ 691 position: absolute; top: -2px; right: -2px;\ 692 width: 14px; height: 14px; border-radius: 50%;\ 693 background: #22c55e; border: 2px solid #fff;\ 694 }\ 695 \ 696 .sg-overlay {\ 697 position: fixed !important; top: 0 !important; left: 0 !important;\ 698 right: 0 !important; bottom: 0 !important;\ 699 width: 100vw !important; height: 100vh !important; height: 100dvh !important;\ 700 z-index: 2147483647 !important;\ 701 background: rgba(0,0,0,0.5) !important;\ 702 backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);\ 703 display: none !important; align-items: center; justify-content: center;\ 704 opacity: 0; transition: opacity 0.25s ease;\ 705 font-family: ' + themeFont + ';\ 706 }\ 707 .sg-overlay.sg-visible { display: flex !important; opacity: 1; }\ 708 \ 709 .sg-modal {\ 710 width: 94vw; max-width: 860px;\ 711 height: 88vh; max-height: 720px;\ 712 background: ' + bg + ';\ 713 border-radius: 16px;\ 714 display: flex; flex-direction: column;\ 715 box-shadow: 0 24px 80px rgba(0,0,0,0.25);\ 716 overflow: hidden;\ 717 color: ' + fc + ';\ 718 font-size: ' + fs + 'px;\ 719 line-height: 1.5;\ 720 transform: translateY(20px) scale(0.97);\ 721 transition: transform 0.3s cubic-bezier(0.22,1,0.36,1);\ 722 }\ 723 .sg-overlay.sg-visible .sg-modal {\ 724 transform: translateY(0) scale(1);\ 725 }\ 726 \ 727 .sg-modal-header {\ 728 padding: 16px 20px;\ 729 display: flex; align-items: center; gap: 12px;\ 730 border-bottom: 1px solid ' + surfaceBorder + ';\ 731 flex-shrink: 0;\ 732 }\ 733 .sg-modal-logo {\ 734 width: 48px; height: 48px; border-radius: 12px;\ 735 background: rgba(' + rgb + ', 0.1);\ 736 display: flex; align-items: center; justify-content: center;\ 737 color: ' + pc + '; flex-shrink: 0;\ 738 }\ 739 .sg-modal-logo svg { width: 26px;
739 height: 26px; }\ 740 .sg-modal-header-info { flex: 1; min-width: 0; }\ 741 .sg-modal-header-title { font-weight: 600; font-size: ' + (fs + 2) + 'px; }\ 742 .sg-modal-header-sub { font-size: ' + (fs - 2) + 'px; color: ' + textSec + '; }\ 743 .sg-modal-header-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }\ 744 .sg-hdr-btn {\ 745 width: 34px; height: 34px; border-radius: 8px;\ 746 background: ' + surfaceBg + '; border: 1px solid ' + surfaceBorder + '; cursor: pointer;\ 747 display: flex; align-items: center; justify-content: center;\ 748 color: ' + fc + '; opacity: 1;\ 749 transition: background 0.15s, border-color 0.15s, transform 0.1s;\ 750 }\ 751 .sg-hdr-btn:hover { background: rgba(' + (isLightColor(bg) ? "0,0,0,0.08" : "255,255,255,0.12") + '); border-color: ' + pc + '; transform: scale(1.05); }\ 752 .sg-hdr-btn svg { width: 16px; height: 16px; }\ 753 .sg-hdr-btn.sg-active { opacity: 1; color: ' + pc + '; }\ 754 .sg-hdr-btn.sg-speaking { opacity: 1; color: ' + pc + '; animation: sg-speak-pulse 1s ease-in-out infinite; }\ 755 #sg-browse-btn { display: none; position: relative; }\ 756 #sg-live-btn.sg-live-active { opacity: 1; color: ' + pc + '; border-color: ' + pc + '; animation: sg-speak-pulse 1.2s ease-in-out infinite; }\ 757 .sg-hdr-badge {\ 758 position: absolute; top: -4px; right: -5px; min-width: 15px; height: 15px;\ 759 padding: 0 4px; border-radius: 8px; background: ' + pc + '; color: #fff;\ 760 font-size: 9.5px; font-weight: 700; line-height: 15px; text-align: center;\ 761 }\ 762 #sg-modal-close {\ 763 width: 32px; height: 32px; border-radius: 50%;\ 764 background: rgba(' + (isLightColor(bg) ? "0,0,0,0.10" : "255,255,255,0.15") + ');\ 765 border: 1.5px solid rgba(' + (isLightColor(bg) ? "0,0,0,0.22" : "255,255,255,0.3") + ');\ 766 color: ' + (isLightColor(bg) ? "#222" : "#eee") + ';\ 767 display: flex; align-items: center; justify-content: center;\ 768 flex-shrink: 0;\ 769 }\ 770 #sg-modal-close:hover {\ 771 background: rgba(' + (isLightColor(bg) ? "0,0,0,0.18" : "255,255,255,0.28") + ');\ 772 border-color: rgba(' + (isLightColor(bg) ? "0,0,0,0.36" : "255,255,255,0.5") + ');\ 773 transform: scale(1.1);\ 774 }\ 775 #sg-modal-close svg { width: 16px; height: 16px; display: block; overflow: visible; }\ 776 @keyframes sg-speak-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }\ 777 \ 778 .sg-modal-body {\ 779 flex: 1; display: flex; overflow: hidden; min-height: 0;\ 780 }\ 781 \ 782 .sg-chat-col {\ 783 width: 340px; min-width: 280px;\ 784 display: flex; flex-direction: column;\ 785 border-right: 1px solid ' + surfaceBorder + ';\ 786 flex-shrink: 0;\ 787 }\ 788 .sg-chat-messages {\ 789 flex: 1; overflow-y: auto; padding: 16px;\ 790 display: flex; flex-direction: column; gap: 10px;\ 791 }\ 792 .sg-chat-input-row {\ 793 padding: 12px 16px; border-top: 1px solid ' + surfaceBorder + ';\ 794 display: flex; gap: 8px; align-items: center; flex-shrink: 0;\ 795 }\ 796 .sg-chat-input {\ 797 flex: 1; border: 1px solid ' + surfaceBorder + '; border-radius: 10px;\ 798 padding: 10px 14px; font-size: ' + fs + 'px; font-family: inherit;\ 799 color: ' + fc + '; background: transparent; outline: none;\ 800 transition: border-color 0.15s;\ 801 }\ 802 .sg-chat-input:focus { border-color: ' + pc + '; }\ 803 .sg-chat-input::placeholder { color: ' + textTert + '; }\ 804 .sg-chat-send {\ 805 width: 40px; height: 40px; border-radius: 10px;\ 806 background: ' + pc + '; color: ' + primaryOnBg + ';\ 807 border: none; cursor: pointer;\ 808 display: flex; align-items: center; justify-content: center;\ 809 flex-shrink: 0; transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s;\ 810 box-shadow: 0 2px 6px rgba(0,0,0,0.15);\ 811 }\ 812 .sg-chat-send:hover:not(:disabled) { opacity: 0.88; transform: scale(1.05); box-shadow: 0 3px 10px rgba(0,0,0,0.2); }\ 813 .sg-chat-send:disabled { opacity: 0.45; filter: grayscale(60%); cursor: default; box-shadow: none; }\
814 .sg-chat-send svg { width: 20px; height: 20px; }\ 815 .sg-powered { text-align: center; padding: 0 0 8px; }\ 816 .sg-powered a { font-size: ' + (fs - 4) + 'px; color: ' + textTert + '; text-decoration: none; letter-spacing: 0.02em; }\ 817 .sg-powered a:hover { color: ' + textSec + '; }\ 818 .sg-powered strong { font-weight: 600; }\ 819 .sg-mic-btn {\ 820 width: 36px; height: 36px; border-radius: 10px;\ 821 background: transparent; border: 1px solid ' + surfaceBorder + ';\ 822 color: ' + fc + '; cursor: pointer;\ 823 display: flex; align-items: center; justify-content: center;\ 824 flex-shrink: 0; opacity: 0.6;\ 825 transition: opacity 0.15s, background 0.15s, border-color 0.15s;\ 826 }\ 827 .sg-mic-btn:hover { opacity: 1; border-color: ' + pc + '; color: ' + pc + '; }\ 828 .sg-mic-btn svg { width: 18px; height: 18px; }\ 829 .sg-mic-btn.sg-listening {\ 830 background: rgba(220,38,38,0.1); border-color: #dc2626; color: #dc2626; opacity: 1;\ 831 animation: sg-pulse 1.5s ease-in-out infinite;\ 832 }\ 833 @keyframes sg-pulse { 0%,100%{box-shadow:0 0 0 0 rgba(220,38,38,0.3)} 50%{box-shadow:0 0 0 6px rgba(220,38,38,0)} }\ 834 \ 835 .sg-msg { max-width: 90%; display: flex; flex-direction: column; gap: 4px; }\ 836 .sg-fb-row { display: flex; gap: 2px; align-items: center; margin-top: 2px; padding-left: 6px; }\ 837 .sg-fb-btn { background: none; border: none; cursor: pointer; opacity: 0.35; padding: 3px; line-height: 1; transition: opacity 0.15s, transform 0.15s; }\ 838 .sg-fb-btn:hover { opacity: 0.9; transform: scale(1.15); }\ 839 .sg-fb-thanks { font-size: 11px; opacity: 0.55; padding-left: 6px; margin-top: 2px; }\ 840 .sg-msg-user { align-self: flex-end; }\ 841 .sg-msg-assistant { align-self: flex-start; }\ 842 .sg-msg-bubble {\ 843 padding: 10px 14px; border-radius: 12px;\ 844 font-size: ' + fs + 'px; line-height: 1.55;\ 845 word-wrap: break-word; overflow-wrap: break-word;\ 846 }\ 847 .sg-msg-user .sg-msg-bubble {\ 848 background: ' + pc + '; color: ' + primaryOnBg + ';\ 849 border-bottom-right-radius: 4px;\ 850 }\ 851 .sg-msg-assistant .sg-msg-bubble {\ 852 background: ' + surfaceBg + '; color: ' + fc + ';\ 853 border: 1px solid ' + surfaceBorder + ';\ 854 border-bottom-left-radius: 4px;\ 855 }\ 856 .sg-typing { display: flex; gap: 4px; align-items: center; padding: 4px 0; }\ 857 .sg-typing-dot {\ 858 width: 5px; height: 5px; border-radius: 50%;\ 859 background: ' + textTert + ';\ 860 animation: sg-bounce 1.4s ease-in-out infinite;\ 861 }\ 862 .sg-typing-dot:nth-child(2) { animation-delay: 0.2s; }\ 863 .sg-typing-dot:nth-child(3) { animation-delay: 0.4s; }\ 864 .sg-typing-label { font-size: 12px; color: ' + textSec + '; margin-left: 4px; }\ 865 @keyframes sg-bounce { 0%,80%,100%{transform:translateY(0)} 40%{transform:translateY(-5px)} }\ 866 \ 867 .sg-welcome-section {\ 868 display: flex; flex-direction: column; align-items: center;\ 869 text-align: center; padding: 32px 20px 16px;\ 870 }\ 871 .sg-welcome-title { font-size: ' + (fs + 6) + 'px; font-weight: 700; margin-bottom: 4px; letter-spacing: 0.01em; }\ 872 .sg-welcome-sub { font-size: ' + (fs - 1) + 'px; color: ' + textSec + '; margin-bottom: 16px; }\ 873 .sg-suggestions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }\ 874 .sg-suggestion {\ 875 padding: 7px 14px; border: 1px solid ' + surfaceBorder + ';\ 876 border-radius: 20px; font-size: ' + (fs - 1) + 'px;\ 877 color: ' + fc + '; cursor: pointer;\ 878 background: transparent; font-family: inherit;\ 879 transition: border-color 0.15s, background 0.15s, color 0.15s;\ 880 }\ 881 .sg-suggestion:hover { border-color: ' + pc + '; background: rgba(' + rgb + ', 0.06); color: ' + pc + '; }\ 882 \ 883 .sg-products-col {\ 884 flex: 1; display: flex; flex-direction: column;\ 885 overflow: hidden; min-width: 0; min-height: 0;\ 886 position: relative;\ 887 }\ 888 .sg-products-header {\ 889 padding: 14px 20px; border-bottom: 1px solid ' + surfaceBorder + ';\ 890 font-weight: 600; font-size: ' + (fs + 1) + 'px;\ 891 display: flex; align-items: center; gap: 8px; flex-shrink: 0;\ 892 color: ' + fc + ';\ 893 }\ 894 .sg-products-header-count {\ 895 font-weight: 400; font-size: ' + (fs - 1) + 'px; color: ' + textSec + ';\ 896 }\ 897 .sg-filter-bar {\ 898 padding: 10px 14px 8px; border-bottom: 1px solid ' + surfaceBorder + ';\ 899 display: flex; flex-direction: column; gap: 7px; flex-shrink: 0;\ 900 background: ' + (isLightColor(bg) ? "rgba(0,0,0,0.018)" : "rgba(255,255,255,0.025)") + ';\ 901 }\ 902 .sg-filter-top-row {\ 903 display: flex; align-items: center; gap: 8px;\ 904 }\ 905 .sg-filter-label {\ 906 font-size: ' + (fs - 2) + 'px; font-weight: 600;\ 907 text-transform: uppercase; letter-spacing: 0.07em;\ 908 color: ' + textSec + '; white-space: nowrap; flex-shrink: 0;\ 909 }\ 910 .sg-filter-search-wrap {\ 911 position: relative; flex: 1;\ 912 }\ 913 .sg-filter-search-icon {\ 914 position: absolute; left: 9px; top: 50%; transform: translateY(-50%);\ 915 pointer-events: none; opacity: 0.45;\ 916 }\ 917 .sg-filter-search {\ 918 width: 100%; height: 30px;\ 919 border: 1px solid ' + surfaceBorder + '; border-radius: 15px;\ 920 background: ' + bg + '; color: ' + fc + ';\ 921 padding: 0 10px 0 28px; font-size: ' + (fs - 1) + 'px; font-family: inherit;\ 922 outline: none; box-sizing: border-box; transition: border-color 0.15s;\ 923 }\ 924 .sg-filter-search:focus { border-color: ' + pc + '; }\ 925 .sg-filter-search::placeholder { color: ' + textSec + '; }\ 926 .sg-filter-clear-btn {\
927 height: 26px; padding: 0 9px; border-radius: 13px;\ 928 border: 1px solid ' + surfaceBorder + '; background: transparent;\ 929 color: ' + textSec + '; font-size: ' + (fs - 2) + 'px; font-family: inherit;\ 930 cursor: pointer; white-space: nowrap; flex-shrink: 0;\ 931 display: none; transition: all 0.15s;\ 932 }\ 933 .sg-filter-clear-btn:hover { border-color: ' + pc + '; color: ' + pc + '; }\ 934 .sg-filter-clear-btn.sg-visible { display: block; }\ 935 .sg-filter-chips {\ 936 display: flex; align-items: center; gap: 5px;\ 937 overflow-x: auto; padding-bottom: 2px;\ 938 scrollbar-width: none;\ 939 }\ 940 .sg-filter-chips::-webkit-scrollbar { display: none; }\ 941 .sg-filter-select {\ 942 height: 26px !important; min-height: 26px !important; max-height: 26px !important;\ 943 line-height: 24px !important; vertical-align: middle !important;\ 944 width: auto !important; max-width: 140px !important; margin: 0 !important;\ 945 border: 1px solid ' + surfaceBorder + ' !important; border-radius: 13px !important;\ 946 background-color: ' + bg + ' !important; color: ' + fc + ' !important;\ 947 padding: 0 22px 0 10px !important; font-size: ' + (fs - 2) + 'px !important; font-family: inherit !important;\ 948 cursor: pointer; outline: none; flex-shrink: 0 !important; flex-grow: 0 !important;\ 949 appearance: none !important; -webkit-appearance: none !important;\ 950 background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27 width%3D%2710%27 height%3D%276%27 viewBox%3D%270 0 10 6%27%3E%3Cpath d%3D%27M1 1l4 4 4-4%27 stroke%3D%27%23999%27 stroke-width%3D%271.5%27 fill%3D%27none%27 stroke-linecap%3D%27round%27%2F%3E%3C%2Fsvg%3E") !important;\ 951 background-repeat: no-repeat !important; background-position: right 6px center !important;\ 952 transition: border-color 0.15s, color 0.15s; white-space: nowrap;\ 953 box-sizing: border-box !important;\ 954 }\ 955 .sg-filter-select:focus { border-color: ' + pc + '; }\ 956 .sg-filter-select.sg-filter-active {\ 957 border-color: ' + pc + '; background-color: rgba(' + rgb + ',0.08);\ 958 color: ' + pc + '; font-weight: 600;\ 959 }\ 960 .sg-filter-toggle {\ 961 height: 26px; padding: 0 9px; border-radius: 13px;\ 962 border: 1px solid ' + surfaceBorder + '; background: ' + bg + ';\ 963 color: ' + textSec + '; font-size: ' + (fs - 2) + 'px; font-family: inherit;\ 964 cursor: pointer; white-space: nowrap; flex-shrink: 0;\ 965 transition: all 0.15s; display: flex; align-items: center; gap: 4px;\ 966 }\ 967 .sg-filter-toggle.sg-filter-active {\ 968 border-color: ' + pc + '; background: rgba(' + rgb + ',0.08);\ 969 color: ' + pc + '; font-weight: 600;\ 970 }\ 971 .sg-filter-loading {\ 972 text-align: center; padding: 40px 16px;\ 973 color: ' + textSec + '; font-size: ' + (fs - 1) + 'px;\ 974 display: flex; flex-direction: column; align-items: center; gap: 12px;\ 975 }\ 976 .sg-filter-divider {\ 977 height: 1px; background: ' + surfaceBorder + '; margin: 0;\ 978 }\ 979 .sg-products-scroll {\ 980 flex: 1; overflow-y: auto; min-height: 0;\ 981 display: flex; flex-direction: column;\ 982 scrollbar-width: thin;\ 983 scrollbar-color: rgba(' + rgb + ',0.35) transparent;\ 984 }\ 985 .sg-products-scroll::-webkit-scrollbar { width: 6px; }\ 986 .sg-products-scroll::-webkit-scrollbar-track { background: transparent; }\ 987 .sg-products-scroll::-webkit-scrollbar-thumb {\ 988 background: rgba(' + rgb + ',0.35); border-radius: 6px;\ 989 }\ 990 .sg-products-scroll::-webkit-scrollbar-thumb:hover {\ 991 background: rgba(' + rgb + ',0.55);\ 992 }\ 993 .sg-products-grid {\ 994 padding: 16px 20px; flex-shrink: 0;\ 995 display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));\ 996 grid-auto-rows: max-content;\ 997 gap: 16px; align-content: start;\ 998 }\ 999 .sg-products-col-inner {\ 1000 flex: 1; display: flex; flex-direction: column; min-height: 0; position: relative;\ 1001 }\ 1002 .sg-products-fade-top, .sg-products-fade-bottom {\ 1003 position: absolute; left: 0; right: 8px; height: 32px;\ 1004 pointer-events: none; z-index: 1; opacity: 0;\ 1005 transition: opacity 0.2s;\ 1006 }\ 1007 .sg-products-fade-top {\ 1008 top: 0;\ 1009 background: linear-gradient(to bottom, ' + bg + ', transparent);\ 1010 }\ 1011 .sg-products-fade-bottom {\ 1012 bottom: 0;\ 1013 background: linear-gradient(to top, ' + bg + ', transparent);\ 1014 }\ 1015 .sg-products-fade-top.sg-fade-visible,\ 1016 .sg-products-fade-bottom.sg-fade-visible { opacity: 1; }\ 1017 .sg-product-card {\ 1018 border: 1px solid ' + surfaceBorder + '; border-radius: 12px;\ 1019 overflow: hidden; background: ' + bg + ';\ 1020 transition: border-color 0.2s, box-shadow 0.2s;\ 1021 display: flex; flex-direction: column;\ 1022 }\ 1023 .sg-product-card:hover {\ 1024 border-color: rgba(' + rgb + ', 0.3);\ 1025 box-shadow: 0 4px 16px rgba(0,0,0,0.06);\ 1026 }\ 1027 .sg-product-card > a {\ 1028 display: block; cursor: zoom-in;\ 1029 }\ 1030 .sg-product-img {\ 1031 width: 100%; aspect-ratio: 1/1; object-fit: cover;\ 1032 background: ' + surfaceBg + ';\ 1033 display: block;\ 1034 }\ 1035 .sg-img-lightbox {\ 1036 display: none; position: absolute; inset: 0; z-index: 10;\ 1037 background: rgba(0,0,0,0.93); flex-direction: column;\ 1038 border-radius: 16px;\ 1039 }\ 1040 .sg-img-lightbox.sg-lb-open { display: flex; }\ 1041 .sg-lb-topbar {\ 1042 flex-shrink: 0; display: flex; align-items: center;\ 1043 padding: 10px 14px; gap: 10px;\ 1044 border-bottom: 1px solid rgba(255,255,255,0.1);\ 1045 }\ 1046 .sg-lb-close {\ 1047 display: flex; align-items: center; gap: 6px;\ 1048 background: rgba(255,255,255,0.12); border: none;\ 1049 color: rgba(255,255,255,0.9); cursor: pointer;\ 1050 padding: 6px 12px; border-radius: 20px;\ 1051 font-size: ' + (fs - 1) + 'px; font-family: inherit;\ 1052 transition: background 0.15s; flex-shrink: 0;\ 1053 }\ 1054 .sg-lb-close:hover { background: rgba(255,255,255,0.22); }\ 1055 .sg-lb-topname {\ 1056 flex: 1; color: rgba(255,255,255,0.8);\ 1057 font-size: ' + (fs - 1) + 'px; font-weight: 500;\ 1058 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\ 1059 }\ 1060 .sg-lb-counter {\ 1061 flex-shrink: 0; color: rgba(255,255,255,0.5);\ 1062 font-size: ' + (fs - 2) + 'px;\ 1063 }\ 1064 .sg-lb-imgarea {\ 1065 flex: 1; display: flex; align-items: center; justify-content: center;\ 1066 position: relative; overflow: hidden; min-height: 0;\ 1067 }\ 1068 .sg-img-lightbox img {\ 1069 max-width: 90%; max-height: 100%; border-radius: 8px;\ 1070 object-fit: contain;\ 1071 }\ 1072 .sg-lb-arrow {\ 1073 position: absolute; top: 50%; transform: translateY(-50%);\ 1074 width: 40px; height: 40px; border-radius: 50%;\ 1075 background: rgba(255,255,255,0.16); border: none;\ 1076 color: #fff; cursor: pointer; font-size: 22px;\ 1077 display: flex; align-items: center; justify-content: center;\ 1078 transition: background 0.15s, opacity 0.15s;\ 1079 }\ 1080 .sg-lb-arrow:hover { background: rgba(255,255,255,0.30); }\ 1081 .sg-lb-arrow:disabled { opacity: 0.18; cursor: default; }\
1082 .sg-lb-prev { left: 10px; }\ 1083 .sg-lb-next { right: 10px; }\ 1084 .sg-product-img-placeholder {\ 1085 width: 100%; aspect-ratio: 1/1;\ 1086 background: ' + surfaceBg + ';\ 1087 display: flex; align-items: center; justify-content: center;\ 1088 color: ' + textTert + ';\ 1089 }\ 1090 .sg-product-img-placeholder svg { width: 32px; height: 32px; opacity: 0.4; }\ 1091 .sg-product-info { padding: 12px; display: flex; flex-direction: column; gap: 6px; flex: 1; }\ 1092 .sg-product-name {\ 1093 font-weight: 600; font-size: ' + fs + 'px;\ 1094 display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;\ 1095 }\ 1096 .sg-product-name a { color: ' + fc + '; text-decoration: none; }\ 1097 .sg-product-name a:hover { color: ' + pc + '; }\ 1098 .sg-product-price { font-size: ' + (fs + 1) + 'px; font-weight: 700; color: ' + fc + '; }\ 1099 .sg-product-variant {\ 1100 font-size: ' + (fs - 2) + 'px; color: ' + textSec + ';\ 1101 display: flex; align-items: center; gap: 4px;\ 1102 }\ 1103 .sg-product-variant-dot { width: 6px; height: 6px; border-radius: 50%; background: #22c55e; flex-shrink: 0; }\ 1104 .sg-variant-picker {\ 1105 padding: 0 12px 8px; display: flex; flex-direction: column; gap: 6px;\ 1106 }\ 1107 .sg-variant-select-group {\ 1108 display: flex; flex-direction: column; gap: 2px;\ 1109 }\ 1110 .sg-variant-select-label {\ 1111 font-size: ' + (fs - 3) + 'px; font-weight: 600; color: ' + textSec + ';\ 1112 text-transform: uppercase; letter-spacing: 0.5px;\ 1113 }\ 1114 .sg-variant-select {\ 1115 width: 100%; padding: 6px 28px 6px 10px; border-radius: 8px;\ 1116 border: 1px solid ' + surfaceBorder + ';\ 1117 background: ' + bg + '; color: ' + fc + ';\ 1118 font-size: ' + (fs - 1) + 'px; font-family: inherit;\ 1119 cursor: pointer; outline: none;\ 1120 -webkit-appearance: none; -moz-appearance: none; appearance: none;\ 1121 background-image: url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'12\' height=\'12\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'%23666\' stroke-width=\'2\'%3E%3Cpolyline points=\'6 9 12 15 18 9\'/%3E%3C/svg%3E");\ 1122 background-repeat: no-repeat; background-position: right 8px center;\ 1123 transition: border-color 0.15s;\ 1124 }\ 1125 .sg-variant-select:hover { border-color: ' + pc + '; }\ 1126 .sg-variant-select:focus { border-color: ' + pc + '; box-shadow: 0 0 0 2px rgba(' + rgb + ', 0.15); }\ 1127 .sg-needs-size { border-color: #dc2626 !important; animation: sg-size-pulse 1.2s ease-in-out 2; }\ 1128 @keyframes sg-size-pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(220,38,38,0.35); } 50% { box-shadow: 0 0 0 4px rgba(220,38,38,0.18); } }\ 1129 .sg-size-msg { font-size: 11px; color: #dc2626; margin-top: 4px; line-height: 1.4; }\ 1130 .sg-size-msg a { color: inherit; text-decoration: underline; cursor: pointer; font-weight: 600; }\ 1131 .sg-variant-static-value {\ 1132 font-size: ' + (fs - 1) + 'px; color: ' + fc + '; padding: 2px 0;\ 1133 }\ 1134 .sg-advisory-note {\ 1135 font-size: ' + (fs - 4) + 'px; color: ' + textSec + '; font-style: italic; margin-top: 2px;\ 1136 }\ 1137 .sg-advisory-select { border-style: dashed; }\ 1138 .sg-product-actions { padding: 0 12px 12px; display: flex; gap: 6px; }\ 1139 .sg-btn-cart {\ 1140 flex: 1; padding: 8px 12px; border-radius: 8px;\ 1141 background: ' + pc + '; color: ' + primaryOnBg + ';\ 1142 border: none; cursor: pointer;\ 1143 font-size: ' + (fs - 1) + 'px; font-weight: 600; font-family: inherit;\ 1144 display: flex; align-items: center; justify-content: center; gap: 6px;\ 1145 transition: opacity 0.15s;\ 1146 }\ 1147 .sg-btn-cart:hover { opacity: 0.9; }\ 1148 .sg-btn-cart:disabled { opacity: 0.5; cursor: default; }\ 1149 .sg-btn-cart svg { width: 14px; height: 14px; }\ 1150 .sg-btn-cart.sg-added { background: ' + pc + '; color: ' + addedFg + '; }\ 1151 .sg-btn-cart.sg-sold-out { background: #9ca3af; color: #fff; cursor: not-allowed; opacity: 0.7; }\ 1152 .sg-btn-view {\ 1153 padding: 8px 12px; border-radius: 8px;\ 1154 background: transparent; border: 1px solid ' + surfaceBorder + ';\ 1155 color: ' + fc + '; cursor: pointer;\ 1156 display: flex; align-items: center; justify-content: center; gap: 4px;\ 1157 flex-shrink: 0; text-decoration: none;\ 1158 font-size: ' + (fs - 1) + 'px; font-weight: 500;\ 1159 transition: border-color 0.15s, color 0.15s;\ 1160 white-space: nowrap;\ 1161 }\ 1162 .sg-btn-view:hover { border-color: ' + pc + '; color: ' + pc + '; }\ 1163 .sg-btn-view svg { width: 12px; height: 12px; }\ 1164 \ 1165 .sg-empty-products {\ 1166 display: flex; flex-direction: column; align-items: center;\ 1167 justify-content: center; padding: 48px 24px; text-align: center;\ 1168 color: ' + textSec + '; height: 100%;\ 1169 }\ 1170 .sg-empty-products svg { width: 48px; height: 48px; margin-bottom: 16px; opacity: 0.3; color: ' + pc + '; }\ 1171 .sg-empty-products h4 { font-size: ' + (fs + 1) + 'px; font-weight: 600; color: ' + fc + '; margin-bottom: 4px; }\ 1172 .sg-empty-products p { font-size: ' + (fs - 1) + 'px; max-width: 240px; }\ 1173 \ 1174 .sg-products-grid.sg-has-bundles {\ 1175 display: flex; flex-direction: column; gap: 12px;\ 1176 }\ 1177 .sg-bundle {\ 1178 border: 1px solid ' + surfaceBorder + '; border-radius: 14px;\ 1179 overflow: hidden; background: ' + bg + ';\ 1180 margin-bottom: 4px;\ 1181 }\ 1182 .sg-bundle-header {\ 1183 padding: 12px 16px; display: flex; align-items: center; justify-content: space-between;\ 1184 gap: 8px; flex-wrap: wrap; border-bottom: 1px solid ' + surfaceBorder + ';\ 1185 background: rgba(' + rgb + ', 0.04);\ 1186 }\ 1187 .sg-bundle-title {\ 1188 font-weight: 700; font-size: ' + fs + 'px; color: ' + fc + ';\ 1189 display: flex; align-items: center; gap: 6px;\ 1190 }\ 1191 .sg-bundle-title svg { width: 16px; height: 16px; color: ' + pc + '; }\ 1192 .sg-bundle-meta {\ 1193 display: flex; align-items: center; gap: 10px;\ 1194 }\ 1195 .sg-bundle-price {\ 1196 font-weight: 700; font-size: ' + (fs + 1) + 'px; color: ' + pc + ';\ 1197 }\ 1198 .sg-bundle-count {\ 1199 font-size: ' + (fs - 2) + 'px; color: ' + textSec + ';\ 1200 background: ' + surfaceBg + '; padding: 2px 8px; border-radius: 10px;\ 1201 }\ 1202 .sg-bundle-items {\ 1203 display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));\ 1204 grid-auto-rows: max-content;\ 1205 gap: 12px; padding: 12px 16px;\ 1206 }\ 1207 .sg-bundle-footer {\ 1208 padding: 10px 16px; border-top: 1px solid ' + surfaceBorder + ';\ 1209 display: flex; align-items: center; justify-content: center; gap: 8px;\ 1210 }\ 1211 .sg-btn-add-all {\ 1212 width: 100%; padding: 10px 16px; border-radius: 10px;\ 1213 background: ' + pc + '; color: ' + primaryOnBg + ';\ 1214 border: none; cursor: pointer;\ 1215 font-size: ' + fs + 'px; font-weight: 700; font-family: inherit;\ 1216 display: flex; align-items: center; justify-content: center; gap: 8px;\ 1217 transition: opacity 0.15s;\ 1218 }\ 1219 .sg-btn-add-all:hover { opacity: 0.9; }\ 1220 .sg-btn-add-all:disabled { opacity: 0.5; cursor: default; }\ 1221 .sg-btn-add-all svg { width: 16px; height: 16px; }\ 1222 .sg-btn-add-all.sg-added { background: ' + pc + '; color: ' + addedFg + '; }\ 1223 .sg-bundle-toast {\ 1224 padding: 8px 12px; margin: 0 16px 10px; border-radius: 8px;\ 1225 font-size: ' + (fs - 1) + 'px; display: flex; align-items: center; gap: 6px;\ 1226 animation: sg-toast-in 0.3s ease;\ 1227 }\ 1228 .sg-bundle-toast.sg-toast-success {\ 1229 background: rgba(' + rgb + ', 0.1); color: ' + pc + '; border: 1px solid rgba(' + rgb + ', 0.2);\ 1230 }\ 1231 .sg-bundle-toast.sg-toast-warn {\ 1232 background: rgba(234,179,8,0.1); color: #a16207; border: 1px solid rgba(234,179,8,0.2);\ 1233 }\ 1234 .sg-bundle-toast.sg-toast-error {\ 1235 background: rgba(239,68,68,0.1); color: #dc2626; border: 1px solid rgba(239,68,68,0.2);\ 1236 }\ 1237 .sg-bundle-toast svg { width: 14px; height: 14px; flex-shrink: 0; }\ 1238 @keyframes sg-toast-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }\ 1239 .sg-suit-summary {\ 1240 margin: 0 16px 10px; padding: 8px 12px;\ 1241 background: rgba(' + rgb + ', 0.06); border: 1px dashed rgba(' + rgb + ', 0.35);\ 1242 border-radius: 8px; font-size: ' + (fs - 2) + 'px; color: ' + pc + ';\ 1243 display: flex; flex-wrap: wrap; gap: 6px; align-items: center;\ 1244 animation: sg-toast-in 0.3s ease;\ 1245 }\ 1246 .sg-suit-summary-label {\ 1247 font-weight: 600; margin-right: 4px; flex-shrink: 0;\ 1248 }\ 1249 .sg-suit-summary-tag {\ 1250 background: rgba(' + rgb + ', 0.12); border-radius: 4px;\ 1251 padding: 1px 7px; font-size: ' + (fs - 3) + 'px; color: ' + pc + ';\ 1252 white-space: nowrap;\ 1253 }\ 1254 .sg-suit-summary-advisory {\ 1255 font-size: ' + (fs - 3) + 'px; color: #888; font-style: italic; flex-basis: 100%;\ 1256 margin-top: 2px;\ 1257 }\ 1258 \ 1259 /* ââ Complete Look zone âââââââââââââââââââââââââââââââââââââââââââââââââââ */\ 1260 .sg-complete-look-zone {\ 1261 margin: 0 0 0 0; border-bottom: 1px solid ' + surfaceBorder + ';\ 1262 background: linear-gradient(135deg, rgba(' + rgb + ',0.06) 0%, rgba(' + rgb + ',0.02) 100%);\ 1263 animation: sg-toast-in 0.3s ease;\ 1264 flex-shrink: 0;\ 1265 }\ 1266 .sg-cl-header {\ 1267 display: flex; align-items: center; justify-content: space-between;\ 1268 padding: 10px 14px 6px; gap: 8px;\ 1269 }\ 1270 .sg-cl-title {\ 1271 font-weight: 700; font-size: ' + fs + 'px; color: ' + fc + ';\ 1272 display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0;\ 1273 }\ 1274 .sg-cl-title svg { width: 15px; height: 15px; color: ' + pc + '; flex-shrink: 0; }\ 1275 .sg-cl-title-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\ 1276 .sg-cl-dismiss {\ 1277 background: none; border: none; cursor: pointer; color: ' + textSec + ';\ 1278 font-size: 18px; line-height: 1; padding: 0 2px; opacity: 0.6; flex-shrink: 0;\ 1279 transition: opacity 0.15s;\ 1280 }\ 1281 .sg-cl-dismiss:hover { opacity: 1; }\ 1282 .sg-cl-strip {\ 1283 display: flex; gap: 8px; overflow-x: auto; padding: 0 14px 8px;\ 1284 scrollbar-width: none;\ 1285 }\ 1286 .sg-cl-strip::-webkit-scrollbar { display: none; }\ 1287 .sg-cl-item {\ 1288 flex-shrink: 0; width: 72px; display: flex; flex-direction: column; gap: 4px;\ 1289 cursor: default;\ 1290 }\ 1291 .sg-cl-thumb-wrap {\ 1292 position: relative; width: 72px; height: 72px; border-radius: 10px;\ 1293 overflow: hidden; border: 1px solid ' + surfaceBorder + ';\ 1294 background: ' + surfaceBg + ';\ 1295 }\ 1296 .sg-cl-thumb {\ 1297 width: 100%; height: 100%; object-fit: cover; display: block;\ 1298 }\ 1299 .sg-cl-thumb-placeholder {\ 1300 width: 100%; height: 100%; background: rgba(' + rgb + ',0.08);\ 1301 display: flex; align-items: center; justify-content: center;\ 1302 }\ 1303 .sg-cl-swap-overlay {\ 1304 position: absolute; inset: 0; background: rgba(0,0,0,0.45);\ 1305 display: flex; align-items: center; justify-content: center;\ 1306 opacity: 0; transition: opacity 0.18s;\ 1307 }\ 1308 .sg-cl-thumb-wrap:hover .sg-cl-swap-overlay { opacity: 1; }\ 1309 .sg-cl-swap-btn {\ 1310 background: rgba(255,255,255,0.92); border: none; cursor: pointer;\ 1311 font-size: 10px; font-weight: 700; font-family: inherit;\ 1312 padding: 3px 7px; border-radius: 6px; color: #222;\ 1313 letter-spacing: 0.03em; transition: background 0.12s;\ 1314 }\ 1315 .sg-cl-swap-btn:hover { background: #fff; }\ 1316 .sg-cl-item-name {\ 1317 font-size: ' + (fs - 3) + 'px; color: ' + textSec + ';\ 1318 text-align: center; white-space: nowrap; overflow: hidden;\ 1319 text-overflow: ellipsis; line-height: 1.2;\ 1320 }\ 1321 .sg-cl-footer {\ 1322 display: flex; align-items: center; gap: 8px;\ 1323 padding: 0 14px 10px; justify-content: space-between;\ 1324 }\ 1325 .sg-cl-total {\ 1326 font-weight: 700; font-size: ' + (fs + 1) + 'px; color: ' + pc + ';\ 1327 }\ 1328 .sg-cl-add-btn {\ 1329 flex: 1; max-width: 220px; padding: 8px 14px; border-radius: 10px;\ 1330 background: ' + pc + '; color: ' + primaryOnBg + ';\ 1331 border: none; cursor: pointer; font-size: ' + (fs - 1) + 'px;\ 1332 font-weight: 700; font-family: inherit;\ 1333 display: flex; align-items: center; justify-content: center; gap: 7px;\ 1334 transition: opacity 0.15s;\ 1335 }\ 1336 .sg-cl-add-btn:hover { opacity: 0.88; }\ 1337 .sg-cl-add-btn:disabled { opacity: 0.5; cursor: default; }\ 1338 .sg-cl-add-btn.sg-added { background: ' + pc + '; color: ' + addedFg + '; }\ 1339 .sg-cl-add-btn svg { width: 14px; height: 14px; }\ 1340 .sg-cl-toast {\ 1341 margin: 0 14px 8px; padding: 6px 10px; border-radius: 8px;\ 1342 font-size: ' + (fs - 2) + 'px; display: flex; align-items: center; gap: 6px;\ 1343 animation: sg-toast-in 0.3s ease;\ 1344 }\ 1345 .sg-cl-toast.sg-toast-success { background: rgba(' + rgb + ', 0.1); color: ' + pc + '; border: 1px solid rgba(' + rgb + ', 0.2); }\ 1346 .sg-cl-toast.sg-toast-warn { background: rgba(234,179,8,0.1); color: #a16207; border: 1px solid rgba(234,179,8,0.2); }\ 1347 .sg-cl-toast.sg-toast-error { background: rgba(239,68,68,0.1); color: #dc2626; border: 1px solid rgba(239,68,68,0.2); }\ 1348 /* Mobile stacking â complete look zone below chat col on small screens */\ 1349 @media (max-width: 640px) {\ 1350 .sg-complete-look-zone { border-bottom: none; border-top: 1px solid ' + surfaceBorder + '; }\ 1351 }\ 1352 .sg-item-role {\ 1353 font-size: ' + (fs - 3) + 'px; text-transform: uppercase; letter-spacing: 0.05em;\ 1354 font-weight: 600; padding: 2px 6px; border-radius: 4px;\ 1355 background: rgba(' + rgb + ', 0.08); color: ' + pc + ';\ 1356 }\ 1357 .sg-inline-items {\ 1358 display: flex; flex-direction: column; gap: 8px; margin-top: 10px;\ 1359 }\ 1360 .sg-inline-item {\ 1361 display: flex; align-items: center; gap: 8px;\ 1362 padding: 8px; border-radius: 8px;\ 1363 border: 1px solid ' + surfaceBorder + ';\ 1364 background: ' + bg + ';\ 1365 }\ 1366 .sg-inline-item.sg-inline-slim {\ 1367 cursor: pointer; transition: border-color 0.15s, background 0.15s;\ 1368 }\ 1369 .sg-inline-item.sg-inline-slim:hover {\ 1370 border-color: ' + pc + '; background: rgba(' + rgb + ', 0.05);\ 1371 }\ 1372 .sg-inline-chevron {\ 1373 color: ' + textSec + '; font-size: 18px; line-height: 1;\ 1374 flex-shrink: 0; padding-right: 2px;\ 1375 }\
1376 .sg-inline-item.sg-inline-slim:hover .sg-inline-chevron { color: ' + pc + '; }\ 1377 .sg-inline-soldout {\ 1378 font-size: ' + (fs - 3) + 'px; font-weight: 600; color: #b45309;\ 1379 background: rgba(234,179,8,0.12); padding: 1px 6px; border-radius: 6px;\ 1380 margin-left: 4px; text-transform: uppercase; letter-spacing: 0.04em;\ 1381 }\ 1382 @keyframes sg-pane-flash-kf {\ 1383 0% { box-shadow: 0 0 0 3px rgba(' + rgb + ', 0.65); }\ 1384 100% { box-shadow: 0 0 0 3px rgba(' + rgb + ', 0); }\ 1385 }\ 1386 .sg-product-card.sg-pane-flash { animation: sg-pane-flash-kf 1.6s ease 2; }\ 1387 .sg-inline-item-img {\ 1388 width: 44px; height: 44px; border-radius: 6px; object-fit: cover; flex-shrink: 0;\ 1389 background: ' + surfaceBg + ';\ 1390 }\ 1391 .sg-inline-item-info {\ 1392 flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px;\ 1393 }\ 1394 .sg-inline-item-name {\ 1395 font-size: ' + (fs - 1) + 'px; font-weight: 600; color: ' + fc + ';\ 1396 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\ 1397 }\ 1398 .sg-inline-item-name a { color: ' + fc + '; text-decoration: none; }\ 1399 .sg-inline-item-name a:hover { color: ' + pc + '; }\ 1400 .sg-inline-item-price {\ 1401 font-size: ' + (fs - 2) + 'px; font-weight: 600; color: ' + textSec + ';\ 1402 }\ 1403 .sg-inline-variant-select {\ 1404 padding: 2px 4px; border-radius: 4px;\ 1405 border: 1px solid ' + surfaceBorder + ';\ 1406 background: ' + bg + '; color: ' + fc + ';\ 1407 font-size: ' + (fs - 3) + 'px; font-family: inherit;\ 1408 cursor: pointer; margin-top: 2px;\ 1409 max-width: 100%;\ 1410 }\ 1411 .sg-inline-add-btn {\ 1412 padding: 5px 10px; border-radius: 6px;\ 1413 background: ' + pc + '; color: ' + primaryOnBg + ';\ 1414 border: none; cursor: pointer;\ 1415 font-size: ' + (fs - 2) + 'px; font-weight: 600; font-family: inherit;\ 1416 display: flex; align-items: center; gap: 4px;\ 1417 white-space: nowrap; flex-shrink: 0;\ 1418 transition: opacity 0.15s;\ 1419 }\ 1420 .sg-inline-add-btn:hover { opacity: 0.85; }\ 1421 .sg-inline-add-btn:disabled { opacity: 0.5; cursor: default; }\ 1422 .sg-inline-add-btn svg { width: 12px; height: 12px; }\ 1423 .sg-inline-add-btn.sg-added { background: ' + pc + '; color: ' + addedFg + '; }\ 1424 .sg-inline-add-btn.sg-sold-out { background: #9ca3af; color: #fff; cursor: not-allowed; opacity: 0.7; }\ 1425 \ 1426 .sg-tab-bar {\ 1427 display: none; flex-direction: row; flex-shrink: 0;\ 1428 border-bottom: 1px solid ' + surfaceBorder + ';\ 1429 background: ' + bg + ';\ 1430 }\ 1431 .sg-tab-btn {\ 1432 flex: 1; padding: 10px 0; font-size: ' + fs + 'px; font-weight: 600;\ 1433 border: none; background: transparent; color: ' + textSec + ';\ 1434 cursor: pointer; font-family: inherit;\ 1435 border-bottom: 2px solid transparent; margin-bottom: -1px;\ 1436 transition: color 0.15s, border-color 0.15s;\ 1437 }\ 1438 .sg-tab-btn.sg-active { color: ' + pc + '; border-bottom-color: ' + pc + '; }\ 1439 .sg-tab-badge {\ 1440 display: inline-block; margin-left: 5px;\ 1441 font-size: ' + (fs - 3) + 'px; background: rgba(' + rgb + ', 0.12);\ 1442 color: ' + pc + '; padding: 1px 6px; border-radius: 10px; font-weight: 700;\ 1443 }\ 1444 @media (max-width: 640px) {\ 1445 .sg-modal { width: 100vw; height: 100%; height: 100dvh; max-height: 100dvh; border-radius: 0; }\ 1446 .sg-modal-body { flex-direction: row; }\ 1447 .sg-chat-col { width: 100%; min-width: 0; border-right: none; flex: 1; }\ 1448 .sg-products-col { flex: 1; min-width: 0; }\ 1449 .sg-tab-hidden { display: none !important; }\ 1450 #sg-browse-btn { display: flex !important; }\ 1451 .sg-modal-header-actions { overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; max-width: 62vw; }\ 1452 .sg-modal-header-actions::-webkit-scrollbar { display: none; }\ 1453 .sg-hdr-btn { flex-shrink: 0; }\ 1454 .sg-modal-header-info { min-width: 0; }\ 1455 .sg-modal-header-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\ 1456 .sg-products-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; padding: 12px; }\ 1457 .sg-product-img { aspect-ratio: 3/4; }\ 1458 .sg-product-name { font-size: ' + (fs - 1) + 'px; }\ 1459 .sg-fab { width: 56px; height: 56px; }\ 1460 .sg-fab svg { width: 44px !important; height: 44px !important; }\ 1461 .sg-chat-input-row { padding: 10px 14px calc(14px + env(safe-area-inset-bottom, 0px)); gap: 10px; }\ 1462 .sg-chat-input {\ 1463 padding: 14px 16px; font-size: 16px;\ 1464 border-radius: 14px; border-width: 1.5px;\ 1465 box-shadow: 0 1px 4px rgba(0,0,0,0.08);\ 1466 min-height: 50px;\ 1467 }\ 1468 .sg-chat-send { width: 50px; height: 50px; border-radius: 14px; flex-shrink: 0; }\ 1469 .sg-chat-send svg { width: 22px; height: 22px; }\ 1470 .sg-mic-btn { width: 50px; height: 50px; border-radius: 14px; }\ 1471 }\ 1472 \ 1473 /* Theme hardening â store themes style bare svg/input/button elements\
1474 (zero-width icons, square 50px inputs). Pin the widget chrome. */\ 1475 .sg-modal-logo svg { width: 48px !important; height: 48px !important; display: block !important; }\ 1476 .sg-hdr-btn svg, #sg-modal-close svg { width: 16px !important; height: 16px !important; display: block !important; max-width: none !important; }\ 1477 .sg-chat-send svg { width: 20px !important; height: 20px !important; display: block !important; max-width: none !important; }\ 1478 .sg-mic-btn svg { width: 18px !important; height: 18px !important; display: block !important; max-width: none !important; stroke: currentColor !important; }\ 1479 .sg-mic-btn svg path, .sg-mic-btn svg line { stroke: currentColor !important; fill: none !important; stroke-width: 2px !important; opacity: 1 !important; }\ 1480 .sg-mic-btn { width: 36px !important; height: 36px !important; min-height: 0 !important; padding: 0 !important; margin: 0 !important; display: flex !important; align-items: center !important; justify-content: center !important; }\ 1481 .sg-empty-products svg { width: 48px !important; height: 48px !important; }\ 1482 .sg-chat-input {\ 1483 height: auto !important; min-height: 0 !important; margin: 0 !important;\ 1484 padding: 10px 14px !important; border-radius: 10px !important;\ 1485 border: 1px solid ' + surfaceBorder + ' !important;\ 1486 font-size: ' + fs + 'px !important; line-height: 1.4 !important;\ 1487 box-shadow: none !important; background: transparent !important;\ 1488 text-transform: none !important; letter-spacing: normal !important;\ 1489 }\ 1490 .sg-chat-input:focus { border-color: ' + pc + ' !important; outline: none !important; }\ 1491 .sg-chat-send {\ 1492 width: 40px !important; height: 40px !important; min-height: 0 !important;\ 1493 margin: 0 !important; padding: 0 !important; border: none !important;\ 1494 border-radius: 10px !important; background: ' + pc + ' !important;\ 1495 }\ 1496 .sg-hdr-btn, #sg-modal-close { min-height: 0 !important; margin: 0 !important; padding: 0 !important; }\ 1497 .sg-suggestion {\ 1498 min-height: 0 !important; margin: 0 !important; padding: 7px 14px !important;\ 1499 border-radius: 20px !important; line-height: 1.4 !important;\ 1500 font-size: ' + (fs - 1) + 'px !important;\ 1501 text-transform: none !important; letter-spacing: normal !important;\ 1502 }\ 1503 .sg-tab-btn { min-height: 0 !important; text-transform: none !important; letter-spacing: normal !important; margin: 0 !important; }\ 1504 .sg-filter-search {\ 1505 height: 30px !important; min-height: 0 !important; margin: 0 !important;\ 1506 padding: 0 10px 0 28px !important;\ 1507 font-size: ' + (fs - 1) + 'px !important; line-height: 28px !important;\ 1508 border-radius: 15px !important; box-shadow: none !important;\ 1509 text-transform: none !important; letter-spacing: normal !important;\ 1510 }\ 1511 .sg-btn-cart, .sg-btn-view {\ 1512 min-height: 0 !important; margin: 0 !important;\ 1513 line-height: 1.2 !important; white-space: nowrap !important;\ 1514 text-transform: none !important; letter-spacing: normal !important;\ 1515 font-size: ' + (fs - 1) + 'px !important;\ 1516 }\ 1517 .sg-btn-cart { display: flex !important; align-items: center !important; justify-content: center !important; gap: 6px !important; padding: 8px 10px !important; }\ 1518 .sg-btn-cart svg { width: 14px !important; height: 14px !important; flex-shrink: 0 !important; display: block !important; }\ 1519 .sg-cart-short { display: none; }\ 1520 @media (max-width: 640px) {\ 1521 .sg-chat-input { padding: 14px 16px !important; font-size: 16px !important; border-radius: 14px !important; min-height: 50px !important; border-width: 1.5px !important; }\ 1522 .sg-chat-send { width: 50px !important; height: 50px !important; border-radius: 14px !important; }\ 1523 .sg-chat-send svg { width: 22px !important; height: 22px !important; }\ 1524 .sg-mic-btn { width: 50px !important; height: 50px !important; }\ 1525 .sg-cart-full { display: none; }\ 1526 .sg-cart-short { display: inline; }\ 1527 }\ 1528 '; 1529 // config.borderRadius (0-40, default 24) scales every corner in the sheet; 1530 // 0 = fully square. The setting was parsed but never applied until 8.39.0 1531 // (first asked for by a merchant wanting square corners). 1532 var baseR = parseInt(config.borderRadius, 10); 1533 if (!isNaN(baseR) && baseR !== 24) { 1534 var rk = baseR / 24; 1535 style.textContent = style.textContent.replace(/border-radius:\s
1535*([0-9.]+)px/g, function(_, px) { 1536 return "border-radius: " + Math.round(parseFloat(px) * rk) + "px"; 1537 }); 1538 if (baseR === 0) { 1539 // Circular buttons go square too; tiny status dots stay dots. 1540 style.textContent += ' .sg-fab, #sg-modal-close, .sg-hdr-btn, .sg-mic-btn, .sg-chat-send, .sg-lb-arrow, #sg-capture-prompt input, #sg-capture-prompt button { border-radius: 0 !important; }'; 1541 } 1542 } 1543 document.head.appendChild(style); 1544 } 1545 1546 var searchSelectors = [ 1547 'form[action="/search"] input[type="search"]', 1548 'form[action="/search"] input[type="text"]', 1549 'form[action="/search"] input[name="q"]', 1550 'input[name="q"][type="search"]', 1551 'input[name="q"]', 1552 '.search-modal__input', 1553 '#Search-In-Modal', 1554 '.search__input', 1555 'predictive-search input', 1556 'search-modal input', 1557 '.header__search input', 1558 '.site-header__search input', 1559 '#HeaderSearchInput', 1560 '[data-search-input]', 1561 '.search-bar input', 1562 '.search-form input', 1563 '#search-input', 1564 '.header input[type="search"]', 1565 '.header input[name="q"]', 1566 ]; 1567 var hookedInputs = []; 1568 1569 function hookSingleInput(input) { 1570 for (var k = 0; k < hookedInputs.length; k++) { 1571 if (hookedInputs[k] === input) return; 1572 } 1573 hookedInputs.push(input); 1574 1575 var searchForm = input.closest("form"); 1576 if (searchForm) { 1577 searchForm.addEventListener("submit", function(e) { 1578 var val = input.value.trim(); 1579 if (!val) return; 1580 e.preventDefault(); 1581 e.stopPropagation(); 1582 openModal(val); 1583 }, true); 1584 } 1585 input.addEventListener("keydown", function(e) { 1586 if (e.key === "Enter") { 1587 var val = input.value.trim(); 1588 if (val) { 1589 e.preventDefault(); 1590 e.stopPropagation(); 1591 openModal(val); 1592 } 1593 } 1594 }, true); 1595 } 1596 1597 function findAndHookSearchInputs() { 1598 for (var i = 0; i < searchSelectors.length; i++) { 1599 var found = document.querySelectorAll(searchSelectors[i]); 1600 for (var j = 0; j < found.length; j++) { 1601 hookSingleInput(found[j]); 1602 } 1603 } 1604 } 1605 1606 function hookSearchBox() { 1607 findAndHookSearchInputs(); 1608 1609 var retries = 0; 1610 var retryInterval = setInterval(function() { 1611 findAndHookSearchInputs(); 1612 retries++; 1613 if (retries >= 10) clearInterval(retryInterval); 1614 }, 1000); 1615 1616 if (typeof MutationObserver !== "undefined") { 1617 var observer = new MutationObserver(function() { 1618 findAndHookSearchInputs(); 1619 }); 1620 observer.observe(document.body, { childList: true, subtree: true }); 1621 } 1622 } 1623 1624 function hasAutoTriggered() { 1625 try { 1626 var key = "sg_auto_triggered_" + shopDomain; 1627 return sessionStorage.getItem(key) === "1"; 1628 } catch(e) { return false; } 1629 } 1630 1631 function markAutoTriggered() { 1632 try { 1633 var key = "sg_auto_triggered_" + shopDomain; 1634 sessionStorage.setItem(key, "1"); 1635 } catch(e) {} 1636 autoTriggeredThisSession = true; 1637 } 1638 1639 function isCartOrCheckoutPage() { 1640 var path = (window.location.pathname || "").toLowerCase(); 1641 return path === "/cart" || path.startsWith("/cart/") || 1642 path === "/checkout" || path.startsWith("/checkout/") || 1643 path.startsWith("/a/checkout") || path.startsWith("/orders/"); 1644 } 1645 1646 function autoOpenOnce() { 1647 if (autoTriggeredThisSession || hasAutoTriggered() || isModalOpen) return; 1648 if (isCartOrCheckoutPage()) return; // never auto-open on cart or checkout 1649 markAutoTriggered(); 1650 trackEvent("assistant_auto_triggered", { trigger: detectTriggerReason() }); 1651 openModal(""); 1652 } 1653 1654 function detectTriggerReason() { 1655 var path = window.location.pathname || ""; 1656 if (/\/products\//.test(path)) return "product_page"; 1657 if (/\/collections\//.test(path)) return "collection_dwell"; 1658 return "search_activity"; 1659 } 1660 1661 function isProductPage() { 1662 var path = window.location.pathname || ""; 1663 return /\/products\//.test(path); 1664 } 1665 1666 function isCategoryPage() { 1667 var path = (window.location.pathname || "").toLowerCase(); 1668 if (!/\/collections\//.test(path)) return false; 1669 var categoryKeywords = [ 1670 "suit", "suits", "shirt", "shirts", "blazer", "blazers", 1671 "formal" 1672 ]; 1673 for (var i = 0; i < categoryKeywords.length; i++) { 1674 if (path.indexOf(categoryKeywords[i]) !== -1) return true; 1675 } 1676 return false;
1677 } 1678 1679 function initSearchTrigger() { 1680 if (isProductPage()) { 1681 setTimeout(function() { autoOpenOnce(); }, 2000); 1682 return; 1683 } 1684 1685 if (isCategoryPage()) { 1686 var dwellTimer = setTimeout(function() { autoOpenOnce(); }, 20000); 1687 var cancelDwell = function() { 1688 clearTimeout(dwellTimer); 1689 window.removeEventListener("beforeunload", cancelDwell); 1690 }; 1691 window.addEventListener("beforeunload", cancelDwell); 1692 } 1693 } 1694 1695 function createFAB() { 1696 if (document.getElementById("sg-fab")) return; 1697 var useBell = config.activationMode === "bell_icon" || config.activationMode === "both"; 1698 var fab = document.createElement("button"); 1699 fab.id = "sg-fab"; 1700 fab.className = "sg-fab" + (useBell ? " sg-bell" : ""); 1701 fab.title = "Ask " + config.stylistName; 1702 fab.setAttribute("data-testid", "button-style-guru-fab"); 1703 fab.innerHTML = (useBell ? bellIcon() : sparklesIcon()) + '<span class="sg-fab-badge"></span>'; 1704 1705 var bPos = config.bellPosition || "bottom_left"; 1706 fab.style.position = "fixed"; 1707 fab.style.top = "auto"; 1708 fab.style.bottom = "auto"; 1709 fab.style.left = "auto"; 1710 fab.style.right = "auto"; 1711 fab.style.transform = ""; 1712 if (bPos === "top_right") { fab.style.top = "24px"; fab.style.right = "24px"; } 1713 else if (bPos === "top_left") { fab.style.top = "24px"; fab.style.left = "24px"; } 1714 else if (bPos === "bottom_left") { fab.style.bottom = "90px"; fab.style.left = "24px"; } 1715 else if (bPos === "bottom_center"){ fab.style.bottom = "90px"; fab.style.left = "50%"; fab.style.transform = "translateX(-50%)"; } 1716 else { fab.style.bottom = "90px"; fab.style.right = "24px"; } 1717 1718 // Soft two-tone door chime on open â a click is a user gesture, so audio 1719 // is permitted. Synthesised (no asset); once per session. 1720 function playOpenChime() { 1721 if (config.bellOpenChime !== true) return; 1722 try { 1723 if (sessionStorage.getItem("sg-chime-done") === "1") return; 1724 sessionStorage.setItem("sg-chime-done", "1"); 1725 } catch (e) {} 1726 try { 1727 var AC = window.AudioContext || window.webkitAudioContext; 1728 if (!AC) return; 1729 var ctx = new AC(); 1730 [[830, 0], [1245, 0.09]].forEach(function (t) { 1731 var osc = ctx.createOscillator(); 1732 var gain = ctx.createGain(); 1733 osc.type = "sine"; 1734 osc.frequency.value = t[0]; 1735 gain.gain.setValueAtTime(0.0001, ctx.currentTime + t[1]); 1736 gain.gain.exponentialRampToValueAtTime(0.12, ctx.currentTime + t[1] + 0.02); 1737 gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + t[1] + 0.7); 1738 osc.connect(gain); gain.connect(ctx.destination); 1739 osc.start(ctx.currentTime + t[1]); 1740 osc.stop(ctx.currentTime + t[1] + 0.8); 1741 }); 1742 setTimeout(function () { try { ctx.close(); } catch (e) {} }, 1500); 1743 } catch (e) {} 1744 } 1745 1746 // Silent visual re-ring: replay the bell-ring animation every ~40s until 1747 // the customer clicks, max 3 times, once per session. Motion draws the 1748 // eye without the autoplay-audio fight (browsers block sound pre-gesture). 1749 var reringTimer = null; 1750 function scheduleReRing() { 1751 if (config.bellReRing === false) return; 1752 try { if (sessionStorage.getItem("sg-rering-done") === "1") return; } catch (e) {} 1753 var count = 0; 1754 reringTimer = setInterval(function () { 1755 var fabEl = document.getElementById("sg-fab"); 1756 if (!fabEl || fabEl.className.indexOf("sg-fab-waiting") !== -1) return; 1757 count++; 1758 fabEl.className += " sg-fab-rering"; 1759 setTimeout(function () { fabEl.className = fabEl.className.replace(" sg-fab-rering", ""); }, 800); 1760 if (count >= 3) stopReRing(); 1761 }, 40000); 1762 } 1763 function stopReRing() { 1764 if (reringTimer) { clearInterval(reringTimer); reringTimer = null; } 1765 try { sessionStorage.setItem("sg-rering-done", "1"); } catch (e) {} 1766 } 1767 1768 // ââ Sizing-hesitation teaser âââââââââââââââââââââââââââââââââââââââââ 1769 // Product page, ~30s dwell, no add-to-cart, widget never opened: a small 1770 // dismissible bubble by the bell offers sizing help. Once per session. 1771 function scheduleSizingTeaser(fabEl, bPosition) { 1772 if (config.sizingTeaser === false) return; 1773 if (!isProductPage()) return;
1774 try { if (sessionStorage.getItem("sg-teaser-done") === "1") return; } catch (e) {} 1775 var cancelled = false; 1776 function cancel() { cancelled = true; } 1777 // An add-to-cart means they weren't hesitating â stand down. 1778 document.addEventListener("click", function (e) { 1779 var t = e.target && e.target.closest && e.target.closest('form[action*="/cart/add"] [type="submit"], form[action*="/cart/add"] button, [name="add"]'); 1780 if (t) cancel(); 1781 }, true); 1782 setTimeout(function () { 1783 if (cancelled || isModalOpen) return; 1784 try { if (sessionStorage.getItem("sg-teaser-done") === "1") return; } catch (e) {} 1785 if (!document.getElementById("sg-fab")) return; 1786 try { sessionStorage.setItem("sg-teaser-done", "1"); } catch (e) {} 1787 var pc = config.primaryColor || "#C6A85B"; 1788 var bubble = document.createElement("div"); 1789 bubble.id = "sg-sizing-teaser"; 1790 var vertical = (bPosition === "top_right" || bPosition === "top_left") ? "top: 96px;" : "bottom: 160px;"; 1791 var horiz = (bPosition === "top_left" || bPosition === "bottom_left") ? "left: 20px;" : (bPosition === "bottom_center" ? "left: 50%; transform: translateX(-50%);" : "right: 20px;"); 1792 bubble.style.cssText = "position:fixed;" + vertical + horiz + "z-index:2147483645;max-width:240px;" + 1793 "background:#fff;color:#1A2332;border:1px solid " + pc + ";border-radius:14px;" + 1794 "box-shadow:0 6px 24px rgba(0,0,0,0.16);padding:12px 34px 12px 14px;" + 1795 "font:500 13px/1.4 " + (config.fontFamily || "inherit") + ";cursor:pointer;" + 1796 "opacity:0;transition:opacity 0.4s ease, translate 0.4s ease;"; 1797 bubble.textContent = T("teaserSizing"); 1798 var x = document.createElement("button"); 1799 x.setAttribute("aria-label", "Dismiss"); 1800 x.innerHTML = "✕"; 1801 x.style.cssText = "position:absolute;top:6px;right:8px;border:none;background:none;color:#9aa0ab;font-size:11px;cursor:pointer;padding:2px;"; 1802 x.onclick = function (e) { e.stopPropagation(); bubble.remove(); trackEvent("teaser_dismissed", {}); }; 1803 bubble.appendChild(x); 1804 bubble.onclick = function () { 1805 bubble.remove(); 1806 trackEvent("teaser_clicked", {}); 1807 openModal(T("starterSize")); 1808 }; 1809 document.body.appendChild(bubble); 1810 requestAnimationFrame(function () { bubble.style.opacity = "1"; }); 1811 trackEvent("teaser_shown", {}); 1812 // quietly retire if ignored â a lingering nag is worse than none 1813 setTimeout(function () { if (bubble.parentNode) { bubble.style.opacity = "0"; setTimeout(function(){ bubble.remove(); }, 500); } }, 25000); 1814 }, 30000); 1815 } 1816 1817 fab.onclick = function() { 1818 try { var tz = document.getElementById("sg-sizing-teaser"); if (tz) tz.remove(); } catch (e) {} 1819 stopReRing(); 1820 playOpenChime(); 1821 // openModal() handles the voice greeting via onUserGesture() â playing 1822 // it here as well doubles the audio (echo). 1823 openModal(""); 1824 }; 1825 scheduleReRing(); 1826 scheduleSizingTeaser(fab, bPos); 1827 1828 // Delayed entrance: on the first page of a session the bell holds back 1829 // (~8s) so cookie/newsletter popups get their moment, then arrives with a 1830 // spring + ring that draws the eye. Later pages show it immediately. 1831 var seenKey = "sg-bell-seen"; 1832 var alreadySeen = false; 1833 try { alreadySeen = sessionStorage.getItem(seenKey) === "1"; } catch (e) {} 1834 if (!alreadySeen) { 1835 fab.className += " sg-fab-waiting"; 1836 setTimeout(function () { 1837 fab.className = fab.className.replace(" sg-fab-waiting", ""); 1838 // bottom_center positions with an inline translateX that the scale 1839 // entrance would override â fade there instead. 1840 fab.className += (bPos === "bottom_center") ? " sg-fab-fade-enter" : " sg-fab-enter"; 1841 try { sessionStorage.setItem(seenKey, "1"); } catch (e) {} 1842 trackEvent("bell_shown", { delayed: true }); 1843 }, 8000); 1844 } else { 1845 trackEvent("bell_shown", { delayed: false }); 1846 } 1847 document.body.appendChild(fab); 1848 } 1849 1850 function createModal() { 1851 if (document.getElementById("sg-overlay")) return; 1852 1853 var fc = config.fontColor || "#333"; 1854 var pc = config.primaryColor || "#C6A85B"; 1855 var sendBtnColor = isLightColor(pc) ? "#111" : "#fff"; 1856 var hdrIconColor = isLightColor(config.backgroundColor || "#fff") ? "#555" : "#ccc"; 1857 1858 var isVoiceMode = config.interactionMode === "voice_stylist"; 1859 audioEnabled = isVoiceMode; 1860 1861 var micHtml = (isVoiceMode && hasSpeechRecognition()) 1862 ? '<button class="sg-mic-btn" id="sg-modal-mic" title="Speak" data-testid="button-mic">' + micIcon() + '</button>' 1863 : ''; 1864 1865 var speakerHtml = isVoiceMode 1866 ? '<button class="sg-hdr-btn' + (audioEnabled ? ' sg-active' : '') + '" id="sg-speaker-toggle" title="' + (audioEnabled ? 'Audio on' : 'Audio off') + '" data-testid="button-speaker-toggle">' + speakerIcon(hdrIconColor) + '</button>' 1867 : ''; 1868 1869 // Live voice (ElevenLabs Speech Engine pilot): real spoken conversation â 1870 // mic, turn-taking and voice handled by the engine, our agent as the brain. 1871 var liveVoiceHtml = (config.speechEngine && PROXY_PATH) 1872 ? '<button class="sg-hdr-btn" id="sg-live-btn" title="Talk to the stylist" data-testid="button-live-voice">
1872<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><line x1="2" y1="6" x2="2" y2="10" stroke="' + hdrIconColor + '" stroke-width="1.6" stroke-linecap="round"/><line x1="5" y1="4" x2="5" y2="12" stroke="' + hdrIconColor + '" stroke-width="1.6" stroke-linecap="round"/><line x1="8" y1="2" x2="8" y2="14" stroke="' + hdrIconColor + '" stroke-width="1.6" stroke-linecap="round"/><line x1="11" y1="4" x2="11" y2="12" stroke="' + hdrIconColor + '" stroke-width="1.6" stroke-linecap="round"/><line x1="14" y1="6" x2="14" y2="10" stroke="' + hdrIconColor + '" stroke-width="1.6" stroke-linecap="round"/></svg></button>' 1873 : ''; 1874 1875 var sizeFinderHtml = config.fitcoreEnabled 1876 ? '<button class="sg-hdr-btn" id="sg-size-finder-btn" title="Find my size with FitCore" data-testid="button-size-finder">' + rulerIcon(hdrIconColor) + '</button>' 1877 : ''; 1878 1879 // Mobile-only header toggle between the chat pane and the browse/filter 1880 // pane â same treatment as the FitCore size-finder link. The icon flips 1881 // to a chat bubble while the products pane is showing. 1882 var browseGridSvg = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1.5" y="1.5" width="5" height="5" rx="1" stroke="' + hdrIconColor + '" stroke-width="1.5"/><rect x="9.5" y="1.5" width="5" height="5" rx="1" stroke="' + hdrIconColor + '" stroke-width="1.5"/><rect x="1.5" y="9.5" width="5" height="5" rx="1" stroke="' + hdrIconColor + '" stroke-width="1.5"/><rect x="9.5" y="9.5" width="5" height="5" rx="1" stroke="' + hdrIconColor + '" stroke-width="1.5"/></svg>'; 1883 var browseChatSvg = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M14 10.5a1.5 1.5 0 0 1-1.5 1.5H5l-3 3V3.5A1.5 1.5 0 0 1 3.5 2h9A1.5 1.5 0 0 1 14 3.5v7z" stroke="' + hdrIconColor + '" stroke-width="1.5" stroke-linejoin="round"/></svg>'; 1884 var browseBtnHtml = '<button class="sg-hdr-btn" id="sg-browse-btn" title="Browse products" data-testid="button-browse"><span id="sg-browse-ic-grid" style="display:flex">' + browseGridSvg + '</span><span id="sg-browse-ic-chat" style="display:none">' + browseChatSvg + '</span><span class="sg-hdr-badge" id="sg-browse-count" style="display:none"></span></button>'; 1885 1886 var overlay = document.createElement("div"); 1887 overlay.id = "sg-overlay"; 1888 overlay.className = "sg-overlay"; 1889 overlay.innerHTML = '\ 1890 <div class="sg-modal" id="sg-modal" data-testid="modal-stylist">\ 1891 <div class="sg-modal-header">\ 1892 <div class="sg-modal-logo">' + sparklesIcon() + '</div>\ 1893 <div class="sg-modal-header-info">\ 1894 <div class="sg-modal-header-title">' + escapeHtml(config.stylistName) + '</div>\ 1895 <div class="sg-modal-header-sub">' + (config.storeVertical && config.storeVertical !== "fashion" ? "Your personal AI shopping assistant" : "Your personal AI style assistant") + '</div>\ 1896 </div>\ 1897 <div class="sg-modal-header-actions">\ 1898 ' + liveVoiceHtml + '\ 1899 ' + speakerHtml + '\ 1900 ' + sizeFinderHtml + '\ 1901 ' + browseBtnHtml + '\ 1902 <button class="sg-hdr-btn" id="sg-message-us" title="Send us a message" data-testid="button-message-us">' + mailIcon(hdrIconColor) + '</button>\ 1903 <button class="sg-hdr-btn" id="sg-modal-close" title="Close" data-testid="button-close-modal">' + closeIcon(hdrIconColor) + '</button>\ 1904 </div>\ 1905 </div>\ 1906 <div class="sg-tab-bar" id="sg-tab-bar">\ 1907 <button class="sg-tab-btn sg-active" id="sg-tab-chat" data-testid="tab-chat">' + T("chat") + '</button>\ 1908 <button class="sg-tab-btn" id="sg-tab-outfits" data-testid="tab-outfits">' + T("outfits") + '<span class="sg-tab-badge" id="sg-tab-count" style="display:none"></span></button>\ 1909 </div>\ 1910 <div class="sg-modal-body">\ 1911 <div class="sg-chat-col" id="sg-chat-col">\ 1912 <div class="sg-chat-messages" id="sg-messages" data-testid="chat-messages"></div>\ 1913 <div class="sg-chat-input-row">\ 1914 <input type="text" class="sg-chat-input" id="sg-chat-input" placeholder="' + (isVoiceMode && hasSpeechRecognition() ? T('micPlaceholder') : T('askPlaceholder')) + '" data-testid="input-chat" />\ 1915 ' + micHtml + '\ 1916 <button class="sg-chat-send" id="sg-chat-send" disabled data-testid="button-send">' + sendIcon(sendBtnColor) + '</button>\ 1917 </div>\ 1918 ' + (config.hideBranding ? '' : '<div class="sg-powered"><a href="https://www.styleguru.io" target="_blank" rel="noopener" data-testid="link-powered-by">Powered by <strong>Style Guru</strong></a></div>') + '\ 1919 </div>\ 1920 <div class="sg-products-col" id="sg-products-col">\ 1921 <div class="sg-products-header">\
1922 <span id="sg-products-header-label">' + T("browse") + '</span>\ 1923 <span class="sg-products-header-count" id="sg-product-count"></span>\ 1924 </div>\ 1925 <div class="sg-filter-bar" id="sg-filter-bar">\ 1926 <div class="sg-filter-top-row">\ 1927 <span class="sg-filter-label">Filter by</span>\ 1928 <div class="sg-filter-search-wrap">\ 1929 <svg class="sg-filter-search-icon" width="13" height="13" viewBox="0 0 13 13" fill="none"><circle cx="5.5" cy="5.5" r="4" stroke="currentColor" stroke-width="1.5"/><line x1="8.5" y1="8.5" x2="12" y2="12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>\ 1930 <input class="sg-filter-search" id="sg-filter-search" type="text" placeholder="' + T("searchProducts") + '" autocomplete="off" />\ 1931 </div>\ 1932 <button class="sg-filter-clear-btn" id="sg-filter-clear" type="button">Clear</button>\ 1933 </div>\ 1934 <div class="sg-filter-chips" id="sg-filter-chips">\ 1935 <select class="sg-filter-select" id="sg-filter-category">\ 1936 <option value="">' + T("category") + '</option>\ 1937 </select>\ 1938 <select class="sg-filter-select" id="sg-filter-colour">\ 1939 <option value="">' + T("colour") + '</option>\ 1940 <option value="black">Black</option>\ 1941 <option value="white">White</option>\ 1942 <option value="navy">Navy</option>\ 1943 <option value="blue">Blue</option>\ 1944 <option value="charcoal">Charcoal</option>\ 1945 <option value="grey">Grey</option>\ 1946 <option value="brown">Brown</option>\ 1947 <option value="tan">Tan</option>\ 1948 <option value="beige">Beige</option>\ 1949 <option value="cream">Cream</option>\ 1950 <option value="burgundy">Burgundy</option>\ 1951 <option value="green">Green</option>\ 1952 <option value="pink">Pink</option>\ 1953 <option value="red">Red</option>\ 1954 </select>\ 1955 <select class="sg-filter-select" id="sg-filter-fabric" style="display:none">\ 1956 <option value="">Fabric</option>\ 1957 </select>\ 1958 <select class="sg-filter-select" id="sg-filter-size">\ 1959 <option value="">' + T("size") + '</option>\ 1960 </select>\ 1961 <select class="sg-filter-select" id="sg-filter-price">\ 1962 <option value="">' + T("price") + '</option>\ 1963 <option value="0-100">Under £100</option>\ 1964 <option value="100-250">£100 â £250</option>\ 1965 <option value="250-500">£250 â £500</option>\ 1966 <option value="500-1000">£500 â £1,000</option>\ 1967 <option value="1000-99999">£1,000+</option>\ 1968 </select>\ 1969 <select class="sg-filter-select" id="sg-filter-sort">\ 1970 <option value="">Sort</option>\ 1971 <option value="price_asc">Price: Low to High</option>\ 1972 <option value="price_desc">Price: High to Low</option>\ 1973 <option value="name_asc">Name: AâZ</option>\ 1974 <option value="new">Newest First</option>\ 1975 </select>\ 1976 <button class="sg-filter-toggle" id="sg-filter-instock" type="button" data-active="false">In stock only</button>\ 1977 </div>\ 1978 </div>\ 1979 <div class="sg-products-col-inner">\ 1980 <div class="sg-products-fade-top" id="sg-fade-top"></div>\ 1981 <div class="sg-products-scroll" id="sg-products-scroll">\ 1982 <div class="sg-complete-look-zone" id="sg-complete-look-zone" style="display:none"></div>\ 1983 <div class="sg-products-grid" id="sg-products-grid" data-testid="products-grid"></div>\ 1984 </div>\ 1985 <div class="sg-products-fade-bottom" id="sg-fade-bottom"></div>\ 1986 </div>\ 1987 <div class="sg-img-lightbox" id="sg-img-lightbox" role="dialog" aria-modal="true">\ 1988 <div class="sg-lb-topbar">\ 1989 <button class="sg-lb-close" id="sg-lb-close" aria-label="Back to products">← Back</button>\
1990 <span class="sg-lb-topname" id="sg-lb-name"></span>\ 1991 <span class="sg-lb-counter" id="sg-lb-counter"></span>\ 1992 </div>\ 1993 <div class="sg-lb-imgarea">\ 1994 <button class="sg-lb-arrow sg-lb-prev" id="sg-lb-prev" aria-label="Previous image">←</button>\ 1995 <img id="sg-lb-img" src="" alt="" />\ 1996 <button class="sg-lb-arrow sg-lb-next" id="sg-lb-next" aria-label="Next image">→</button>\ 1997 </div>\ 1998 </div>\ 1999 </div>\ 2000 </div>\ 2001 </div>\ 2002 '; 2003 2004 document.body.appendChild(overlay); 2005 2006 overlay.addEventListener("click", function(e) { 2007 if (e.target === overlay) closeModal(); 2008 }); 2009 2010 document.getElementById("sg-modal-close").onclick = closeModal; 2011 var messageUsBtn = document.getElementById("sg-message-us"); 2012 if (messageUsBtn) messageUsBtn.onclick = function() { showHandoffForm(null, ""); }; 2013 var sizeFinderBtn = document.getElementById("sg-size-finder-btn"); 2014 if (sizeFinderBtn) sizeFinderBtn.onclick = function() { switchTab("chat"); openSizeFinderUI(); }; 2015 var liveBtn = document.getElementById("sg-live-btn"); 2016 if (liveBtn) liveBtn.onclick = function() { sgLiveConv ? sgStopLiveVoice() : sgStartLiveVoice(); }; 2017 var browseHdrBtn = document.getElementById("sg-browse-btn"); 2018 if (browseHdrBtn) browseHdrBtn.onclick = function() { switchTab(activeTab === "products" ? "chat" : "products"); }; 2019 2020 // Image lightbox with left/right navigation 2021 var lightbox = document.getElementById("sg-img-lightbox"); 2022 var lbImg = document.getElementById("sg-lb-img"); 2023 var lbName = document.getElementById("sg-lb-name"); 2024 var lbClose = document.getElementById("sg-lb-close"); 2025 var lbPrev = document.getElementById("sg-lb-prev"); 2026 var lbNext = document.getElementById("sg-lb-next"); 2027 var lbCounter = document.getElementById("sg-lb-counter"); 2028 2029 var lbImages = []; // [{src, name}] â all images currently in the panel 2030 var lbIndex = 0; // which one is showing 2031 2032 function getLightboxImages() { 2033 var triggers = document.querySelectorAll("#sg-products-col .sg-img-zoom-trigger[data-img]"); 2034 var imgs = []; 2035 for (var i = 0; i < triggers.length; i++) { 2036 var src = triggers[i].getAttribute("data-img"); 2037 var name = triggers[i].getAttribute("data-name") || ""; 2038 if (src) imgs.push({ src: src, name: name }); 2039 } 2040 return imgs; 2041 } 2042 2043 function renderLightboxAt(idx) { 2044 if (!lbImages.length) return; 2045 idx = Math.max(0, Math.min(idx, lbImages.length - 1)); 2046 lbIndex = idx; 2047 lbImg.src = lbImages[idx].src; 2048 lbImg.alt = lbImages[idx].name; 2049 lbName.textContent = lbImages[idx].name; 2050 if (lbImages.length > 1) { 2051 lbCounter.textContent = (idx + 1) + " / " + lbImages.length; 2052 lbCounter.style.display = ""; 2053 lbPrev.style.display = ""; 2054 lbNext.style.display = ""; 2055 lbPrev.disabled = idx === 0; 2056 lbNext.disabled = idx === lbImages.length - 1; 2057 } else { 2058 lbCounter.style.display = "none"; 2059 lbPrev.style.display = "none"; 2060 lbNext.style.display = "none"; 2061 } 2062 } 2063 2064 function openLightbox(src, name) { 2065 lbImages = getLightboxImages(); 2066 // Find the index of the clicked image 2067 var foundIdx = 0; 2068 for (var i = 0; i < lbImages.length; i++) { 2069 if (lbImages[i].src === src) { foundIdx = i; break; } 2070 } 2071 lightbox.classList.add("sg-lb-open"); 2072 renderLightboxAt(foundIdx); 2073 } 2074 2075 function closeLightbox() { 2076 lightbox.classList.remove("sg-lb-open"); 2077 lbImg.src = ""; 2078 } 2079 2080 lbClose.onclick = function(e) { e.stopPropagation(); closeLightbox(); }; 2081 lightbox.addEventListener("click", function(e) { if (e.target === lightbox) closeLightbox(); }); 2082 lbPrev.onclick = function(e) { e.stopPropagation(); renderLightboxAt(lbIndex - 1); }; 2083 lbNext.onclick = function(e) { e.stopPropagation(); renderLightboxAt(lbIndex + 1); }; 2084 2085 // Delegate click on image zoom triggers inside products col 2086 document.getElementById("sg-products-col").addEventListener("click", function(e) { 2087 var trigger = e.target.closest(".sg-img-zoom-trigger"); 2088 if (!trigger) return; 2089 var imgSrc = trigger.getAttribute("data-img"); 2090 var imgName = trigger.getAttribute("data-name"); 2091 if (imgSrc) { 2092 e.preventDefault(); 2093 openLightbox(imgSrc, imgName); 2094 } 2095 }); 2096 2097 var chatInput = document.getElementById("sg-chat-input"); 2098 var chatSend = document.getElementById("sg-chat-send"); 2099 2100 chatInput.addEventListener("input", function() { 2101 chatSend.disabled = !chatInput.value.trim() || isLoading; 2102 }); 2103 2104 chatInput.addEventListener("keydown", function(e) { 2105 if (e.key === "Enter" && !e.shiftKey) { 2106 e.preventDefault(); 2107 sendMessage(); 2108 } 2109 }); 2110 2111 document.addEventListener("keydown", function(e) { 2112 if (!lightbox.classList.contains("sg-lb-open")) return; 2113 if (e.key === "Escape") { closeLightbox(); } 2114 else if (e.key === "ArrowLeft") { e.preventDefault(); renderLightboxAt(lbIndex - 1); } 2115 else if (e.key === "ArrowRight") { e.preventDefault(); renderLightboxAt(lbIndex + 1); } 2116 }); 2117 2118 chatSend.onclick = sendMessage; 2119 2120 // Scroll-fade indicators for the products pane (outfit zone + grid scroll together) 2121 var sgGrid = document.getElementById("sg-products-grid"); 2122 var sgScroll = document.getElementById("sg-products-scroll"); 2123 var sgFadeTop = document.getElementById("sg-fade-top"); 2124 var sgFadeBottom = document.getElementById("sg-fade-bottom"); 2125 2126 updateScrollFade = function() { 2127 if (!sgScroll || !sgFadeTop || !sgFadeBottom) return;
2128 var atTop = sgScroll.scrollTop < 8; 2129 var atBottom = sgScroll.scrollTop + sgScroll.clientHeight >= sgScroll.scrollHeight - 8; 2130 sgFadeTop.classList.toggle("sg-fade-visible", !atTop); 2131 sgFadeBottom.classList.toggle("sg-fade-visible", !atBottom && sgScroll.scrollHeight > sgScroll.clientHeight); 2132 }; 2133 2134 if (sgScroll) { 2135 sgScroll.addEventListener("scroll", updateScrollFade, { passive: true }); 2136 } 2137 2138 // âââ Deterministic filter pane âââââââââââââââââââââââââââââââââââââââââââââ 2139 var filterState = { query: "", category: "", colour: "", size: "", priceRange: "", sort: "", inStockOnly: false, onSale: false, fabric: "" }; 2140 // Merchant-configured landing view: Browse opens pre-filtered (category/ 2141 // tags/sort set in the dashboard). The filter UI reflects it, and Clear 2142 // filters returns the shopper to the full catalogue. 2143 var landingTags = []; 2144 // When the AI applies a grid filter that matches nothing, relax it one 2145 // constraint at a time (query -> category -> size) rather than showing an 2146 // empty grid under a message that promised products. 2147 var aiFilterRelax = []; 2148 if (config.catalogLanding && typeof config.catalogLanding === "object") { 2149 if (config.catalogLanding.category) filterState.category = String(config.catalogLanding.category); 2150 if (config.catalogLanding.sort) filterState.sort = String(config.catalogLanding.sort); 2151 if (config.catalogLanding.tags && config.catalogLanding.tags.length) landingTags = config.catalogLanding.tags; 2152 } 2153 var filterDebounceTimer = null; 2154 var filterFacetsLoaded = false; 2155 var filterRequestSeq = 0; 2156 var currentCompleteLook = null; 2157 2158 var filterSearch = document.getElementById("sg-filter-search"); 2159 var filterCategory = document.getElementById("sg-filter-category"); 2160 var filterColour = document.getElementById("sg-filter-colour"); 2161 var filterFabric = document.getElementById("sg-filter-fabric"); 2162 var filterSize = document.getElementById("sg-filter-size"); 2163 var filterPrice = document.getElementById("sg-filter-price"); 2164 var filterSort = document.getElementById("sg-filter-sort"); 2165 var filterInstock = document.getElementById("sg-filter-instock"); 2166 var filterClear = document.getElementById("sg-filter-clear"); 2167 var headerLabel = document.getElementById("sg-products-header-label"); 2168 2169 // Force inline styles on select elements to guarantee they override any theme CSS 2170 var filterSelects = [filterCategory, filterColour, filterSize, filterPrice, filterS
2170ort]; 2171 var selectFc = config.fontColor || "#333333"; 2172 var selectBg = config.backgroundColor || "#ffffff"; 2173 var selectBorder = isLightColor(selectBg) ? "rgba(0,0,0,0.15)" : "rgba(255,255,255,0.2)"; 2174 for (var fsi = 0; fsi < filterSelects.length; fsi++) { 2175 var fsel = filterSelects[fsi]; 2176 if (!fsel) continue; 2177 fsel.style.setProperty("color", selectFc, "important"); 2178 fsel.style.setProperty("background-color", selectBg, "important"); 2179 fsel.style.setProperty("border-color", selectBorder, "important"); 2180 fsel.style.setProperty("width", "auto", "important"); 2181 fsel.style.setProperty("max-width", "140px", "important"); 2182 fsel.style.setProperty("height", "26px", "important"); 2183 fsel.style.setProperty("min-height", "26px", "important"); 2184 fsel.style.setProperty("max-height", "26px", "important"); 2185 fsel.style.setProperty("line-height", "24px", "important"); 2186 fsel.style.setProperty("padding", "0 22px 0 10px", "important"); 2187 fsel.style.setProperty("margin", "0", "important"); 2188 fsel.style.setProperty("box-sizing", "border-box", "important"); 2189 fsel.style.setProperty("appearance", "none", "important"); 2190 fsel.style.setProperty("-webkit-appearance", "none", "important"); 2191 fsel.style.setProperty("border-radius", "13px", "important"); 2192 fsel.style.setProperty("padding", "0 22px 0 10px", "important"); 2193 fsel.style.setProperty("font-size", (parseInt(config.fontSize || "14") - 2) + "px", "important"); 2194 fsel.style.setProperty("flex-shrink", "0", "important"); 2195 fsel.style.setProperty("flex-grow", "0", "important"); 2196 fsel.style.setProperty("cursor", "pointer", "important"); 2197 fsel.style.setProperty("outline", "none", "important"); 2198 fsel.style.setProperty("background-image", "url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27 width%3D%2710%27 height%3D%276%27 viewBox%3D%270 0 10 6%27%3E%3Cpath d%3D%27M1 1l4 4 4-4%27 stroke%3D%27%23777%27 stroke-width%3D%271.5%27 fill%3D%27none%27 stroke-linecap%3D%27round%27%2F%3E%3C%2Fsvg%3E\")", "important"); 2199 fsel.style.setProperty("background-repeat", "no-repeat", "important"); 2200 fsel.style.setProperty("background-position", "right 6px center", "important"); 2201 fsel.style.setProperty("box-sizing", "border-box", "important"); 2202 } 2203 2204 function isFilterActive() { 2205 return !!(filterState.query || filterState.category || filterState.colour || filterState.size || filterState.priceRange || filterState.inStockOnly || filterState.onSale || filterState.fabric); 2206 } 2207 2208 function updateFilterClearBtn() { 2209 if (filterClear) filterClear.classList.toggle("sg-visible", isFilterActive()); 2210 } 2211 2212 function updateFilterActiveStyles() { 2213 var pc = config.primaryColor || "#C6A85B"; 2214 var rgb = hexToRgb(pc); 2215 var selectBg = config.backgroundColor || "#ffffff"; 2216 var selectFc = config.fontColor || "#333333"; 2217 [[filterCategory, !!filterState.category], 2218 [filterColour, !!filterState.colour], 2219 [filterSize, !!filterState.size], 2220 [filterPrice, !!filterState.priceRange], 2221 [filterSort, !!filterState.sort]].forEach(function(pair) { 2222 var el = pair[0]; var active = pair[1]; 2223 if (!el) return; 2224 el.classList.toggle("sg-filter-active", active); 2225 if (active) { 2226 el.style.setProperty("color", pc, "important"); 2227 el.style.setProperty("border-color", pc, "important"); 2228 el.style.setProperty("background-color", "rgba(" + rgb + ",0.08)", "important"); 2229 el.style.setProperty("font-weight", "600", "important"); 2230 } else { 2231 el.style.setProperty("color", selectFc, "important"); 2232 el.style.setProperty("border-color", isLightColor(selectBg) ? "rgba(0,0,0,0.15)" : "rgba(255,255,255,0.2)", "important"); 2233 el.style.setProperty("background-color", selectBg, "important"); 2234 el.style.setProperty("font-weight", "400", "important"); 2235 } 2236 }); 2237 if (filterInstock) { 2238 filterInstock.classList.toggle("sg-filter-active", filterState.inStockOnly); 2239 filterInstock.setAttribute("data-active", filterState.inStockOnly ? "true" : "false"); 2240 } 2241 updateFilterClearBtn(); 2242 } 2243 2244 function loadFilteredProducts() { 2245 var catalogUrl = PROXY_PATH ? PROXY_PATH + "/catalog" : (API_BASE || "") + "/apps/style-guru/catalog"; 2246 var body = { shop: shopDomain }; 2247 if (filterState.query) body.query = filterState.query; 2248 if (filterState.category) body.category = filterState.category; 2249 if (filterState.colour) body.colour = filterState.colour; 2250 if (filterState.size) body.size = filterState.size; 2251 if (filterState.priceRange) body.priceRange = filterState.priceRange; 2252 if (filterState.sort) body.sort = filterState.sort; 2253 if (filterState.inStockOnly) body.inStockOnly = true; 2254 if (filterState.onSale) body.onSale = true; 2255 if (filterState.fabric) body.fabric = filterState.fabric; 2256 if (landingTags.length) body.tags = landingTags; 2257 2258 var spinColor = config.primaryColor || "#C6A85B"; 2259 if (sgGrid) { 2260 sgGrid.innerHTML = '<div class="sg-filter-loading"><div style="width:24px;height:24px;border:3px solid ' + spinColor + ';border-top-color:transparent;border-radius:50%;animation:sg-spin 0.7s linear infinite"></div><span>Filtering...</span></div>'; 2261 } 2262 if (headerLabel) headerLabel.textContent = isFilterActive() ? T("results") : T("browse"); 2263 2264 filterRequestSeq++; 2265 var thisSeq = filterRequestSeq; 2266 2267 fetch(catalogUrl, { 2268 method: "POST", 2269 headers: { "Content-Type": "application/json" }, 2270 body: JSON.stringify(body) 2271 }) 2272 .then(function(r) { 2273 if (thisSeq !== filterRequestSeq) return null; // stale request â ignore 2274 if (!r.ok) { throw new Error("HTTP " + r.status); } 2275 return r.json(); 2276 }) 2277 .then(function(data) { 2278 if (!data || thisSeq !== filterRequestSeq) return; // stale or aborted 2279 var products = (data.products || []).map(function(p) { 2280 return { 2281 title: p.name, 2282 items: [{ 2283 name: p.name, 2284 url: p.url || ("/products/" + (p.handle || "")), 2285 price: p.price || "", 2286 image: p.image || "", 2287 productId: String(p.id || ""), 2288 // Only carry a variant when there is exactly one â for sized 2289 // products the customer must pick; defaulting to the first 2290 // size put wrong sizes in carts. 2291 variantId: (p.variants && p.variants.length === 1 ? String(p.variants[0].id) : ""), 2292 variantTitle: (p.variants && p.variants.length === 1 ? p.variants[0].title : ""), 2293 role: "hero", 2294 options: (p.options || []).map(function(o) { return typeof o === "object" ? (o.name || "") : (o || ""); }), 2295 variants: p.variants || [], 2296 tags: p.tags || [], 2297 option1: p.variants && p.variants[0] ? p.variants[0].option1 : "", 2298 option2: p.variants && p.variants[0] ? p.variants[0].option2 : "" 2299 }] 2300 }; 2301 }); 2302 // Populate category facets once (from the initial all-products load) 2303 if (!filterFacetsLoaded && data.facets) { 2304 populateFacets(data.facets); 2305 filterFacetsLoaded = true; 2306 } 2307 // Always refresh size facets so they stay relevant to the selected category 2308 if (data.facets && filterFacetsLoaded) { 2309 populateSizeFacets(data.facets.sizes || []); 2310 populateEnrichmentFacets(data.facets); 2311 } 2312 if (products.length === 0 && aiFilterRelax.length) { 2313 var relaxKey = aiFilterRelax.shift(); 2314 filterState[relaxKey] = ""; 2315 if (relaxKey === "query" && filterSearch) filterSearch.value = ""; 2316 if (relaxKey === "category" && filterCategory) filterCategory.value = ""; 2317 if (relaxKey === "size" && filterSize) filterSize.value = ""; 2318 updateFilterActiveStyles(); 2319 loadFilteredProducts(); 2320 return; 2321 } 2322 if (pendingChatPreview) { 2323 pendingChatPreview = false;
2324 if (products.length > 0) { 2325 for (var pcm = messages.length - 1; pcm >= 0; pcm--) { 2326 if (messages[pcm].role === "assistant") { 2327 if (!messages[pcm].outfits || messages[pcm].outfits.length === 0) { 2328 messages[pcm].outfits = products.slice(0, 4); 2329 renderMessages(); 2330 } 2331 break; 2332 } 2333 } 2334 } 2335 } 2336 aiFilterRelax = []; // results (or a manual empty) â ladder is spent 2337 if (products.length === 0) { 2338 var countEl2 = document.getElementById("sg-product-count"); 2339 if (countEl2) countEl2.textContent = ""; 2340 if (sgGrid) sgGrid.innerHTML = '<div class="sg-empty-products">' + searchIcon() + '<h4>' + T("noProducts") + '</h4><p>' + T("adjustFilters") + '</p></div>'; 2341 updateScrollFade(); 2342 } else { 2343 try { 2344 renderProducts(products, { source: "browse" }); 2345 } catch(renderErr) { 2346 console.error("[StyleGuru] renderProducts error:", renderErr); 2347 if (sgGrid) sgGrid.innerHTML = '<div class="sg-empty-products">' + searchIcon() + '<h4>Could not display products</h4></div>'; 2348 } 2349 } 2350 }) 2351 .catch(function(err) { 2352 if (thisSeq !== filterRequestSeq) return; // stale â ignore 2353 console.error("[StyleGuru] Catalog fetch error:", err); 2354 if (sgGrid) sgGrid.innerHTML = '<div class="sg-empty-products">' + searchIcon() + '<h4>Could not load products</h4><p>Check your connection and try again.</p></div>'; 2355 }); 2356 } 2357 2358 function populateSizeFacets(sizes) { 2359 if (!filterSize) return; 2360 sizes = sizes || []; 2361 // A remembered size that doesn't exist in the current category (collar vs 2362 // shoe vs chest sizing) silently filters everything to zero and renders 2363 // the select blank. Drop it and refetch once. 2364 if (filterState.size && sizes.indexOf(filterState.size) === -1) { 2365 filterState.size = ""; 2366 filterSize.value = ""; 2367 updateFilterActiveStyles(); 2368 loadFilteredProducts(); 2369 return; 2370 } 2371 if (sizes.length === 0) { 2372 filterSize.innerHTML = '<option value="">' + T("size") + '</option>'; 2373 return; 2374 } 2375 var numericSizes = sizes.filter(function(s) { return /\d/.test(s); }) 2376 .sort(function(a, b) { 2377 var aNum = parseFloat(a); var bNum = parseFloat(b); 2378 return (!isNaN(aNum) && !isNaN(bNum)) ? aNum - bNum : a.localeCompare(b); 2379 }); 2380 var wordSizes = sizes.filter(function(s) { return !/\d/.test(s); }) 2381 .sort(function(a, b) { 2382 var order = ["XS","S","M","L","XL","XXL","2XL","XXXL","3XL"]; 2383 var ai = order.indexOf(a.toUpperCase()); var bi = order.indexOf(b.toUpperCase()); 2384 if (ai !== -1 && bi !== -1) return ai - bi; 2385 return a.localeCompare(b); 2386 }); 2387 var sizeHtml = '<option value="">' + T("size") + '</option>'; 2388 if (wordSizes.length > 0) { 2389 sizeHtml += '<optgroup label="Size">'; 2390 wordSizes.forEach(function(s) { sizeHtml += '<option value="' + escapeHtml(s) + '">' + escapeHtml(s) + '</option>'; }); 2391 sizeHtml += '</optgroup>'; 2392 } 2393 if (numericSizes.length > 0) { 2394 sizeHtml += '<optgroup label="Collar / Numeric">'; 2395 numericSizes.forEach(function(s) { sizeHtml += '<option value="' + escapeHtml(s) + '">' + escapeHtml(s) + '</option>'; }); 2396 sizeHtml += '</optgroup>'; 2397 } 2398 filterSize.innerHTML = sizeHtml; 2399 // Re-apply inline styles after innerHTML replacement (they get wiped) 2400 var selectFc2 = config.fontColor || "#333333"; 2401 var selectBg2 = config.backgroundColor || "#ffffff"; 2402 filterSize.style.setProperty("color", filterState.size ? (config.primaryColor || "#C6A85B") : selectFc2, "important"); 2403 filterSize.style.setProperty("background-color", selectBg2, "important"); 2404 if (filterState.size) filterState.size = syncSelectValue(filterSize, filterState.size); 2405 } 2406 2407 function populateFacets(facets) { 2408 if (filterCategory && facets.categories && facets.categories.length > 0) { 2409 var catHtml = '<option value="">' + T("category") + '</option>';
2410 facets.categories.forEach(function(cat) { 2411 catHtml += '<option value="' + escapeHtml(cat) + '">' + escapeHtml(cat) + '</option>'; 2412 }); 2413 filterCategory.innerHTML = catHtml; 2414 // Re-apply inline styles after innerHTML replacement 2415 var selectFc3 = config.fontColor || "#333333"; 2416 var selectBg3 = config.backgroundColor || "#ffffff"; 2417 filterCategory.style.setProperty("color", filterState.category ? (config.primaryColor || "#C6A85B") : selectFc3, "important"); 2418 filterCategory.style.setProperty("background-color", selectBg3, "important"); 2419 if (filterState.category) filterState.category = syncSelectValue(filterCategory, filterState.category); 2420 } 2421 populateSizeFacets(facets.sizes || []); 2422 populateEnrichmentFacets(facets); 2423 } 2424 2425 // Canonical colour/fabric dropdowns from vision enrichment. Colours only 2426 // replace the built-in list when the store is enriched; the fabric select 2427 // stays hidden entirely until fabrics exist (graceful absence). 2428 function populateEnrichmentFacets(facets) { 2429 var pretty = function(v) { 2430 return v.split("-").map(function(w){ return w.charAt(0).toUpperCase() + w.slice(1); }).join(" "); 2431 }; 2432 if (filterColour && facets.colours && facets.colours.length >= 3) { 2433 var ch = '<option value="">' + T("colour") + '</option>'; 2434 facets.colours.forEach(function(c) { ch += '<option value="' + escapeHtml(c) + '">' + escapeHtml(pretty(c)) + '</option>'; }); 2435 filterColour.innerHTML = ch; 2436 if (filterState.colour) filterState.colour = syncSelectValue(filterColour, filterState.colour); 2437 } 2438 if (filterFabric && facets.fabrics && facets.fabrics.length >= 2) { 2439 var fh = '<option value="">Fabric</option>'; 2440 facets.fabrics.forEach(function(f) { fh += '<option value="' + escapeHtml(f) + '">' + escapeHtml(pretty(f)) + '</option>'; }); 2441 filterFabric.innerHTML = fh; 2442 filterFabric.style.display = ""; 2443 if (filterState.fabric) filterState.fabric = syncSelectValue(filterFabric, filterState.fabric); 2444 } 2445 } 2446 2447 // ââ Complete Look Zone ââââââââââââââââââââââââââââââââââââââââââââââââââââ 2448 function showClToast(zoneEl, message, type) { 2449 var existing = zoneEl.querySelector(".sg-cl-toast"); 2450 if (existing) existing.remove(); 2451 var t = document.createElement("div"); 2452 t.className = "sg-cl-toast sg-toast-" + (type || "success"); 2453 t.textContent = message; 2454 zoneEl.appendChild(t); 2455 setTimeout(function() { if (t.parentNode) t.remove(); }, 4000); 2456 } 2457 2458 hideCompleteLook = function() { 2459 currentCompleteLook = null; 2460 var zone = document.getElementById("sg-complete-look-zone"); 2461 if (zone) { zone.style.display = "none"; zone.innerHTML = ""; } 2462 } 2463 2464 showCompleteLook = function(outfit) { 2465 if (!outfit || !outfit.items || outfit.items.length === 0) return; 2466 currentCompleteLook = outfit; 2467 var zone = document.getElementById("sg-complete-look-zone"); 2468 if (!zone) return; 2469 2470 var pc = config.primaryColor || "#C6A85B"; 2471 var btnIconColor = isLightColor(pc) ? "#111" : "#fff"; 2472 var items = outfit.items || []; 2473 var title = outfit.title || "Complete Look"; 2474 var bundlePrice = outfit.bundlePrice || ""; 2475 2476 var thumbsHtml = ""; 2477 for (var ti = 0; ti < items.length; ti++) { 2478 var item = items[ti]; 2479 var imgSrc = item.image || ""; 2480 var itemName = item.name || "Item"; 2481 var itemCat = item.category || ""; 2482 var variantId = item.variantId || ""; 2483 var soldOut = item.soldOut ? "true" : "false"; 2484 var shortName = itemName.split(" ").slice(0, 3).join(" "); 2485 var thumbContent = imgSrc 2486 ? '<img src="' + escapeHtml(imgSrc) + '" class="sg-cl-thumb" alt="' + escapeHtml(itemName) + '" loading="lazy" />' 2487 : '<div class="sg-cl-thumb-placeholder"></div>'; 2488 var swapOverlay = itemCat 2489 ? '<div class="sg-cl-swap-overlay"><button class="sg-cl-swap-btn" data-cat="' + escapeHtml(itemCat) + '" type="button">Swap</button></div>' 2490 : ''; 2491 var removeBtn = outfit.removable 2492 ? '<button class="sg-cl-remove-btn" data-key="' + escapeHtml(sgLookKey(item)) + '" type="button" title="Remove from look" style="position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;border:none;background:rgba(0,0,0,0.55);color:#fff;font-size:12px;line-height:18px;padding:0;cursor:pointer;z-index:2">Ã</button>' 2493 : '';
2494 thumbsHtml += '<div class="sg-cl-item" data-variant-id="' + escapeHtml(variantId) + '" data-sold-out="' + soldOut + '">' 2495 + '<div class="sg-cl-thumb-wrap" style="position:relative">' + thumbContent + swapOverlay + removeBtn + '</div>' 2496 + '<div class="sg-cl-item-name">' + escapeHtml(shortName) + '</div>' 2497 + '</div>'; 2498 } 2499 2500 var availCount = 0; 2501 for (var ai = 0; ai < items.length; ai++) { 2502 if (!items[ai].soldOut && (items[ai].variantId || items[ai].productId)) availCount++; 2503 } 2504 var addBtnHtml = availCount >= 2 2505 ? '<button class="sg-cl-add-btn" type="button">' + cartIcon(btnIconColor) + ' Add Complete Look</button>' 2506 : ''; 2507 2508 zone.innerHTML = '<div class="sg-cl-header">' 2509 + '<div class="sg-cl-title">' + bundleIcon(pc) + '<span class="sg-cl-title-text">' + escapeHtml(title) + '</span></div>' 2510 + '<button class="sg-cl-dismiss" type="button" title="Dismiss">\u00d7</button>' 2511 + '</div>' 2512 + '<div class="sg-cl-strip">' + thumbsHtml + '</div>' 2513 + '<div class="sg-cl-footer">' 2514 + (bundlePrice ? '<span class="sg-cl-total">' + escapeHtml(bundlePrice) + ' total</span>' : '<span></span>') 2515 + addBtnHtml 2516 + '</div>'; 2517 2518 zone.style.display = "block"; 2519 2520 var dismissBtn = zone.querySelector(".sg-cl-dismiss"); 2521 if (dismissBtn) dismissBtn.addEventListener("click", function() { 2522 // Dismissing the look starts a fresh one. 2523 sessionLook = []; 2524 hideCompleteLook(); 2525 }); 2526 2527 var removeBtns = zone.querySelectorAll(".sg-cl-remove-btn"); 2528 for (var ri = 0; ri < removeBtns.length; ri++) { 2529 (function(rBtn) { 2530 rBtn.addEventListener("click", function(ev) { 2531 ev.stopPropagation(); 2532 removeFromSessionLook(rBtn.getAttribute("data-key")); 2533 }); 2534 })(removeBtns[ri]); 2535 } 2536 2537 var swapBtns = zone.querySelectorAll(".sg-cl-swap-btn"); 2538 for (var si = 0; si < swapBtns.length; si++) { 2539 (function(swapBtn) { 2540 swapBtn.addEventListener("click", function() { 2541 var cat = swapBtn.getAttribute("data-cat"); 2542 if (cat && filterCategory) { 2543 filterState.category = cat; 2544 filterCategory.value = cat; 2545 updateFilterActiveStyles(); 2546 loadFilteredProducts(); 2547 // Update header label to show we're browsing for a swap 2548 var lbl = document.getElementById("sg-products-header-label"); 2549 if (lbl) lbl.textContent = "Browse to swap"; 2550 } 2551 }); 2552 })(swapBtns[si]); 2553 } 2554 2555 var addBtn = zone.querySelector(".sg-cl-add-btn"); 2556 if (addBtn) { 2557 addBtn.addEventListener("click", function() { addZoneOutfitToCart(addBtn, outfit, zone); }); 2558 } 2559 2560 // New outfit: bring the pane back to the top so it's in view, 2561 // with the product grid scrollable beneath it. 2562 var scrollWrap = document.getElementById("sg-products-scroll"); 2563 if (scrollWrap) scrollWrap.scrollTop = 0; 2564 } 2565 2566 function addZoneOutfitToCart(btn, outfit, zoneEl) { 2567 var items = outfit.items || []; 2568 var lines = []; 2569 var unsized = []; 2570 for (var i = 0; i < items.length; i++) { 2571 var vid = items[i].variantId; 2572 if (vid && !items[i].soldOut) lines.push({ variantId: vid, quantity: 1, name: items[i].name || "" }); 2573 else if (!vid && !items[i].soldOut) unsized.push(items[i].name || "an item"); 2574 } 2575 if (lines.length === 0) { 2576 showClToast(zoneEl, "Please select sizes using the product cards below before adding.", "warn"); 2577 return; 2578 } 2579 // Never silently drop pieces: sized items go in, but say what's missing. 2580 if (unsized.length > 0) { 2581 showClToast(zoneEl, "Adding " + lines.length + " â still need a size for: " + unsized.join(", "), "warn"); 2582 } 2583 2584 var pc = config.primaryColor || "#C6A85B"; 2585 var btnIconColor = isLightColor(pc) ? "#111" : "#fff"; 2586 trackEvent("add_bundle_clicked", { itemCount: lines.length, source: "complete_look_zone" }); 2587 btn.disabled = true; 2588 btn.innerHTML = '<span style="width:14px;
2588height:14px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;display:inline-block;animation:sg-spin 0.6s linear infinite"></span> Adding ' + lines.length + ' items\u2026'; 2589 2590 var styleEl = document.getElementById("sg-spin-style"); 2591 if (!styleEl) { 2592 styleEl = document.createElement("style"); 2593 styleEl.id = "sg-spin-style"; 2594 styleEl.textContent = '@keyframes sg-spin { to { transform: rotate(360deg); } }'; 2595 document.head.appendChild(styleEl); 2596 } 2597 2598 var bundleUrl = apiUrl("/cart/add-bundle"); 2599 var xhr = new XMLHttpRequest(); 2600 xhr.open("POST", bundleUrl); 2601 xhr.setRequestHeader("Content-Type", "application/json"); 2602 xhr.onreadystatechange = function() { 2603 if (xhr.readyState !== 4) return; 2604 if (xhr.status === 429) { 2605 btn.disabled = false; 2606 btn.innerHTML = cartIcon(btnIconColor) + ' Add Complete Look'; 2607 showClToast(zoneEl, "Please wait a moment before trying again.", "warn"); 2608 return; 2609 } 2610 try { 2611 var data = JSON.parse(xhr.responseText); 2612 if (!data.ok) { 2613 btn.disabled = false; 2614 btn.innerHTML = cartIcon(btnIconColor) + ' Add Complete Look'; 2615 showClToast(zoneEl, data.error || "Failed to add to cart.", "error"); 2616 return; 2617 } 2618 var addedCount = data.added ? data.added.length : 0; 2619 if (addedCount > 0) { 2620 var cartItems = data.added.map(function(a) { 2621 return { id: parseInt(a.variantId.replace(/\D/g, ""), 10), quantity: a.quantity }; 2622 }); 2623 var cartXhr = new XMLHttpRequest(); 2624 cartXhr.open("POST", "/cart/add.js"); 2625 cartXhr.setRequestHeader("Content-Type", "application/json"); 2626 cartXhr.onreadystatechange = function() { 2627 if (cartXhr.readyState === 4) { 2628 if (cartXhr.status === 200 || cartXhr.status === 201) { 2629 btn.classList.add("sg-added"); 2630 btn.innerHTML = checkIcon(btnIconColor) + ' Added to Bag!'; 2631 markCartAttributed(); 2632 trackEvent("add_bundle_success", { itemCount: addedCount, source: "complete_look_zone", items: (lines || []).slice(0, 10).map(function(l){ return { name: l.name || "", variantId: String(l.variantId || "") }; }) }); 2633 if (data.removedUnavailable && data.removedUnavailable.length > 0) { 2634 showClToast(zoneEl, addedCount + " items added. " + data.removedUnavailable.length + " item(s) are currently unavailable.", "warn"); 2635 } else { 2636 showClToast(zoneEl, "Complete look added to your bag!", "success"); 2637 } 2638 } else { 2639 btn.disabled = false; 2640 btn.innerHTML = cartIcon(btnIconColor) + ' Add Complete Look'; 2641 showClToast(zoneEl, "Could not add to cart. Please try again.", "error"); 2642 } 2643 } 2644 }; 2645 cartXhr.send(JSON.stringify({ items: cartItems })); 2646 } else { 2647 btn.disabled = false; 2648 btn.innerHTML = cartIcon(btnIconColor) + ' Add Complete Look'; 2649 showClToast(zoneEl, data.removedUnavailable && data.removedUnavailable.length > 0 2650 ? "These items are currently unavailable. Please check back soon." 2651 : "Nothing to add â some items may be sold out.", "warn"); 2652 } 2653 } catch(e) { 2654 btn.disabled = false; 2655 btn.innerHTML = cartIcon(btnIconColor) + ' Add Complete Look'; 2656 showClToast(zoneEl, "Something went wrong. Please try again.", "error"); 2657 } 2658 }; 2659 xhr.send(JSON.stringify({ lines: lines })); 2660 } 2661 2662 // Setting select.value to a value with no matching <option> renders the 2663 // pill gold-highlighted but BLANK (facets not loaded yet, or a case 2664 // mismatch like "suits" vs "Suits"). Match case-insensitively and adopt 2665 // the option's actual value; if nothing matches, append a temporary 2666 // title-cased option so the pill always shows its text. 2667 function syncSelectValue(sel, value) { 2668 if (!sel || !value) return value; 2669 var wanted = String(value).toLowerCase(); 2670 for (var oi = 0; oi < sel.options.length; oi++) { 2671 if (sel.options[oi].value && sel.options[oi].value.toLowerCase() === wanted) { 2672 sel.value = sel.options[oi].value; 2673 return sel.options[oi].value; 2674 } 2675 } 2676 var opt = document.createElement("option"); 2677 opt.value = value; 2678 opt.textContent = String(value).replace(/\b[a-z]/g, function(c) { return c.toUpperCase(); }); 2679 opt.setAttribute("data-sg-temp", "1"); 2680 sel.appendChild(opt); 2681 sel.value = value; 2682 return value; 2683 } 2684 2685 applyAIFilters = function(cmd, opts) { 2686 pendingChatPreview = !!(opts && opts.chatPreview); 2687 // cmd: { category?, size?, colour?, query?, inStockOnly? } 2688 // A new grid command is a new intent: start from a clean slate so stale 2689 // constraints from earlier turns (a colour, an old query) can't leak in 2690 // and silently narrow what the customer just asked to see. 2691 filterState = { query: "", category: "", colour: "", size: "", priceRange: "", sort: "", inStockOnly: false, onSale: false, fabric: "" }; 2692 if (filterSearch) filterSearch.value = ""; 2693 if (filterCategory) filterCategory.value = ""; 2694 if (filterColour) filterColour.value = ""; 2695 if (filterFabric) filterFabric.value = ""; 2696 if (filterSize) filterSize.value = ""; 2697 var changed = true; 2698 if (cmd.category !== undefined) { filterState.category = cmd.category; changed = true; } 2699 if (cmd.colour !== undefined) { filterState.colour = cmd.colour; changed = true; } 2700 if (cmd.size !== undefined) { filterState.size = cmd.size; changed = true; } 2701 if (cmd.query !== undefined) { filterState.query = cmd.query; changed = true; } 2702 if (cmd.inStockOnly !== undefined) { filterState.inStockOnly = !!cmd.inStockOnly; changed = true; } 2703 if (cmd.onSale !== undefined) { filterState.onSale = !!cmd.onSale; changed = true; } 2704 if (cmd.fabric !== undefined) { filterState.fabric = cmd.fabric; changed = true; } 2705 // Sync DOM â adopt the matched option's actual value so the state and 2706 // the pill can never disagree. 2707 if (filterCategory && filterState.category) filterState.category = syncSelectValue(filterCategory, filterState.category); 2708 if (filterColour && filterState.colour) filterState.colour = syncSelectValue(filterColour, filterState.colour); 2709 if (filterSize && filterState.size) filterState.size = syncSelectValue(filterSize, filterState.size); 2710 if (filterSearch && filterState.query) filterSearch.value = filterState.query; 2711 updateFilterActiveStyles(); 2712 // Relax the AI's guesses before the customer's own words: category is 2713 // the agent's mapping, size is inferred â the query is what they SAID. 2714 aiFilterRelax = []; 2715 if (cmd.category) aiFilterRelax.push("category"); 2716 if (cmd.size) aiFilterRelax.push("size"); 2717 if (cmd.query) aiFilterRelax.push("query"); 2718 if (changed) loadFilteredProducts(); 2719 }; 2720 2721 function clearFilters() { 2722 filterState = { query: "", category: "", colour: "", size: "", priceRange: "", sort: "", inStockOnly: false, onSale: false, fabric: "" }; 2723 landingTags = []; 2724 aiFilterRelax = []; 2725 if (filterSearch) filterSearch.value = ""; 2726 if (filterCategory) filterCategory.value = ""; 2727 if (filterColour) filterColour.value = ""; 2728 if (filterFabric) filterFabric.value = ""; 2729 if (filterSize) filterSize.value = ""; 2730 if (filterPrice) filterPrice.value = ""; 2731 if (filterSort) filterSort.value = ""; 2732 updateFilterActiveStyles(); 2733 if (headerLabel) headerLabel.textContent = T("browse"); 2734 loadFilteredProducts(); 2735 } 2736 2737 // Proactive filter: detect product type from user message and pre-populate pane immediately 2738 detectAndApplyProactiveFilter = function(userText) { 2739 var text = (userText || "").toLowerCase(); 2740 var catMap = [ 2741 [["shirt","shirts","dress shirt","formal shirt","casual shirt"], "shirts"], 2742 [["shoe","shoes","brogue","brogues","oxford","oxfords","loafer","loafers","derby","monk strap","footwear"], "shoes"], 2743 [["tie","ties","cravat","bow tie","bow ties","necktie"], "ties"], 2744 [["trouser","trousers","pant","pants","chino","chinos"], "trousers"], 2745 [["suit","suits"], "suits"], 2746 [["knit","knitwear","jumper","sweater","cardigan","pullover"], "knitwear"], 2747 [["belt","cufflink","cufflinks","pocket square","accessory","accessories","sock","socks"], "accessories"] 2748 ]; 2749 // Garment types with no store category of their own (blazers, coats, 2750 // polos...) go in the QUERY â mapping them to a guessed category shows 2751 // the customer a grid of something they didn't ask for. 2752 var offCategoryNouns = ["blazer", "sport coat", "overcoat", "waistcoat", "polo", "gilet", "jacket", "coat"]; 2753 var detectedNounQuery = ""; 2754 for (var ni = 0; ni < offCategoryNouns.length; ni++) { 2755 if (text.indexOf(offCategoryNouns[ni]) !== -1) { detectedNounQuery = offCategoryNouns[ni]; break; } 2756 } 2757 var detectedCategory = ""; 2758 for (var ci = 0; ci < catMap.length; ci++) { 2759 var words = catMap[ci][0]; 2760 for (var wi = 0; wi < words.length; wi++) { 2761 if (text.indexOf(words[wi]) !== -1) { detectedCategory = catMap[ci][1]; break; } 2762 } 2763 if (detectedCategory) break; 2764 } 2765 var colours = ["white","black","navy","blue","grey","gray","brown","beige","tan","burgundy","red","green","charcoal","cream","pink","khaki"]; 2766 var detectedColour = ""; 2767 for (var ci2 = 0; ci2 < colours.length; ci2++) { 2768 if (text.indexOf(colours[ci2]) !== -1) { detectedColour = colours[ci2] === "gray" ? "grey" : colours[ci2]; break; } 2769 } 2770 // Size: collar sizes like 15.5, jacket sizes like 40R/42S, waist sizes like 32/34, shoe sizes like 8/9/10 2771 var sizeMatch = text.match(/\b(\d{1,2}(?:\.\d)?(?:r|s|l|xl|xxl)?)\b/); 2772 var detectedSize = sizeMatch ? sizeMatch[1] : ""; 2773 if (detectedNounQuery || detectedCategory || detectedColour) { 2774 var cmd = {}; 2775 if (detectedNounQuery) { cmd.query = detectedNounQuery; } 2776 else if (detectedCategory) { cmd.category = detectedCategory; } 2777 if (detectedColour) cmd.colour = detectedColour; 2778 if (detectedSize) cmd.size = detectedSize; 2779 applyAIFilters(cmd); 2780 proactiveFilterApplied = true; 2781 } 2782 }; 2783 2784 // Wire filter controls 2785 if (filterSearch) { 2786 filterSearch.addEventListener("input", function() { 2787 filterState.query = filterSearch.value.trim(); 2788 updateFilterClearBtn(); 2789 clearTimeout(filterDebounceTimer); 2790 filterDebounceTimer = setTimeout(loadFilteredProducts, 350); 2791 }); 2792 } 2793 if (filterCategory) { 2794 filterCategory.addEventListener("change", function() { 2795 filterState.category = filterCategory.value; 2796 // Sizes are category-specific (collar vs shoe vs chest) â a stale size 2797 // from the previous category silently filters everything to zero. 2798 filterState.size = ""; 2799 if (filterSize) filterSize.value = ""; 2800 updateFilterActiveStyles(); 2801 loadFilteredProducts(); 2802 }); 2803 } 2804 if (filterColour) { 2805 filterColour.addEventListener("change", function() { 2806 filterState.colour = filterColour.value; 2807 updateFilterActiveStyles(); 2808 loadFilteredProducts(); 2809 }); 2810 } 2811 if (filterFabric) { 2812 filterFabric.addEventListener("change", function() { 2813 filterState.fabric = filterFabric.value; 2814 updateFilterActiveStyles(); 2815 loadFilteredProducts(); 2816 }); 2817 } 2818 if (filterSize) { 2819 filterSize.addEventListener("change", function() { 2820 filterState.size = filterSize.value; 2821 updateFilterActiveStyles(); 2822 loadFilteredProducts(); 2823 }); 2824 } 2825 if (filterPrice) { 2826 filterPrice.addEventListener("change", function() { 2827 filterState.priceRange = filterPrice.value; 2828 updateFilterActiveStyles(); 2829 loadFilteredProducts(); 2830 }); 2831 } 2832 if (filterSort) {
2833 filterSort.addEventListener("change", function() { 2834 filterState.sort = filterSort.value; 2835 updateFilterActiveStyles(); 2836 loadFilteredProducts(); 2837 }); 2838 } 2839 if (filterInstock) { 2840 filterInstock.addEventListener("click", function() { 2841 filterState.inStockOnly = !filterState.inStockOnly; 2842 updateFilterActiveStyles(); 2843 loadFilteredProducts(); 2844 }); 2845 } 2846 if (filterClear) { 2847 filterClear.addEventListener("click", clearFilters); 2848 } 2849 2850 // Load products + facets on open 2851 loadFilteredProducts(); 2852 2853 var micBtn = document.getElementById("sg-modal-mic"); 2854 if (micBtn) { 2855 micBtn.onclick = (config.speechEngine && PROXY_PATH) 2856 ? function() { sgLiveConv ? sgStopLiveVoice() : sgStartLiveVoice(); } 2857 : toggleMic; 2858 } 2859 2860 var speakerBtn = document.getElementById("sg-speaker-toggle"); 2861 if (speakerBtn) { speakerBtn.onclick = toggleSpeaker; updateSpeakerButton(); } 2862 2863 var tabChat = document.getElementById("sg-tab-chat"); 2864 var tabOutfits = document.getElementById("sg-tab-outfits"); 2865 if (tabChat) tabChat.onclick = function() { switchTab("chat"); }; 2866 if (tabOutfits) tabOutfits.onclick = function() { switchTab("products"); }; 2867 2868 renderMessages(); 2869 renderProducts([]); 2870 } 2871 2872 var activeTab = "chat"; 2873 window.addEventListener("resize", function () { 2874 if (isModalOpen) switchTab(activeTab || "chat"); 2875 }); 2876 try { if (sessionStorage.getItem("sg-await-reply") === "1") { setTimeout(function () { startReplyPolling(); }, 3000); } } catch (e) {} 2877 2878 // On phones the product grid lives behind the Outfits tab â when the agent 2879 // presents products, drop a tappable chip into the chat so the customer can 2880 // actually reach them (real shoppers repeated queries because replies said 2881 // "here are the ties" while the results sat on the hidden tab). 2882 function showViewProductsChip(label) { 2883 if (window.innerWidth > 640 || activeTab !== "chat") return; 2884 var container = document.getElementById("sg-messages"); 2885 if (!container) return; 2886 var old = document.getElementById("sg-view-products-chip"); 2887 if (old) old.remove(); 2888 var div = document.createElement("div"); 2889 div.id = "sg-view-products-chip"; 2890 div.className = "sg-msg sg-msg-assistant"; 2891 var pc = config.primaryColor || "#C6A85B"; 2892 div.innerHTML = '<button style="display:inline-flex;align-items:center;gap:8px;padding:10px 18px;border:none;border-radius:22px;background:' + pc + ';color:#fff;font-size:14px;font-weight:600;font-family:inherit;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.18);line-height:1.2;">' + escapeHtml(label) + ' â</button>'; 2893 div.firstChild.onclick = function() { switchTab("products"); }; 2894 container.appendChild(div); 2895 container.scrollTop = container.scrollHeight; 2896 } 2897 2898 // After an error reply, offer a one-tap resend of the message that failed. 2899 // Matches the welcome-chip styling; removes itself once used. 2900 function showRetryChip() { 2901 if (!lastSentText) return; 2902 var container = document.getElementById("sg-messages"); 2903 if (!container) return; 2904 var old = document.getElementById("sg-retry-chip"); 2905 if (old) old.remove(); 2906 var div = document.createElement("div"); 2907 div.id = "sg-retry-chip"; 2908 div.className = "sg-msg sg-msg-assistant"; 2909 var btn = document.createElement("button"); 2910 btn.className = "sg-suggestion"; 2911 btn.textContent = T("tryAgain"); 2912 btn.onclick = function() { 2913 if (isLoading) return; 2914 trackEvent("retry_used"); 2915 div.remove(); 2916 sendToAPI(lastSentText); 2917 }; 2918 div.appendChild(btn); 2919 container.appendChild(div); 2920 container.scrollTop = container.scrollHeight; 2921 } 2922 2923 function switchTab(tab) { 2924 activeTab = tab; 2925 var chatCol = document.getElementById("sg-chat-col"); 2926 var productsCol = document.getElementById("sg-products-col"); 2927 var tabChatBtn = document.getElementById("sg-tab-chat"); 2928 var tabOutfitsBtn = document.getElementById("sg-tab-outfits"); 2929 var isMobile = window.innerWidth <= 640; 2930 if (!chatCol || !productsCol) return; 2931 if (!isMobile) { 2932 chatCol.classList.remove("sg-tab-hidden"); 2933 productsCol.classList.remove("sg-tab-hidden"); 2934 return; 2935 } 2936 var browseBtn = document.getElementById("sg-browse-btn"); 2937 var browseIcGrid = document.getElementById("sg-browse-ic-grid"); 2938 var browseIcChat = document.getElementById("sg-browse-ic-chat"); 2939 if (tab === "chat") { 2940 chatCol.classList.remove("sg-tab-hidden"); 2941 productsCol.classList.add("sg-tab-hidden"); 2942 if (tabChatBtn) tabChatBtn.classList.add("sg-active"); 2943 if (tabOutfitsBtn) tabOutfitsBtn.classList.remove("sg-active"); 2944 if (browseBtn) { browseBtn.classList.remove("sg-active"); browseBtn.title = "Browse products"; } 2945 if (browseIcGrid) browseIcGrid.style.display = "flex"; 2946 if (browseIcChat) browseIcChat.style.display = "none"; 2947 } else { 2948 chatCol.classList.add("sg-tab-hidden"); 2949 productsCol.classList.remove("sg-tab-hidden"); 2950 if (tabChatBtn) tabChatBtn.classList.remove("sg-active"); 2951 if (tabOutfitsBtn) tabOutfitsBtn.classList.add("sg-active"); 2952 if (browseBtn) { browseBtn.classList.add("sg-active"); browseBtn.title = "Back to chat"; } 2953 if (browseIcGrid) browseIcGrid.style.display = "none"; 2954 if (browseIcChat) browseIcChat.style.display = "flex"; 2955 } 2956 } 2957 2958 function openModal(initialQuery) { 2959 createModal(); 2960 isModalOpen = true; 2961 trackEvent("widget_opened"); 2962 sgPrefetchVoice(); // warm the live-voice token+SDK so the mic starts instantly 2963 setTimeout(showVoiceInvite, 900);
2964 var overlay = document.getElementById("sg-overlay"); 2965 // Re-append so the overlay is the last element in <body>: third-party 2966 // floating badges (reviews, chat bubbles) often use the same max z-index, 2967 // and later DOM order wins the tie. 2968 if (overlay.parentNode === document.body && document.body.lastElementChild !== overlay) { 2969 document.body.appendChild(overlay); 2970 } 2971 overlay.style.display = "flex"; 2972 requestAnimationFrame(function() { 2973 requestAnimationFrame(function() { 2974 overlay.classList.add("sg-visible"); 2975 }); 2976 }); 2977 document.body.style.overflow = "hidden"; 2978 // Hide the bell while the widget is open â it otherwise floats on top of 2979 // the modal on small screens. 2980 var fabEl = document.getElementById("sg-fab"); 2981 if (fabEl) fabEl.style.display = "none"; 2982 var topBtn = document.getElementById("sg-top-button"); 2983 if (topBtn) topBtn.style.display = "none"; 2984 2985 // Mobile shows ONE column at a time â chat first; the tab bar moves 2986 // between them. Without this both columns open side-by-side, squashed. 2987 switchTab(activeTab || "chat"); 2988 2989 onUserGesture(); 2990 2991 var chatInput = document.getElementById("sg-chat-input"); 2992 if (initialQuery) { 2993 chatInput.value = initialQuery; 2994 sendMessage(); 2995 } else { 2996 setTimeout(function() { chatInput.focus(); }, 200); 2997 } 2998 } 2999 3000 function closeModal() { 3001 isModalOpen = false; 3002 sgStopLiveVoice(); // closing the widget ends the call â no disembodied voice 3003 try { if (window.speechSynthesis) window.speechSynthesis.cancel(); } catch (e) {} 3004 var overlay = document.getElementById("sg-overlay"); 3005 if (!overlay) return; 3006 overlay.classList.remove("sg-visible"); 3007 document.body.style.overflow = ""; 3008 setTimeout(function() { 3009 overlay.style.display = "none"; 3010 }, 300); 3011 var fabEl = document.getElementById("sg-fab"); 3012 if (fabEl) fabEl.style.display = ""; 3013 var topBtn2 = document.getElementById("sg-top-button"); 3014 if (topBtn2) topBtn2.style.display = ""; 3015 if (currentAudio) { 3016 try { 3017 if (currentAudio.pause) currentAudio.pause(); 3018 else currentAudio.stop(); 3019 } catch(e) {} 3020 currentAudio = null; 3021 setSpeakingState(false); 3022 } 3023 } 3024 3025 function restoreConversation() { 3026 if (!sessionId || !shopDomain) return; 3027 var xhr = new XMLHttpRequest(); 3028 xhr.open("GET", apiUrl("/history?shop=" + encodeURIComponent(shopDomain) + "&sessionId=" + encodeURIComponent(sessionId))); 3029 xhr.onreadystatechange = function() { 3030 if (xhr.readyState === 4 && xhr.status === 200) { 3031 try { 3032 var data = JSON.parse(xhr.responseText); 3033 if (data.messages && data.messages.length > 0) { 3034 // An existing conversation counts as already-gated â returning 3035 // shoppers are never re-asked for their details mid-journey. 3036 sgMarkLeadDone(); 3037 messages = []; 3038 for (var i = 0; i < data.messages.length; i++) { 3039 var m = data.messages[i]; 3040 messages.push({ 3041 role: m.role, 3042 content: m.content, 3043 outfits: m.outfits || undefined 3044 }); 3045 if (m.outfits && m.outfits.length > 0) { 3046 lastOutfits = m.outfits; 3047 mergeIntoSessionLook(m.outfits[0]); 3048 } 3049 } 3050 renderMessages(); 3051 if (sessionLook.length > 0) { 3052 renderSessionLook(); 3053 showDiscussedProducts([{ items: sessionLook }]); 3054 } 3055 } 3056 } catch(e) {} 3057 } 3058 }; 3059 xhr.send(); 3060 } 3061 3062 function sendMessage() { 3063 var chatInput = document.getElementById("sg-chat-input"); 3064 var text = chatInput.value.trim(); 3065 if (!text || isLoading) return; 3066 3067 // Barge-in: a new question interrupts the answer still being spoken. 3068 // Runs before onUserGesture so the first-gesture welcome audio isn't 3069 // silenced the instant it starts. 3070 if (stopSpeaking()) trackEvent("voice_bargein", { source: "send" }); 3071 3072 onUserGesture(); 3073 // Remember that this shopper has actually used the widget â later visits 3074 // greet them as returning instead of repeating the first-time welcome. 3075 try { localStorage.setItem("sg_used_" + shopDomain, "1"); } catch (e) {} 3076 chatInput.value = ""; 3077 document.getElementById("sg-chat-send").disabled = true; 3078 3079 switchTab("chat"); // ensure chat is visible while waiting for response 3080 trackEvent("message_sent"); 3081 refreshSessionTimestamp(); // keep 24-hour window alive while actively chatting 3082 messages.push({ role: "user", content: text }); 3083 renderMessages(); 3084 if (audioEnabled) unlockSpeech(); // Safari: unlock during the send gesture 3085 // Immediately filter right pane based on what user typed (before AI responds) 3086 proactiveFilterApplied = false;
3087 detectAndApplyProactiveFilter(text); 3088 sendToAPI(text); 3089 } 3090 3091 var audioEnabled = false; 3092 var welcomeAudioUrl = null; 3093 var welcomeAudioPlayed = false; 3094 var welcomeAudioFetched = false; 3095 var sgAudioEl = null; 3096 // Second half of a split reply, preloading while the first half plays. 3097 var sgQueuedUrl = null; 3098 3099 function getSgAudioEl() { 3100 if (!sgAudioEl) { 3101 sgAudioEl = document.createElement("audio"); 3102 sgAudioEl.id = "sg-audio-player"; 3103 sgAudioEl.preload = "auto"; 3104 sgAudioEl.style.display = "none"; 3105 document.body.appendChild(sgAudioEl); 3106 sgAudioEl.addEventListener("ended", function() { 3107 if (sgQueuedUrl && audioEnabled) { 3108 var next = sgQueuedUrl; 3109 sgQueuedUrl = null; 3110 sgPlayUrl(next); 3111 return; 3112 } 3113 setSpeakingState(false); 3114 currentAudio = null; 3115 }); 3116 sgAudioEl.addEventListener("error", function() { 3117 sgQueuedUrl = null; 3118 setSpeakingState(false); 3119 currentAudio = null; 3120 }); 3121 } 3122 return sgAudioEl; 3123 } 3124 3125 function sgPlayUrl(url) { 3126 var el = getSgAudioEl(); 3127 el.volume = 1.0; // explicit â no state carry-over between clips 3128 el.playbackRate = 1.0; 3129 el.src = url; 3130 el.load(); 3131 setSpeakingState(true); 3132 currentAudio = el; 3133 trackEvent("voice_played"); 3134 var p = el.play(); 3135 if (p && p.catch) p.catch(function() { 3136 setSpeakingState(false); 3137 currentAudio = null; 3138 }); 3139 } 3140 3141 function fetchWelcomeAudio() { 3142 if (!shopDomain || welcomeAudioFetched) return; 3143 if (config.interactionMode !== "voice_stylist" && config.interactionMode !== "concierge") return; 3144 welcomeAudioFetched = true; 3145 var xhr = new XMLHttpRequest(); 3146 xhr.open("GET", apiUrl("/welcome-audio?shop=" + encodeURIComponent(shopDomain))); 3147 xhr.onreadystatechange = function() { 3148 if (xhr.readyState === 4 && xhr.status === 200) { 3149 try { 3150 var data = JSON.parse(xhr.responseText); 3151 if (data.audioUrl) { 3152 welcomeAudioUrl = PROXY_PATH ? data.audioUrl : (API_BASE + data.audioUrl); 3153 var el = getSgAudioEl(); 3154 el.src = welcomeAudioUrl; 3155 el.load(); 3156 } 3157 } catch(e) {} 3158 } 3159 }; 3160 xhr.send(); 3161 } 3162 3163 function onUserGesture() { 3164 if (welcomeAudioPlayed || !audioEnabled) return; 3165 welcomeAudioPlayed = true; 3166 if (welcomeAudioUrl) { 3167 sgPlayUrl(welcomeAudioUrl); 3168 } 3169 } 3170 3171 // Toggle-on confirmation in the stylist's server voice â fetched fresh each 3172 // time (TTS clips expire server-side); browser speech only as a fallback. 3173 var SG_SILENT_WAV = "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA="; 3174 function playConfirmAudio() { 3175 var xhr = new XMLHttpRequest(); 3176 xhr.open("GET", apiUrl("/welcome-audio?kind=confirm&shop=" + encodeURIComponent(shopDomain))); 3177 xhr.onreadystatechange = function() { 3178 if (xhr.readyState !== 4 || !audioEnabled) return; 3179 var url = null; 3180 if (xhr.status === 200) { 3181 try { url = JSON.parse(xhr.responseText).audioUrl; } catch (e) {} 3182 } 3183 if (url) { 3184 sgPlayUrl(PROXY_PATH ? url : (API_BASE + url)); 3185 } else { 3186 speakFallback("Voice replies are on."); 3187 } 3188 }; 3189 xhr.send(); 3190 } 3191 3192 function updateSpeakerButton() { 3193 var btn = document.getElementById("sg-speaker-toggle"); 3194 if (!btn) return; 3195 btn.classList.toggle("sg-active", audioEnabled); 3196 btn.title = audioEnabled ? "Voice replies: on" : "Voice replies: off"; 3197 // unmistakable state: gold-filled when on, slashed grey when off 3198 btn.innerHTML = (audioEnabled ? speakerIcon("currentColor") : speakerMutedIcon("currentColor")); 3199 btn.style.background = audioEnabled ? (config.primaryColor || "#C6A85B") : ""; 3200 btn.style.color = audioEnabled ? "#fff" : ""; 3201 } 3202 3203 function toggleSpeaker() { 3204 audioEnabled = !audioEnabled; 3205 updateSpeakerButton(); 3206 if (!audioEnabled) { 3207 sgQueuedUrl = null; 3208 try { if (window.speechSynthesis) window.speechSynthesis.cancel(); } catch (e) {} 3209 if (currentAudio) { 3210 try { 3211 if (currentAudio.pause) currentAudio.pause(); 3212 else currentAudio.stop(); 3213 } catch(e) {} 3214 currentAudio = null; 3215 } 3216 setSpeakingState(false); 3217 } else { 3218 // Unlock the shared audio element inside this click gesture (Safari), 3219 // then confirm in the same server voice used for replies. 3220 var el = getSgAudioEl();
3221 try { el.src = SG_SILENT_WAV; var p = el.play(); if (p && p.catch) p.catch(function() {}); } catch (e) {} 3222 playConfirmAudio(); 3223 } 3224 } 3225 3226 function toggleMic() { 3227 if (isListening) { 3228 stopListening(); 3229 } else { 3230 startListening(); 3231 } 3232 } 3233 3234 function startListening() { 3235 var SR = window.SpeechRecognition || window.webkitSpeechRecognition; 3236 if (!SR) return; 3237 // Barge-in: the customer talking always silences the stylist. 3238 if (stopSpeaking()) trackEvent("voice_bargein", { source: "mic" }); 3239 recognition = new SR(); 3240 recognition.continuous = false; 3241 recognition.interimResults = true; 3242 // Recognise in the storefront's language â a US model mishears British 3243 // accents (and non-English shoppers entirely), which reads as lag. 3244 var SR_LANGS = { en: "en-GB", fr: "fr-FR", de: "de-DE", es: "es-ES", it: "it-IT", nl: "nl-NL" }; 3245 recognition.lang = SR_LANGS[sgLocale] || (navigator.language || "en-GB"); 3246 3247 recognition.onstart = function() { 3248 isListening = true; 3249 var btn = document.getElementById("sg-modal-mic"); 3250 if (btn) btn.classList.add("sg-listening"); 3251 }; 3252 // Live transcript: interim results echo into the input so the customer 3253 // sees they're being heard; the final result settles for 600ms (cleared 3254 // if more results arrive) and then auto-sends. Browsers without interim 3255 // support just deliver the one final result â same behaviour as before. 3256 var settleTimer = null; 3257 recognition.onresult = function(event) { 3258 var finalText = ""; 3259 var interimText = ""; 3260 for (var ri = 0; ri < event.results.length; ri++) { 3261 if (event.results[ri].isFinal) finalText += event.results[ri][0].transcript; 3262 else interimText += event.results[ri][0].transcript; 3263 } 3264 var chatInput = document.getElementById("sg-chat-input"); 3265 if (!chatInput) return; 3266 chatInput.value = (finalText + interimText).trim(); 3267 var sendBtn = document.getElementById("sg-chat-send"); 3268 if (sendBtn) sendBtn.disabled = !chatInput.value; 3269 if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; } 3270 if (finalText.trim()) { 3271 settleTimer = setTimeout(function() { 3272 var input2 = document.getElementById("sg-chat-input"); 3273 // Release the mic BEFORE sending: an open recognition session 3274 // ducks ALL playback at OS level on mobile, so a lingering mic 3275 // makes the stylist's reply play quiet. 3276 stopListening(); 3277 if (input2 && input2.value.trim()) sendMessage(); 3278 }, 350); 3279 } 3280 }; 3281 recognition.onerror = function() { stopListening(); }; 3282 recognition.onend = function() { stopListening(); }; 3283 // Show the listening state on the tap itself â onstart can lag a beat 3284 // and a silent button reads as broken. 3285 isListening = true; 3286 var micBtnNow = document.getElementById("sg-modal-mic"); 3287 if (micBtnNow) micBtnNow.classList.add("sg-listening"); 3288 try { 3289 recognition.start(); 3290 } catch (e) { 3291 // A just-stopped session can still be tearing down (rapid re-tap) â 3292 // one delayed retry covers it; a second failure gives up cleanly. 3293 var rec = recognition; 3294 setTimeout(function () { 3295 if (recognition !== rec) return; // user already toggled again 3296 try { rec.start(); } catch (e2) { stopListening(); } 3297 }, 250); 3298 } 3299 } 3300 3301 function stopListening() { 3302 isListening = false; 3303 if (recognition) { 3304 try { recognition.stop(); } catch(e) {} 3305 recognition = null; 3306 } 3307 var btn = document.getElementById("sg-modal-mic"); 3308 if (btn) btn.classList.remove("sg-listening"); 3309 } 3310 3311 function setSpeakingState(active) { 3312 isSpeaking = active; 3313 var btn = document.getElementById("sg-speaker-toggle"); 3314 if (btn) btn.classList.toggle("sg-speaking", active); 3315 } 3316 3317 // Barge-in: silence the stylist immediately â server audio, the queued tail 3318 // clip, and any browser speech. Returns true if something was actually 3319 // playing so callers can track real interruptions only. 3320 function stopSpeaking() { 3321 var wasPlaying = false;
3322 if (currentAudio) { 3323 wasPlaying = true; 3324 try { 3325 if (currentAudio.pause) currentAudio.pause(); 3326 else currentAudio.stop(); 3327 } catch(e) {} 3328 currentAudio = null; 3329 } 3330 sgQueuedUrl = null; 3331 try { 3332 if (window.speechSynthesis) { 3333 if (window.speechSynthesis.speaking) wasPlaying = true; 3334 window.speechSynthesis.cancel(); 3335 } 3336 } catch(e) {} 3337 if (isSpeaking) wasPlaying = true; 3338 setSpeakingState(false); 3339 return wasPlaying; 3340 } 3341 3342 // Early-audio protocol: the server may announce the first-sentence clip 3343 // mid-stream (type:"audio") so the voice starts while text is still arriving; 3344 // the done event then carries the tail clip to queue behind it. 3345 var sgEarlyAudioUrl = null; 3346 3347 function queueFollowUpAudio(audioUrl2) { 3348 if (!audioEnabled || !audioUrl2) return; 3349 var resolved = PROXY_PATH ? audioUrl2 : (API_BASE + audioUrl2); 3350 if (isSpeaking && currentAudio) { 3351 sgQueuedUrl = resolved; 3352 } else { 3353 sgPlayUrl(resolved); 3354 } 3355 } 3356 3357 function playAudio(audioUrl, audioUrl2) { 3358 if (!audioEnabled || !audioUrl) return; 3359 if (currentAudio) { 3360 try { 3361 if (currentAudio.pause) currentAudio.pause(); 3362 else currentAudio.stop(); 3363 } catch(e) {} 3364 currentAudio = null; 3365 } 3366 sgQueuedUrl = audioUrl2 ? (PROXY_PATH ? audioUrl2 : (API_BASE + audioUrl2)) : null; 3367 sgPlayUrl(PROXY_PATH ? audioUrl : (API_BASE + audioUrl)); 3368 } 3369 3370 // Browser-native speech when the server has no TTS configured â voice mode 3371 // works out of the box; server audio (when present) always takes priority. 3372 var sgVoice = null; 3373 function pickVoice() { 3374 if (sgVoice) return sgVoice; 3375 try { 3376 var voices = window.speechSynthesis.getVoices() || []; 3377 sgVoice = 3378 // Chrome's cloud voices are far more natural than local defaults 3379 voices.find(function(v) { return /Google UK English Female/i.test(v.name); }) || 3380 voices.find(function(v) { return /Google UK English/i.test(v.name); }) || 3381 // Safari/macOS premium British voices 3382 voices.find(function(v) { return /en-GB/i.test(v.lang) && /Serena|Daniel|Kate|Stephanie|Sonia/i.test(v.name); }) || 3383 voices.find(function(v) { return /Google US English/i.test(v.name); }) || 3384 voices.find(function(v) { return /en-GB/i.test(v.lang); }) || 3385 voices.find(function(v) { return /^en/i.test(v.lang); }) || 3386 null; 3387 } catch (e) {} 3388 return sgVoice; 3389 } 3390 if (window.speechSynthesis && window.speechSynthesis.onvoiceschanged !== undefined) { 3391 window.speechSynthesis.onvoiceschanged = function() { sgVoice = null; pickVoice(); }; 3392 } 3393 3394 // Safari refuses speech that doesn't originate from a user gesture. Speaking 3395 // a silent zero-length utterance DURING a gesture unlocks the engine for the 3396 // network-callback speech that follows. 3397 var speechUnlocked = false; 3398 function unlockSpeech() { 3399 if (speechUnlocked || !window.speechSynthesis) return; 3400 try { 3401 var u = new SpeechSynthesisUtterance(" "); 3402 u.volume = 0; 3403 window.speechSynthesis.speak(u); 3404 window.speechSynthesis.resume(); 3405 speechUnlocked = true; 3406 } catch (e) {} 3407 } 3408 3409 // Module-level reference: Chrome garbage-collects unreferenced utterances 3410 // mid-speech, which silences them without any error. 3411 var sgUtterance = null; 3412 3413 function speakFallback(text) { 3414 // When the server synthesises voice, a missing audioUrl means
3414silence â 3415 // never the robotic browser voice. The fallback only exists for 3416 // out-of-the-box installs with no TTS key on the server. 3417 if (config.serverVoice) return; 3418 if (!audioEnabled || !text || !window.speechSynthesis) return; 3419 try { 3420 window.speechSynthesis.cancel(); 3421 // strip markdown-ish noise so it reads naturally 3422 var clean = String(text).replace(/[*_#`]/g, "").replace(/\s+/g, " ").trim().slice(0, 1200); 3423 if (!clean) return; 3424 var u = new SpeechSynthesisUtterance(clean); 3425 var v = pickVoice(); 3426 if (v) u.voice = v; 3427 u.rate = 1.02; 3428 u.pitch = 1.0; 3429 u.onstart = function() { setSpeakingState(true); }; 3430 u.onend = function() { setSpeakingState(false); if (sgUtterance === u) sgUtterance = null; }; 3431 u.onerror = function() { setSpeakingState(false); if (sgUtterance === u) sgUtterance = null; }; 3432 sgUtterance = u; // keep alive against Chrome's GC 3433 // cancel() followed immediately by speak() races in Chrome â brief defer 3434 setTimeout(function() { 3435 try { 3436 window.speechSynthesis.speak(u); 3437 window.speechSynthesis.resume(); 3438 } catch (e3) {} 3439 }, 60); 3440 } catch (e) {} 3441 } 3442 3443 var lastOutfits = []; 3444 var captureShown = false; 3445 3446 function hasCaptureShown() { 3447 try { return sessionStorage.getItem("sg_capture_" + shopDomain) === "1"; } catch(e) { return false; } 3448 } 3449 function markCaptureShown() { 3450 captureShown = true; 3451 try { sessionStorage.setItem("sg_capture_" + shopDomain, "1"); } catch(e) {} 3452 } 3453 3454 var PURCHASE_SIGNALS = ["add to cart", "buy", "order", "purchase", "checkout", "i'll take", "i want", "can i get", "how do i order", "price", "stock", "available", "in stock"]; 3455 var SIZING_SIGNALS = ["size", "sizing", "chest", "waist", "collar", "fit", "too big", "too small", "measurements", "inside leg", "trouser", "jacket size"]; 3456 var SAVE_SIGNALS = ["save", "send me", "email me", "remember", "keep this", "love this", "perfect outfit", "this is the one", "i like this"]; 3457 var ORDER_SIGNALS = ["order", "delivery", "shipping", "return", "refund", "exchange", "track", "where is my", "late", "not arrived"]; 3458 3459 function detectIntent(userText, aiReply) { 3460 // Only scan user text â the AI frequently asks about sizes/prices which would false-trigger 3461 var uText = (userText || "").toLowerCase(); 3462 var score = { purchase: 0, sizing: 0, save: 0, order: 0 }; 3463 for (var i = 0; i < PURCHASE_SIGNALS.length; i++) if (uText.indexOf(PURCHASE_SIGNALS[i]) !== -1) score.purchase++; 3464 for (var i = 0; i < SIZING_SIGNALS.length; i++) if (uText.indexOf(SIZING_SIGNALS[i]) !== -1) score.sizing++; 3465 for (var i = 0; i < SAVE_SIGNALS.length; i++) if (uText.indexOf(SAVE_SIGNALS[i]) !== -1) score.save++; 3466 for (var i = 0; i < ORDER_SIGNALS.length; i++) if (uText.indexOf(ORDER_SIGNALS[i]) !== -1) score.order++; 3467 if (score.save >= 2) return "save"; 3468 if (score.purchase >= 3) return "outfit"; 3469 if (score.sizing >= 3) return "sizing"; 3470 if (score.order >= 2) return "order"; 3471 return null; 3472 } 3473 3474 var CAPTURE_PROMPTS = { 3475 outfit: "Love what you see? Drop your email and we'll send the full outfit look-book straight to your inbox â no account needed.", 3476 sizing: "Want us to save your measurements for next time? Leave your email and we'll keep your size profile ready.", 3477 save: "We can email you this complete outfit so you've got it saved. Just drop your email below.", 3478 order: "If you'd like an update on your order or a callback from our team, leave your email and we'll be in touch.", 3479 }; 3480 3481 // ââ Lead gate (value-moment capture) âââââââââââââââââââââââââââââââââââââââ 3482 // Chat is free; the FIRST outfit/look reveal is locked behind name + email. 3483 // Once per device (localStorage), skipped for logged-in customers 3484 // (prechatSatisfied from the signed settings call) and for restored 3485 // conversations. Server only sends leadGate:true on entitled plans. 3486 function sgLeadDone() { 3487 if (config.prechatSatisfied) return true; 3488 try { 3489 // 'sgfc:lead-captured' is the SHARED cross-app flag (contract with the 3490 // FitCore widget, 2026-09-25): either app's capture silences both gates. 3491 return localStorage.getItem("sg_lead_" + shopDomain) === "1" 3492 || localStorage.getItem("sgfc:lead-captured") === "1"; 3493 } catch (e) { return true; } 3494 } 3495 /** Internal skip only (e.g. a restored conversation) â does NOT claim a 3496 * capture happened, so FitCore's gate is unaffected. */ 3497 function sgMarkLeadDone() { 3498 try { localStorage.setItem("sg_lead_" + shopDomain, "1"); } catch (e) {} 3499 } 3500 /** A REAL capture (our form, or FitCore's) â sets the shared flag too. */ 3501 function sgMarkLeadCaptured() { 3502 sgMarkLeadDone(); 3503 try { localStorage.setItem("sgfc:lead-captured", "1"); } catch (e) {} 3504 } 3505 // Belt and braces per the FitCore contract: their viewer broadcasts 3506 // {type:'fitcore:lead', captured:true} on capture â catch it live so an 3507 // in-page FitCore capture unlocks any pending chat reveal immediately. 3508 try { 3509 window.addEventListener("message", function (ev) { 3510 if (ev.data && ev.data.type === "fitcore:lead" && ev.data.captured) { 3511 sgMarkLeadCaptured(); 3512 var gateEl = document.getElementById("sg-lead-gate"); 3513 if (gateEl) { 3514 gateEl.remove(); 3515 var reveal = sgGateReveal; 3516 sgGateReveal = null; 3517 if (reveal) reveal(); 3518 } 3519 } 3520 }); 3521 } catch (e) {} 3522 var sgGateReveal = null; // the stashed reveal, run on unlock 3523 3524 function sgShowLeadGate(revealFn, outfitSummary) { 3525 sgGateReveal = revealFn; 3526 var container = document.getElementById("sg-messages"); 3527 if (!container) { revealFn(); return; } 3528 if (document.getElementById("sg-lead-gate")) return; // already showing â reveal updated above 3529 3530 trackEvent("lead_gate_shown"); 3531 var inputStyle = "width:100%;padding:7px 10px;border:1px solid #ccc;border-radius:8px;font-size:13px;background:#fff;color:#333;box-sizing:border-box;"; 3532 var div = document.createElement("div"); 3533 div.className = "sg-msg sg-msg-assistant"; 3534 div.id = "sg-lead-gate"; 3535 div.innerHTML = 3536 '<div class="sg-msg-bubble" style="' + sgFormTint() + '">' + 3537 '<p style="margin:0 0 10px;font-size:13px;line-height:1.5;font-weight:600;">' + escapeHtml(T("leadGateTitle") || "Your look is ready â who shall I show it to?") + '</p>' + 3538 '<p style="margin:0 0 10px;font-size:12px;line-height:1.5;">' + escapeHtml(T("leadGateBody") || "Leave your name and email and Iâll reveal it, plus keep your looks and sizes saved for next time.") + '</p>' + 3539 '<div style="margin-bottom:8px"><input type="text" id="sg-lead-name" placeholder="' + escapeHtml(T("leadGateName") || "Your name") + '" style="' + inputStyle + '" /></div>' + 3540 '<div style="margin-bottom:8px"><input type="email" id="sg-lead-email" placeholder="' + escapeHtml(T("leadGateEmail") || "Your email address") + '" style="' + inputStyle + '" /></div>' + 3541 '<button id="sg-lead-submit" style="width:100%;padding:9px 12px;background:' + (config.primaryColor || "#C6A85B") + ';color:' + (config.addedFg || "#fff") + ';border:none;border-radius:8px;font-size:13px;
3541cursor:pointer;font-weight:600;">' + escapeHtml(T("leadGateCta") || "Show my look") + '</button>' + 3542 '</div>'; 3543 container.appendChild(div); 3544 container.scrollTop = container.scrollHeight; 3545 3546 document.getElementById("sg-lead-submit").onclick = function () { 3547 var name = (document.getElementById("sg-lead-name").value || "").trim(); 3548 var email = (document.getElementById("sg-lead-email").value || "").trim(); 3549 if (!name) { document.getElementById("sg-lead-name").style.borderColor = "#e44"; return; } 3550 if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { document.getElementById("sg-lead-email").style.borderColor = "#e44"; return; } 3551 var btn = document.getElementById("sg-lead-submit"); 3552 btn.textContent = "â¦"; 3553 btn.disabled = true; 3554 var payload = JSON.stringify({ name: name, email: email, sessionId: sessionId, captureType: "gate", outfitSummary: outfitSummary || null }); 3555 var post = function () { 3556 var xhr = new XMLHttpRequest(); 3557 xhr.open("POST", apiUrl("/capture")); 3558 xhr.setRequestHeader("Content-Type", "application/json"); 3559 xhr.onreadystatechange = function () { 3560 if (xhr.readyState !== 4) return; 3561 // One background retry on failure â but the reveal NEVER waits on 3562 // (or breaks because of) the network: the lead form was completed. 3563 if (xhr.status < 200 || xhr.status >= 300) setTimeout(function () { 3564 var r = new XMLHttpRequest(); 3565 r.open("POST", apiUrl("/capture")); 3566 r.setRequestHeader("Content-Type", "application/json"); 3567 r.send(payload); 3568 }, 4000); 3569 }; 3570 xhr.send(payload); 3571 }; 3572 try { post(); } catch (e) {} 3573 trackEvent("lead_gate_submitted"); 3574 sgMarkLeadCaptured(); 3575 div.remove(); 3576 var reveal = sgGateReveal; 3577 sgGateReveal = null; 3578 if (reveal) reveal(); 3579 }; 3580 } 3581 3582 function showCapturePrompt(intentType, outfitSummary) { 3583 if (!config.emailCapture) return; 3584 if (sgLeadDone() && config.leadGate) return; // identity already captured by the gate 3585 if (captureShown || hasCaptureShown()) return; 3586 markCaptureShown(); 3587 3588 var container = document.getElementById("sg-messages"); 3589 if (!container) return; 3590 3591 var prompt = CAPTURE_PROMPTS[intentType] || CAPTURE_PROMPTS.outfit; 3592 var showPhone = config.whatsappCapture; 3593 3594 var div = document.createElement("div"); 3595 div.className = "sg-msg sg-msg-assistant"; 3596 div.id = "sg-capture-prompt"; 3597 3598 var phoneField = showPhone 3599 ? '<div style="margin-top:8px"><input type="tel" id="sg-capture-phone" placeholder="WhatsApp number (optional)" style="width:100%;padding:7px 10px;border:1px solid #ccc;border-radius:8px;font-size:13px;background:#fff;color:#333;box-sizing:border-box;" /></div>' 3600 : ""; 3601 3602 div.innerHTML = 3603 '<div class="sg-msg-bubble" style="' + sgFormTint() + '">' + 3604 '<p style="margin:0 0 10px;font-size:13px;line-height:1.5;">' + escapeHtml(prompt) + '</p>' + 3605 '<div style="margin-bottom:8px"><input type="email" id="sg-capture-email" placeholder="Your email address" style="width:100%;padding:7px 10px;border:1px solid #ccc;border-radius:8px;font-size:13px;background:#fff;color:#333;box-sizing:border-box;" /></div>' + 3606 phoneField + 3607 '<div style="display:flex;gap:8px;margin-top:10px">' + 3608 '<button id="sg-capture-submit" style="flex:1;padding:8px 12px;background:' + (config.primaryColor || "#C6A85B") + ';color:#fff;border:none;border-radius:8px;font-size:13px;cursor:pointer;font-weight:600;">Send me the details</button>' + 3609 '<button id="sg-capture-dismiss" style="padding:8px 12px;background:transparent;color:#999;border:1px solid #ddd;border-radius:8px;font-size:12px;cursor:pointer;">Not now</button>' + 3610 '</div>' + 3611 '</div>'; 3612 3613 container.appendChild(div); 3614 container.scrollTop = container.scrollHeight; 3615 3616 document.getElementById("sg-capture-dismiss").onclick = function() { 3617 div.remove(); 3618 }; 3619 3620 document.getElementById("sg-capture-submit").onclick = function() { 3621 var email = (document.getElementById("sg-capture-email").value || "").trim(); 3622 if (!email || email.indexOf("@") === -1) { 3623 document.getElementById("sg-capture-email").style.borderColor = "#e44"; 3624 return; 3625 } 3626 var phone = showPhone ? ((document.getElementById("sg-capture-phone") || {}).value || "").trim() : ""; 3627 var btn = document.getElementById("sg-capture-submit"); 3628 btn.textContent = "Savingâ¦"; 3629 btn.disabled = true; 3630 3631 var payload = { email: email, sessionId: sessionId, captureType: intentType, outfitSummary: outfitSummary || null }; 3632 if (phone) payload.phone = phone; 3633 3634 var xhr = new XMLHttpRequest(); 3635 xhr.open("POST", apiUrl("/capture")); 3636 xhr.setRequestHeader("Content-Type", "application/json"); 3637 xhr.onreadystatechange = function() { 3638 if (xhr.readyState !== 4) return; 3639 div.remove(); 3640 var thankDiv = document.createElement("div"); 3641 thankDiv.className = "sg-msg sg-msg-assistant"; 3642 thankDiv.innerHTML = '<div class="sg-msg-bubble" style="background:linear-gradient(135deg,#f0faf2,#f9fcf9);border:1px solid #b8dcbc;">' + 3643 '<p style="margin:0;font-size:13px;">Perfect â we\'ve got your details. We\'ll be in touch with your curated look. In the meantime, feel free to ask about anything else.</p>' + 3644 '</div>'; 3645 container.appendChild(thankDiv); 3646 container.scrollTop = container.scrollHeight; 3647 }; 3648 xhr.send(JSON.stringify(payload)); 3649 }; 3650 } 3651 3652 // "Send us a message" â human handoff form, agent-triggered (handoff in the 3653 // chat payload) or manual via the envelope button in the header. 3654 // ââ Live voice (Speech Engine pilot) ââââââââââââââââââââââââââââââââââ 3655 // Token comes from our signed proxy endpoint, which enforces the paid-plan 3656 // gate server-side; sessions hard-stop after 5 minutes as an abuse cap.
3657 var sgLiveConv = null; 3658 var sgLiveTimer = null; 3659 var sgLiveConvId = null; 3660 var sgLivePoll = null; 3661 var sgLiveSeq = 0; 3662 // Warmed at modal open so tapping the mic starts the call instantly. 3663 var sgVoicePrefetch = null; 3664 function sgPrefetchVoice() { 3665 if (!config.speechEngine || !PROXY_PATH) return; 3666 var modP = import("https://cdn.jsdelivr.net/npm/@elevenlabs/[email protected]/+esm"); 3667 var qs = ""; 3668 try { 3669 var sz = storedFitcoreSizes() || {}; 3670 var parts = []; 3671 ["size", "trouser", "shirt", "casual", "fit", "shoe"].forEach(function(k) { 3672 if (sz[k]) parts.push(k + "=" + encodeURIComponent(String(sz[k]).slice(0, 28))); 3673 }); 3674 if (sessionId) parts.push("sessionId=" + encodeURIComponent(sessionId)); 3675 if (parts.length) qs = "?" + parts.join("&"); 3676 } catch (e) {} 3677 var tokP = fetch(apiUrl("/voice-token" + qs), { credentials: "omit" }) 3678 .then(function(r) { return r.ok ? r.json() : null; }).catch(function() { return null; }); 3679 sgVoicePrefetch = { modP: modP, tokP: tokP, ts: Date.now() }; 3680 } 3681 3682 // Action bridge: while the call runs, poll for this conversation's visual 3683 // payloads (cards, sizes, cart actions) and replay them through the exact 3684 // same handlers a typed chat reply uses. 3685 function sgApplyVoiceVisuals(data) { 3686 var outfits = data.outfits || []; 3687 // Mirror the spoken exchange into the chat thread so every pane change 3688 // has a visible cause â the call and the chat are ONE conversation. 3689 if (data.userText) messages.push({ role: "user", content: data.userText }); 3690 if (data.reply || outfits.length) { 3691 messages.push({ role: "assistant", content: data.reply || "Here's what I've found \u2014 take a look.", outfits: outfits }); 3692 } 3693 if (data.userText || data.reply || outfits.length) renderMessages(); 3694 if (outfits.length) { 3695 lastOutfits = outfits; 3696 persistLastOutfits(outfits); 3697 trackShownProducts(outfits); 3698 sessionLook = []; // voice: the look mirrors the current recommendation 3699 mergeIntoSessionLook(outfits[0]); 3700 renderSessionLook(); 3701 showDiscussedProducts([{ items: sessionLook }]); 3702 showViewProductsChip(T("viewOutfit")); 3703 } 3704 if (data.filterCmd) applyAIFilters(data.filterCmd, { chatPreview: true }); 3705 if (data.sizes) mergeFitcoreSizes(data.sizes); 3706 if (data.sizeFinder) { switchTab("chat"); setTimeout(openSizeFinderUI, 300); } 3707 if (data.addToCart) agentAddLookToCart(outfits.length ? outfits : (data.outfits || [])); 3708 if (data.openCart) agentOpenCart(); 3709 } 3710 function sgStartVoicePoll() { 3711 if (sgLivePoll || !sgLiveConvId) return; 3712 sgLivePoll = setInterval(function() { 3713 fetch(apiUrl("/voice-visuals?conv=" + encodeURIComponent(sgLiveConvId) + "&seq=" + sgLiveSeq)) 3714 .then(function(r) { return r.ok ? r.json() : null; }) 3715 .then(function(d) { 3716 if (!d) return; 3717 var list = d.payloads || (d.seq && d.seq > sgLiveSeq ? [d] : []); 3718 for (var i = 0; i < list.length; i++) { 3719 if (list[i].seq > sgLiveSeq) { sgLiveSeq = list[i].seq; sgApplyVoiceVisuals(list[i]); } 3720 } 3721 }).catch(function() {}); 3722 }, 1500); 3723 } 3724 // "Audio on by default": the shopper is invited to turn their mic on the 3725 // moment the chat opens â accepting starts the live spoken session (agent 3726 // audio plays by default); the pulsing waveform is the in-chat off switch. 3727 // Shown once per browser session, never nags. 3728 function showVoiceInvite() { 3729 if (!config.speechEngine || !PROXY_PATH || sgLiveConv) return; 3730 try { if (sessionStorage.getItem("sg-voice-invite-done") === "1") return; } catch (e) {} 3731 var msgs = document.getElementById("sg-messages"); 3732 if (!msgs || document.getElementById("sg-voice-invite")) return; 3733 var div = document.createElement("div"); 3734 div.id = "sg-voice-invite"; 3735 div.style.cssText = "margin:10px 0;padding:12px 14px;border:1px solid " + (config.borderColor || "#e0e0e0") + ";background:rgba(" + hexToRgb(config.primaryColor || "#C6A85B") + ",0.06);"; 3736 div.innerHTML = '<div style="font-size:13px;margin-bottom:8px;">Prefer to talk? Turn your microphone on and speak with the stylist \u2014 you can end the call anytime with the pulsing button above.</div>' + 3737 '<div style="display:flex;gap:8px;">' + 3738 '<button id="sg-voice-yes" type="button" style="padding:8px 14px;background:' + (config.primaryColor || "#C6A85B") + ';color:' + (isLightColor(config.primaryColor || "#C6A85B") ? "#111" : "#fff") + ';border:none;font-size:12.5px;font-weight:600;cursor:pointer;">Turn on my mic</button>' + 3739 '<button id="sg-voice-no" type="button" style="padding:8px 14px;background:transparent;border:1px solid ' + (config.borderColor || "#ccc") + ';font-size:12.5px;cursor:pointer;color:inherit;">Maybe later</button>' + 3740 '</div>'; 3741 msgs.appendChild(div); 3742 msgs.scrollTop = msgs.scrollHeight; 3743 function done() {
3744 try { sessionStorage.setItem("sg-voice-invite-done", "1"); } catch (e) {} 3745 if (div.parentNode) div.parentNode.removeChild(div); 3746 } 3747 document.getElementById("sg-voice-yes").onclick = function() { done(); sgStartLiveVoice(); }; 3748 document.getElementById("sg-voice-no").onclick = done; 3749 } 3750 3751 try { window.addEventListener("pagehide", function() { sgStopLiveVoice(); }); } catch (e) {} 3752 function sgStopLiveVoice() { 3753 try { if (sgLiveConv) sgLiveConv.endSession(); } catch (e) {} 3754 sgLiveConv = null; 3755 if (sgLivePoll) { clearInterval(sgLivePoll); sgLivePoll = null; } 3756 sgLiveConvId = null; 3757 sgLiveSeq = 0; 3758 if (sgLiveTimer) { clearTimeout(sgLiveTimer); sgLiveTimer = null; } 3759 var b = document.getElementById("sg-live-btn"); 3760 if (b) { b.classList.remove("sg-live-active"); b.title = "Talk to the stylist"; } 3761 trackEvent("voice_session_ended"); 3762 } 3763 function sgStartLiveVoice() { 3764 if (sgLiveConv || !config.speechEngine || !PROXY_PATH) return; 3765 var btn = document.getElementById("sg-live-btn"); 3766 var warm = (sgVoicePrefetch && Date.now() - sgVoicePrefetch.ts < 8 * 60_000) ? sgVoicePrefetch : null; 3767 if (!warm) { sgPrefetchVoice(); warm = sgVoicePrefetch; } 3768 navigator.mediaDevices.getUserMedia({ audio: true }).then(function() { 3769 return warm.tokP; 3770 }).then(function(d) { 3771 if (!d || !d.token) throw new Error("voice token unavailable"); 3772 return d; 3773 }).then(function(d) { 3774 sgLiveConvId = d.conversationId || null; 3775 sgVoicePrefetch = null; // tokens are single-conversation â never reuse 3776 return warm.modP.then(function(mod) { 3777 return mod.Conversation.startSession({ 3778 conversationToken: d.token, 3779 onConnect: function() { 3780 if (btn) { btn.classList.add("sg-live-active"); btn.title = "End voice conversation"; } 3781 sgStartVoicePoll(); 3782 trackEvent("voice_session_started"); 3783 }, 3784 onDisconnect: function() { sgStopLiveVoice(); }, 3785 onError: function(e) { console.warn("[StyleGuru voice]", e); sgStopLiveVoice(); } 3786 }); 3787 }); 3788 }).then(function(conv) { 3789 sgLiveConv = conv; 3790 sgLiveTimer = setTimeout(sgStopLiveVoice, 5 * 60 * 1000); 3791 }).catch(function(e) { 3792 console.warn("[StyleGuru voice] unavailable:", e && e.message); 3793 sgStopLiveVoice(); 3794 // Never fail silently â say what went wrong where the customer looks. 3795 var msg = /not found|denied|not allowed|permission/i.test(String(e && e.message)) 3796 ? "I couldn't reach your microphone. Please allow microphone access for this site in your browser settings, then tap the voice button again." 3797 : "Voice isn't available right now \u2014 but I'm right here in the chat."; 3798 messages.push({ role: "assistant", content: msg }); 3799 renderMessages(); 3800 }); 3801 } 3802 3803 // Full FitCore experience in an overlay covering the widget: the customer 3804 // works out their size in FitCore's own interface, the result is relayed 3805 // back via postMessage, and closing feeds the sizes into the conversation. 3806 var sgFitcoreResult = null; 3807 3808 function openSizeFinderUI() { 3809 if (config.fitcoreEmbedUrl) { openFitcoreOverlay(); } else { showSizeFinder(); } 3810 } 3811 3812 function openFitcoreOverlay() { 3813 var modal = document.getElementById("sg-modal"); 3814 if (!modal || document.getElementById("sg-fitcore-overlay")) return; 3815 trackEvent("size_finder_opened", { mode: "overlay" }); 3816 sgFitcoreResult = null; 3817 if (!modal.style.position) modal.style.position = "relative"; 3818 3819 var pc = config.primaryColor || "#C6A85B"; 3820 var ov = document.createElement("div"); 3821 ov.id = "sg-fitcore-overlay"; 3822 ov.style.cssText = "position:absolute;top:0;left:0;right:0;bottom:0;z-index:60;background:#f7f4ec;display:flex;flex-direction:column;border-radius:inherit;overflow:hidden;"; 3823 ov.innerHTML = 3824 '<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;background:#fff;border-bottom:1px solid rgba(0,0,0,0.08);flex-shrink:0;">' + 3825 '<div style="font-size:14px;font-weight:600;color:#333;">Find your size <span style="font-weight:400;color:#999;font-size:12px;">— powered by FitCore</span></div>' + 3826 '<button id="sg-fitcore-close" type="button" style="padding:6px 14px;background:transparent;border:1px solid #ccc;border-radius:999px;font-size:12.5px;cursor:pointer;color:#555;">Back to the stylist</button>' + 3827 '</div>' + 3828 // A shopper who already gave their details to the chat's lead gate (or 3829 // is logged in) must never be asked again inside the size finder â 3830 // lead=satisfied tells the FitCore viewer to skip its own gate. 3831 '<iframe id="sg-fitcore-frame" src="' + config.fitcoreEmbedUrl + (sgLeadDone() ? (config.fitcoreEmbedUrl.indexOf("?") === -1 ? "?" : "&") + "lead=satisfied" : "") + '" style="flex:1;width:100%;border:none;background:#f7f4ec;" title="FitCore size finder"></iframe>' + 3832 '<div id="sg-fitcore-bar" style="display:none;flex-shrink:0;padding:10px 16px;background:#fff;border-top:1px solid rgba(0,0,0,0.08);align-items:center;gap:10px;">' + 3833 '<span id="sg-fitcore-bar-text" style="flex:1;font-size:13px;color:#333;"></span>' + 3834 '<button id="sg-fitcore-use" type="button" style="padding:9px 16px;background:' + pc + ';color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap;">Use this size →
3834 continue</button>' + 3835 '</div>'; 3836 modal.appendChild(ov); 3837 3838 // Closing with a result in hand still feeds the chat â the customer was 3839 // told "come back and we'll continue", whichever button they reach for. 3840 document.getElementById("sg-fitcore-close").onclick = function() { closeFitcoreOverlay(true); }; 3841 document.getElementById("sg-fitcore-use").onclick = function() { closeFitcoreOverlay(true); }; 3842 } 3843 3844 function fitcoreResultSummary(r) { 3845 return [ 3846 r.size ? "Jacket " + r.size : null, 3847 r.trouser_size ? "Trousers " + r.trouser_size : null, 3848 r.shirt_size ? "Shirt collar " + r.shirt_size : null, 3849 r.casual_size ? "Casual " + r.casual_size : null, 3850 r.shoe_size ? "Shoes " + r.shoe_size : null, 3851 r.recommended_fit ? "Cut " + r.recommended_fit : null, 3852 ].filter(Boolean).join(" · "); 3853 } 3854 3855 function closeFitcoreOverlay(useResult) { 3856 switchTab("chat"); // never strand the customer outside the conversation 3857 var ov = document.getElementById("sg-fitcore-overlay"); 3858 if (ov) ov.remove(); 3859 var r = sgFitcoreResult; 3860 sgFitcoreResult = null; 3861 if (!useResult || !r || !r.size) return; 3862 trackEvent("size_finder_used", { size: r.size, mode: "overlay" }); 3863 var chatInput = document.getElementById("sg-chat-input"); 3864 if (chatInput) { 3865 chatInput.value = "I've used the FitCore size finder â " + fitcoreResultSummary(r) + ". Let's carry on with the look."; 3866 var send = document.getElementById("sg-chat-send"); 3867 if (send) send.disabled = false; 3868 sendMessage(); 3869 } 3870 } 3871 3872 // Results relayed from the FitCore iframe. Origin-checked against the 3873 // configured embed URL. 3874 window.addEventListener("message", function(ev) { 3875 try { 3876 if (!ev.data || ev.data.type !== "fitcore:recommendation") return; 3877 if (!config.fitcoreEmbedUrl || config.fitcoreEmbedUrl.indexOf(ev.origin) !== 0) return; 3878 var r = ev.data.result || {}; 3879 if (!r.size) return; 3880 sgFitcoreResult = r; 3881 trackEvent("size_finder_result", { size: r.size, mode: "overlay" }); 3882 var bar = document.getElementById("sg-fitcore-bar"); 3883 var txt = document.getElementById("sg-fitcore-bar-text"); 3884 if (bar && txt) { 3885 txt.textContent = "Your size: " + fitcoreResultSummary(r); 3886 bar.style.display = "flex"; 3887 } 3888 } catch (e) {} 3889 }); 3890 3891 // ââ FitCore storefront handoff âââââââââââââââââââââââââââââââââââââââââââââ 3892 // FitCore's own storefront widget announces an applied size via 3893 // postMessage {type:'fitcore:apply', size, trouser, shirt, casual, shoe}. We store 3894 // the sizes for every future conversation and offer (never force) a styled 3895 // look: gentle prompt bubble, dismissible, max once per session. 3896 var FITCORE_SIZES_KEY = "sg-fitcore-sizes"; 3897 3898 function sanitizeFitcoreSize(v) { 3899 if (typeof v !== "string") return null; 3900 var s = v.trim().slice(0, 28);
3901 // Sizes only: letters, digits, spaces and the punctuation real size 3902 // strings use (16" / 41cm, 36 regular leg). Anything else is not a size. 3903 return s && /^[A-Za-z0-9\s"'.,/â-]+$/.test(s) ? s : null; 3904 } 3905 3906 function storedFitcoreSizes() { 3907 try { return JSON.parse(localStorage.getItem(FITCORE_SIZES_KEY) || "null"); } catch (e) { return null; } 3908 } 3909 3910 function fitcoreSizesSentence(sizes) { 3911 var parts = []; 3912 if (sizes.size) parts.push("jacket/suit " + sizes.size); 3913 if (sizes.trouser) parts.push("trousers " + sizes.trouser); 3914 if (sizes.shirt) parts.push("shirt collar " + sizes.shirt); 3915 if (sizes.casual) parts.push("casual " + sizes.casual); 3916 if (sizes.shoe) parts.push("shoes " + sizes.shoe); 3917 return parts.join(", "); 3918 } 3919 3920 function showFitcorePrompt(sizes) { 3921 if (isModalOpen) return; // sizes are stored; the open conversation already benefits 3922 try { if (sessionStorage.getItem("sg-fitcore-prompt-done") === "1") return; } catch (e) {} 3923 try { sessionStorage.setItem("sg-fitcore-prompt-done", "1"); } catch (e) {} 3924 var existing = document.getElementById("sg-fitcore-prompt"); 3925 if (existing) existing.remove(); 3926 3927 var headline = sizes.size || sizes.casual || sizes.trouser || sizes.shirt || sizes.shoe; 3928 var onProduct = isProductPage(); 3929 var text = headline + " â good to know. " + (onProduct 3930 ? "Want to see this in your size, plus what goes with it?" 3931 : "Want me to build a look in your size?"); 3932 3933 var pc = config.primaryColor || "#C6A85B"; 3934 var bPosition = config.bellPosition || "bottom_left"; 3935 var vertical = (bPosition === "top_right" || bPosition === "top_left") ? "top: 96px;" : "bottom: 160px;"; 3936 var horiz = (bPosition === "top_left" || bPosition === "bottom_left") ? "left: 20px;" : (bPosition === "bottom_center" ? "left: 50%; transform: translateX(-50%);" : "right: 20px;"); 3937 var bubble = document.createElement("div"); 3938 bubble.id = "sg-fitcore-prompt"; 3939 bubble.style.cssText = "position:fixed;" + vertical + horiz + "z-index:2147483645;max-width:260px;" + 3940 "background:#fff;color:#1A2332;border:1px solid " + pc + ";border-radius:14px;" + 3941 "box-shadow:0 6px 24px rgba(0,0,0,0.16);padding:12px 34px 12px 14px;" + 3942 "font:500 13px/1.4 " + (config.fontFamily || "inherit") + ";cursor:pointer;" + 3943 "opacity:0;transition:opacity 0.4s ease;"; 3944 bubble.textContent = text; 3945 var x = document.createElement("button"); 3946 x.setAttribute("aria-label", "Dismiss"); 3947 x.innerHTML = "✕"; 3948 x.style.cssText = "position:absolute;top:6px;right:8px;border:none;background:none;color:#9aa0ab;font-size:11px;cursor:pointer;padding:2px;"; 3949 x.onclick = function (e) { e.stopPropagation(); bubble.remove(); trackEvent("fitcore_prompt_dismissed", {}); }; 3950 bubble.appendChild(x); 3951 bubble.onclick = function () { 3952 bubble.remove(); 3953 trackEvent("fitcore_prompt_accepted", {}); 3954 var handle = ((window.location.pathname || "").match(/\/products\/([^?#/]+)/) || [])[1]; 3955 var q = "FitCore just sized me: " + fitcoreSizesSentence(sizes) + "."; 3956 if (handle) { 3957 q += " I'm looking at the \"" + decodeURIComponent(handle).replace(/-/g, " ") + 3958 "\" â show me it in my size and build the rest of the look around it, in my sizes where they apply."; 3959 } else { 3960 q += " Build me a look around them."; 3961 } 3962 openModal(q); 3963 }; 3964 document.body.appendChild(bubble); 3965 requestAnimationFrame(function () { bubble.style.opacity = "1"; }); 3966 trackEvent("fitcore_prompt_shown", { size: sizes.size || null }); 3967 setTimeout(function () { if (bubble.parentNode) { bubble.style.opacity = "0"; setTimeout(function(){ bubble.remove(); }, 500); } }, 25000); 3968 } 3969 3970 window.addEventListener("message", function(ev) { 3971 try { 3972 if (!ev.data) return; 3973 var evType = ev.data.type; 3974 if (evType !== "fitcore:apply" && evType !== "fitcore:result") return; 3975 var sizes = { 3976 size: sanitizeFitcoreSize(ev.data.size), 3977 trouser: sanitizeFitcoreSize(ev.data.trouser), 3978 shirt: sanitizeFitcoreSize(ev.data.shirt),
3979 casual: sanitizeFitcoreSize(ev.data.casual), 3980 shoe: sanitizeFitcoreSize(ev.data.shoe), 3981 fit: sanitizeFitcoreSize(ev.data.fit), 3982 }; 3983 if (!sizes.size && !sizes.trouser && !sizes.shirt && !sizes.casual && !sizes.shoe) return; 3984 if (evType === "fitcore:apply") { 3985 // Applied = committed: always overwrites, and is the ONLY prompt trigger. 3986 try { localStorage.setItem(FITCORE_SIZES_KEY, JSON.stringify(sizes)); } catch (e) {} 3987 trackEvent("fitcore_apply_received", sizes); 3988 showFitcorePrompt(sizes); 3989 } else { 3990 // Result-only = size memory, silently: the shopper computed a size but 3991 // didn't apply it. Never prompt, never overwrite an applied size. 3992 if (!storedFitcoreSizes()) { 3993 try { localStorage.setItem(FITCORE_SIZES_KEY, JSON.stringify(sizes)); } catch (e) {} 3994 trackEvent("fitcore_result_stored", sizes); 3995 } 3996 } 3997 } catch (e) {} 3998 }); 3999 4000 // ââ Top-of-page "Ask the Stylist" button (activation mode top_button) âââââ 4001 // The British-Supplements pattern: the assistant lives at the top of every 4002 // page, full-width on mobile â prime position instead of a corner bell. 4003 function renderTopButton() { 4004 if (document.getElementById("sg-top-button")) return; 4005 var pc = config.topButtonColor || config.primaryColor || "#C6A85B"; 4006 var txtColor = isLightColor(pc) ? "#111" : "#fff"; 4007 var btn = document.createElement("button"); 4008 btn.id = "sg-top-button"; 4009 btn.type = "button"; 4010 var topLabel = config.topButtonLabel || (config.storeVertical && config.storeVertical !== "fashion" ? "Ask us anything" : "Ask the Stylist"); 4011 btn.setAttribute("aria-label", topLabel); 4012 btn.innerHTML = '<span style="margin-right:7px">✦</span>' + escapeHtml(topLabel); 4013 btn.style.cssText = 4014 "position:fixed;top:10px;left:50%;transform:translateX(-50%);z-index:2147483643;" + 4015 "background:" + pc + ";color:" + txtColor + ";border:none;border-radius:" + (parseInt(config.borderRadius, 10) === 0 ? "0" : "999px") + ";" + 4016 "padding:10px 22px;font:600 14px/1 " + (config.fontFamily || "inherit") + ";cursor:pointer;" + 4017 "box-shadow:0 4px 16px rgba(0,0,0,0.18);white-space:nowrap;" + 4018 "transition:transform 0.15s ease, box-shadow 0.15s ease;"; 4019 // Mobile: full-width bar, still top-pinned 4020 var mq = window.matchMedia("(max-width: 767px)"); 4021 function fitTopButton() { 4022 if (mq.matches) { 4023 btn.style.left = "10px"; 4024 btn.style.right = "10px"; 4025 btn.style.transform = "none"; 4026 btn.style.width = "calc(100% - 20px)"; 4027 } else { 4028 btn.style.left = "50%"; 4029 btn.style.right = "auto"; 4030 btn.style.width = "auto"; 4031 btn.style.transform = "translateX(-50%)"; 4032 } 4033 } 4034 fitTopButton(); 4035 if (mq.addEventListener) mq.addEventListener("change", fitTopButton); 4036 btn.onmouseenter = function () { btn.style.boxShadow = "0 6px 22px rgba(0,0,0,0.26)"; }; 4037 btn.onmouseleave = function () { btn.style.boxShadow = "0 4px 16px rgba(0,0,0,0.18)"; }; 4038 btn.onclick = function () { 4039 trackEvent("top_button_clicked", {}); 4040 openModal(""); 4041 }; 4042 document.body.appendChild(btn); 4043 trackEvent("top_button_shown", {}); 4044 } 4045 4046 // ââ Agent-driven cart close ââââââââââââââââââââââââââââââââââââââââââââââââ 4047 // The stylist works out sizes (estimate_size) â server pre-sizes every card â 4048 // the agent asks ONE question â on yes it flags addToCart, and when the 4049 // customer is done, openCart. The widget executes both here. 4050 4051 /** Server returns freshly worked-out sizes with the reply â remember them so 4052 * every later conversation (and page) starts size-aware. Newest wins. */ 4053 function mergeFitcoreSizes(sizes) { 4054 if (!sizes) return; 4055 try { 4056 var cur = storedFitcoreSizes() || {}; 4057 var next = { 4058 size: sanitizeFitcoreSize(sizes.size) || cur.size || null, 4059 trouser: sanitizeFitcoreSize(sizes.trouser) || cur.trouser || null,
4060 shirt: sanitizeFitcoreSize(sizes.shirt) || cur.shirt || null, 4061 casual: sanitizeFitcoreSize(sizes.casual) || cur.casual || null, 4062 shoe: sanitizeFitcoreSize(sizes.shoe) || cur.shoe || null, 4063 fit: sanitizeFitcoreSize(sizes.fit) || cur.fit || null, 4064 }; 4065 localStorage.setItem(FITCORE_SIZES_KEY, JSON.stringify(next)); 4066 // Live call in progress: the stylist must know the new sizes NOW. 4067 if (sgLiveConvId) { 4068 var body = { conv: sgLiveConvId }; 4069 ["size", "trouser", "shirt", "casual", "fit", "shoe"].forEach(function(k) { if (next[k]) body[k] = next[k]; }); 4070 fetch(apiUrl("/voice-sizes"), { 4071 method: "POST", 4072 headers: { "Content-Type": "application/json" }, 4073 body: JSON.stringify(body), 4074 credentials: "omit" 4075 }).catch(function() {}); 4076 } 4077 } catch (e) {} 4078 } 4079 4080 var sgAgentCartBusy = false; 4081 var sgPendingOpenCart = false; 4082 4083 // The presented look survives page navigations (incl. the cart redirect): 4084 // without this, "add my items" on the next page has nothing to add. 4085 function persistLastOutfits(outfits) { 4086 try { 4087 if (!outfits || !outfits.length) return; 4088 var slim = outfits.slice(0, 4).map(function (o) { 4089 return { title: o.title || "", items: (o.items || []).slice(0, 12).map(function (it) { 4090 return { name: it.name, variantId: it.variantId || null, variantTitle: it.variantTitle || null, soldOut: !!it.soldOut, productId: it.productId || null, isSuit: !!it.isSuit }; 4091 }) }; 4092 }); 4093 sessionStorage.setItem("sg-last-outfits-" + shopDomain, JSON.stringify(slim)); 4094 } catch (e) {} 4095 } 4096 function restoreLastOutfits() { 4097 try { return JSON.parse(sessionStorage.getItem("sg-last-outfits-" + shopDomain) || "null"); } catch (e) { return null; } 4098 } 4099 4100 /** Add the presented look to the basket â same two-step flow as the 4101 * Complete Look button (server validates via add-bundle, then Shopify's 4102 * AJAX cart), without needing the button's DOM. */ 4103 function agentAddLookToCart(outfits) { 4104 var items = []; 4105 // This turn's outfits when it presented any; otherwise the LAST look shown 4106 // (an empty array must not shadow it â that was the empty-basket bug). 4107 var source = (outfits && outfits.length) ? outfits : (lastOutfits && lastOutfits.length ? lastOutfits : restoreLastOutfits()); 4108 (source || []).forEach(function (o) { 4109 (o.items || []).forEach(function (it) { 4110 if (it.variantId && !it.soldOut) items.push(it); 4111 }); 4112 }); 4113 if (items.length === 0) return; 4114 var lines = items.map(function (it) { return { variantId: it.variantId, quantity: 1, name: it.name || "" }; }); 4115 trackEvent("add_bundle_clicked", { itemCount: lines.length, source: "agent_close" }); 4116 if (!PROXY_PATH) { 4117 // Demo/api mode: no storefront cart exists â narrate only. 4118 try { console.log("[StyleGuru] demo mode â would add to cart:", lines); } catch (e) {} 4119 if (sgPendingOpenCart) sgPendingOpenCart = false; 4120 return; 4121 } 4122 sgAgentCartBusy = true; 4123 var xhr = new XMLHttpRequest(); 4124 xhr.open("POST", apiUrl("/cart/add-bundle")); 4125 xhr.setRequestHeader("Content-Type", "application/json"); 4126 xhr.onreadystatechange = function () { 4127 if (xhr.readyState !== 4) return; 4128 var finish = function () { 4129 sgAgentCartBusy = false; 4130 if (sgPendingOpenCart) { sgPendingOpenCart = false; agentOpenCart(); } 4131 }; 4132 try { 4133 var data = JSON.parse(xhr.responseText); 4134 if (!data.ok || !data.added || data.added.length === 0) return finish(); 4135 var cartItems = data.added.map(function (a) { 4136 return { id: parseInt(a.variantId.replace(/\D/g, ""), 10), quantity: a.quantity }; 4137 }); 4138 var cartXhr = new XMLHttpRequest(); 4139 cartXhr.open("POST", "/cart/add.js"); 4140 cartXhr.setRequestHeader("Content-Type", "application/json"); 4141 cartXhr.onreadystatechange = function () { 4142 if (cartXhr.readyState !== 4) return; 4143 if (cartXhr.status === 200 || cartXhr.status === 201) { 4144 markCartAttributed(); 4145 trackEvent("add_bundle_success", { itemCount: data.added.length, source: "agent_close", items: lines.slice(0, 10).map(function (l) { return { name: l.name, variantId: String(l.variantId) }; }) }); 4146 } 4147 finish(); 4148 }; 4149 cartXhr.send(JSON.stringify({ items: cartItems })); 4150 } catch (e) { finish(); } 4151 }; 4152 var fcS = storedFitcoreSizes() || {}; 4153 var hasSuitLine = items.some(function (it) { return it.isSuit || /suit/i.test(it.name || ""); }); 4154 var suitSel; 4155 if (hasSuitLine && (fcS.size || fcS.trouser)) { 4156 suitSel = {}; 4157 var jNum = fcS.size ? (String(fcS.size).match(/\d{2}/) || [])[0] : null; 4158 if (fcS.size) suitSel.jacketSize = String(fcS.size).slice(0, 20); 4159 var tNum = fcS.trouser ? (String(fcS.trouser).match(/\d{2}/) || [])[0] : null; 4160 if (tNum) suitSel.trouserSize = tNum; 4161 if (jNum) suitSel.waistcoatSize = jNum; 4162 } 4163 xhr.send(JSON.stringify({ lines: lines, sessionId: sessionId, customSelections: suitSel })); 4164 } 4165 4166 /** Take the customer to their basket â waits for an in-flight add first. */ 4167 function agentOpenCart() { 4168 if (sgAgentCartBusy) { sgPendingOpenCart = true; return; } 4169 if (!PROXY_PATH) {
4170 try { console.log("[StyleGuru] demo mode â would redirect to /cart"); } catch (e) {} 4171 return; 4172 } 4173 // Never land a customer on an EMPTY basket â the agent can misjudge 4174 // whether anything was actually added, and an empty /cart is a dead end. 4175 try { 4176 var chk = new XMLHttpRequest(); 4177 chk.open("GET", "/cart.js"); 4178 chk.onreadystatechange = function () { 4179 if (chk.readyState !== 4) return; 4180 var count = 0; 4181 try { count = JSON.parse(chk.responseText).item_count || 0; } catch (e) {} 4182 if (count > 0) { 4183 // A beat so the customer reads the stylist's sign-off first. 4184 setTimeout(function () { window.location.href = "/cart"; }, 600); 4185 } else { 4186 trackEvent("open_cart_blocked_empty"); 4187 } 4188 }; 4189 chk.send(); 4190 } catch (e) { 4191 setTimeout(function () { window.location.href = "/cart"; }, 600); 4192 } 4193 } 4194 4195 // In-chat FitCore size finder: measurements in, exact sizes out, and one tap 4196 // hands the result back to the stylist so the outfit conversation continues. 4197 // Fallback for stores without the full FitCore embed. 4198 function showSizeFinder() { 4199 var container = document.getElementById("sg-messages"); 4200 if (!container || !config.fitcoreEnabled) return; 4201 var existing = document.getElementById("sg-size-finder"); 4202 if (existing) { existing.scrollIntoView({ block: "nearest" }); return; } 4203 trackEvent("size_finder_opened"); 4204 4205 var pc = config.primaryColor || "#C6A85B"; 4206 var div = document.createElement("div"); 4207 div.className = "sg-msg sg-msg-assistant"; 4208 div.id = "sg-size-finder"; 4209 var inputStyle = "width:100%;padding:8px 10px;border:1px solid #ccc;border-radius:8px;font-size:13px;line-height:1.45;background:#fff;color:#333;box-sizing:border-box;font-family:inherit;min-height:0;margin:0;letter-spacing:normal;text-transform:none;"; 4210 var lblStyle = "display:block;font-size:11px;color:#888;margin:0 0 3px;text-transform:uppercase;letter-spacing:0.04em;"; 4211 div.innerHTML = 4212 '<div class="sg-msg-bubble" style="' + sgFormTint() + 'width:100%;">' + 4213 '<p style="margin:0 0 2px;font-size:13px;line-height:1.5;font-weight:600;">Find my size</p>' + 4214 '<p style="margin:0 0 12px;font-size:11.5px;color:#999;">Powered by <strong>FitCore</strong></p>' + 4215 '<div style="display:flex;gap:8px;margin-bottom:8px;">' + 4216 '<div style="flex:1"><span style="' + lblStyle + '">Height (ft)</span><input type="number" id="sg-sf-ft" min="4" max="7" placeholder="5" style="' + inputStyle + '" /></div>' + 4217 '<div style="flex:1"><span style="' + lblStyle + '">Height (in)</span><input type="number" id="sg-sf-in" min="0" max="11" placeholder="10" style="' + inputStyle + '" /></div>' + 4218 '<div style="flex:1"><span style="' + lblStyle + '">Weight (st)</span><input type="number" id="sg-sf-st" min="5" max="35" placeholder="13" style="' + inputStyle + '" /></div>' + 4219 '<div style="flex:1"><span style="' + lblStyle + '">(lb)</span><input type="number" id="sg-sf-lb" min="0" max="13" placeholder="0" style="' + inputStyle + '" /></div>' + 4220 '</div>' + 4221 '<p style="margin:0 0 10px;font-size:11px;color:#999;">Or metric: <input type="number" id="sg-sf-cm" placeholder="cm" style="width:64px;padding:4px 6px;border:1px solid #ddd;border-radius:6px;font-size:12px;"/> <input type="number" id="sg-sf-kg" placeholder="kg" style="width:64px;padding:4px 6px;border:1px solid #ddd;border-radius:6px;font-size:12px;"/></p>' + 4222 '<div style="margin-bottom:12px;">' + 4223 '<span style="' + lblStyle + '">Build</span>' + 4224 '<input type="range" id="sg-sf-build" min="0" max="100" value="50" style="width:100%;accent-color:' + pc + ';margin:4px 0 2px;" />' + 4225 '<div style="display:flex;justify-content:space-between;font-size:10.5px;color:#999;"><span>Leaner</span><span>Average</span><span>Fuller</span></div>' + 4226 '</div>' + 4227 '<div style="display:flex;gap:8px;">' + 4228 '<button id="sg-sf-submit" style="flex:1;padding:8px 12px;background:' + pc + ';color:#fff;border:none;border-radius:8px;font-size:13px;
4228cursor:pointer;font-weight:600;">Find my size</button>' + 4229 '<button id="sg-sf-dismiss" style="padding:8px 12px;background:transparent;color:#999;border:1px solid #ddd;border-radius:8px;font-size:12px;cursor:pointer;">Not now</button>' + 4230 '</div>' + 4231 '<p id="sg-sf-error" style="display:none;margin:8px 0 0;font-size:12px;color:#c0392b;"></p>' + 4232 '</div>'; 4233 4234 container.appendChild(div); 4235 container.scrollTop = container.scrollHeight; 4236 4237 document.getElementById("sg-sf-dismiss").onclick = function() { div.remove(); }; 4238 4239 document.getElementById("sg-sf-submit").onclick = function() { 4240 var num = function(id) { var v = parseFloat((document.getElementById(id) || {}).value); return isNaN(v) ? 0 : v; }; 4241 var heightCm = num("sg-sf-cm") || ((num("sg-sf-ft") * 12 + num("sg-sf-in")) * 2.54); 4242 var weightKg = num("sg-sf-kg") || ((num("sg-sf-st") * 14 + num("sg-sf-lb")) * 0.45359237); 4243 var errEl = document.getElementById("sg-sf-error"); 4244 if (heightCm < 120 || heightCm > 230 || weightKg < 30 || weightKg > 300) { 4245 errEl.textContent = "Please enter your height and weight (imperial or metric)."; 4246 errEl.style.display = "block"; 4247 return; 4248 } 4249 errEl.style.display = "none"; 4250 var btn = document.getElementById("sg-sf-submit"); 4251 btn.textContent = "Measuringâ¦"; 4252 btn.disabled = true; 4253 4254 var xhr = new XMLHttpRequest(); 4255 xhr.open("POST", apiUrl("/size-finder")); 4256 xhr.setRequestHeader("Content-Type", "application/json"); 4257 xhr.onreadystatechange = function() { 4258 if (xhr.readyState !== 4) return; 4259 var data = null; 4260 try { data = JSON.parse(xhr.responseText); } catch (e) {} 4261 if (xhr.status < 200 || xhr.status >= 300 || !data || !data.size) { 4262 btn.textContent = "Find my size"; 4263 btn.disabled = false; 4264 errEl.textContent = (data && data.error) || "The sizing service is busy â please try again in a moment."; 4265 errEl.style.display = "block"; 4266 return; 4267 } 4268 trackEvent("size_finder_result", { size: data.size }); 4269 var rows = [ 4270 ["Jacket", data.size], 4271 ["Trousers", data.trouserSize], 4272 ["Shirt collar", data.shirtSize], 4273 ["Casual", data.casualSize], 4274 ["Shoes", data.shoeSize], 4275 ["Cut", data.recommendedFit], 4276 ].filter(function(r) { return !!r[1]; }); 4277 var summary = rows.map(function(r) { return r[0] + " " + r[1]; }).join(" · "); 4278 div.innerHTML = 4279 '<div class="sg-msg-bubble" style="' + sgFormTint() + 'width:100%;">' + 4280 '<p style="margin:0 0 2px;font-size:13px;font-weight:600;">Your FitCore sizes</p>' + 4281 '<p style="margin:0 0 10px;font-size:11.5px;color:#999;">' + (data.confidence ? escapeHtml(data.confidence) + ' confidence · ' : '') + 'Powered by <strong>FitCore</strong></p>' + 4282 rows.map(function(r) { 4283 return '<div style="display:flex;justify-content:space-between;padding:5px 0;border-bottom:1px solid rgba(0,0,0,0.05);font-size:13px;"><span style="color:#777;">' + escapeHtml(r[0]) + '</span><strong>' + escapeHtml(String(r[1])) + '</strong></div>'; 4284 }).join('') + 4285 (data.escalate ? '<p style="margin:10px 0 0;font-size:12px;color:#8a6d3b;">For your build we recommend an in-store fitting for the perfect result.</p>' : '') + 4286 '<div style="display:flex;gap:8px;margin-top:12px;">' + 4287 '<button id="sg-sf-use" style="flex:1;padding:8px 12px;background:' + pc + ';color:#fff;border:none;border-radius:8px;font-size:13px;cursor:pointer;font-weight:600;">Use this size â continue the look</button>' + 4288 '<button id="sg-sf-close" style="padding:8px 12px;background:transparent;color:#999;border:1px solid #ddd;border-radius:8px;font-size:12px;cursor:pointer;">Close</button>' + 4289 '</div>' + 4290 '</div>'; 4291 document.getElementById("sg-sf-close").onclick = function() { div.remove(); }; 4292 document.getElementById("sg-sf-use").onclick = function() { 4293 trackEvent("size_finder_used", { size: data.size }); 4294 div.remove(); 4295 var chatInput = document.getElementById("sg-chat-input"); 4296 if (chatInput) { 4297 chatInput.value = "I've used the FitCore size finder â " + summary + ". Let's carry on with the look."; 4298 var send = document.getElementById("sg-chat-send"); 4299 if (send) send.disabled = false;
4300 sendMessage(); 4301 } 4302 }; 4303 }; 4304 xhr.send(JSON.stringify({ 4305 shop: shopDomain, 4306 height_cm: Math.round(heightCm * 10) / 10, 4307 weight_kg: Math.round(weightKg * 10) / 10, 4308 build: num("sg-sf-build") / 100, 4309 })); 4310 }; 4311 } 4312 4313 function showHandoffForm(reason, prefillMessage) { 4314 var container = document.getElementById("sg-messages"); 4315 if (!container) return; 4316 var existing = document.getElementById("sg-handoff-form"); 4317 if (existing) { existing.scrollIntoView({ block: "nearest" }); return; } 4318 4319 var pc = config.primaryColor || "#C6A85B"; 4320 var div = document.createElement("div"); 4321 div.className = "sg-msg sg-msg-assistant"; 4322 div.id = "sg-handoff-form"; 4323 var inputStyle = "width:100%;padding:8px 10px;border:1px solid #ccc;border-radius:8px;font-size:13px;line-height:1.45;background:#fff;color:#333;box-sizing:border-box;font-family:inherit;min-height:0;margin:0;letter-spacing:normal;text-transform:none;"; 4324 div.innerHTML = 4325 '<div class="sg-msg-bubble" style="' + sgFormTint() + 'width:100%;">' + 4326 '<p style="margin:0 0 10px;font-size:13px;line-height:1.5;font-weight:600;">Send us a message</p>' + 4327 '<p style="margin:0 0 10px;font-size:12.5px;line-height:1.5;color:#666;">Leave your question and the team will reply by email.</p>' + 4328 '<div style="margin-bottom:8px"><input type="text" id="sg-handoff-name" placeholder="Your name (optional)" style="' + inputStyle + '" /></div>' + 4329 '<div style="margin-bottom:8px"><input type="email" id="sg-handoff-email" placeholder="Your email address" style="' + inputStyle + '" /></div>' + 4330 '<div style="margin-bottom:8px"><textarea id="sg-handoff-message" placeholder="Your message" rows="3" style="' + inputStyle + 'resize:vertical;min-height:64px;">' + escapeHtml(prefillMessage || "") + '</textarea></div>' + 4331 '<div style="display:flex;gap:8px;margin-top:10px">' + 4332 '<button id="sg-handoff-submit" style="flex:1;padding:8px 12px;background:' + pc + ';color:#fff;border:none;border-radius:8px;font-size:13px;cursor:pointer;font-weight:600;">Send message</button>' + 4333 '<button id="sg-handoff-dismiss" style="padding:8px 12px;background:transparent;color:#999;border:1px solid #ddd;border-radius:8px;font-size:12px;cursor:pointer;">Not now</button>' + 4334 '</div>' + 4335 '</div>'; 4336 4337 container.appendChild(div); 4338 container.scrollTop = container.scrollHeight; 4339 4340 document.getElementById("sg-handoff-dismiss").onclick = function() { div.remove(); }; 4341 4342 document.getElementById("sg-handoff-submit").onclick = function() { 4343 var emailEl = document.getElementById("sg-handoff-email"); 4344 var msgEl = document.getElementById("sg-handoff-message"); 4345 var email = (emailEl.value || "").trim(); 4346 var msg = (msgEl.value || "").trim(); 4347 var ok = true; 4348 if (!email || email.indexOf("@") === -1 || email.indexOf(".") === -1) { emailEl.style.borderColor = "#e44"; ok = false; } 4349 if (!msg) { msgEl.style.borderColor = "#e44"; ok = false; } 4350 if (!ok) return; 4351 4352 var btn = document.getElementById("sg-handoff-submit"); 4353 btn.textContent = "Sendingâ¦"; 4354 btn.disabled = true; 4355 4356 // Last few exchanges so the team sees the conversation context. 4357 var transcript = ""; 4358 try { 4359 transcript = messages.slice(-12).map(function(m) { 4360 return (m.role === "user" ? "Customer: " : "Stylist: ") + String(m.content || "").slice(0, 400); 4361 }).join("\n"); 4362 } catch (e) {} 4363 4364 var payload = { 4365 shop: shopDomain, 4366 sessionId: sessionId, 4367 name: (document.getElementById("sg-handoff-name").value || "").trim(), 4368 email: email, 4369 message: msg, 4370 reason: reason || null, 4371 transcript: transcript 4372 }; 4373 4374 var xhr = new XMLHttpRequest(); 4375 xhr.open("POST", apiUrl("/handoff")); 4376 xhr.setRequestHeader("Content-Type", "application/json"); 4377 xhr.onreadystatechange = function() { 4378 if (xhr.readyState !== 4) return; 4379 var success = xhr.status >= 200 && xhr.status < 300; 4380 if (success) startReplyPolling(); 4381 div.remove(); 4382 var doneDiv = document.createElement("div"); 4383 doneDiv.className = "sg-msg sg-msg-assistant"; 4384 doneDiv.innerHTML = '<div class="sg-msg-bubble" style="background:linear-gradient(135deg,#f0faf2,#f9fcf9);border:1px solid #b8dcbc;">' + 4385 '<p style="margin:0;font-size:13px;">' + 4386 (success 4387 ? "Your message is with the team â they'll reply to " + escapeHtml(email) + " as soon as they can. Anything else I can help with in the meantime?" 4388 : "Sorry â your message didn't go through. Please try again in a moment.") + 4389 '</p></div>'; 4390 container.appendChild(doneDiv); 4391 container.scrollTop = container.scrollHeight; 4392 }; 4393 xhr.send(JSON.stringify(payload)); 4394 }; 4395 } 4396 4397 function trackShownProducts(outfitsOrBundles) { 4398 if (!outfitsOrBundles) return; 4399 for (var gi = 0; gi < outfitsOrBundles.length; gi++) { 4400 var group = outfitsOrBundles[gi]; 4401 var items = group.items || []; 4402 for (var ii = 0; ii < items.length; ii++) { 4403 var pid = items[ii].productId || items[ii].id; 4404 if (pid && shownProductIds.indexOf(String(pid)) === -1) { 4405 shownProductIds.push(String(pid)); 4406 } 4407 } 4408 } 4409 } 4410 4411 // Rep-style quick replies: the agent sends 2-3 follow-up questions with 4412 // every presentation â pin them to the reply so the next step is a tap. 4413 function attachSuggestions(qs) { 4414 if (!qs || !qs.length) return; 4415 var lm = messages[messages.length - 1]; 4416 if (lm && lm.role === "assistant") { 4417 lm.suggestedQuestions = qs.slice(0, 3).map(function (q) { return String(q).slice(0, 80); }); 4418 } 4419 } 4420 4421 function sendToAPI(text) { 4422 isLoading = true; 4423 lastSentText = text; 4424 sgEarlyAudioUrl = null; 4425 // True once the stream delivered an error event â the stream-closed 4426 // handler re-renders messages (wiping appended chips), so the retry chip 4427 // must be re-added after that final render. 4428 var streamErrored = false;
4429 showTyping(); 4430 4431 reDetectCurrency(); 4432 var chatBody = { 4433 message: text, 4434 locale: sgLocale, 4435 sessionId: sessionId, 4436 shop: shopDomain, 4437 stream: true 4438 }; 4439 if (detectedCurrency) chatBody.currencyCode = detectedCurrency; 4440 if (devCustomerId) chatBody.customerId = devCustomerId; // dev/testing only; production uses the signed App Proxy param 4441 if (shownProductIds.length > 0) chatBody.shownProductIds = shownProductIds.slice(); 4442 // FitCore sizes applied on the storefront â every future recommendation 4443 // is size-aware without the customer repeating themselves. 4444 var fcSizes = storedFitcoreSizes(); 4445 if (fcSizes) chatBody.fitcoreSizes = fcSizes; 4446 4447 var streamingMsg = { role: "assistant", content: "", outfits: [] }; 4448 messages.push(streamingMsg); 4449 var streamingIdx = messages.length - 1; 4450 4451 fetch(apiUrl("/chat"), { 4452 method: "POST", 4453 headers: { "Content-Type": "application/json" }, 4454 body: JSON.stringify(chatBody) 4455 }).then(function(response) { 4456 if (!response.ok) { 4457 throw new Error("HTTP " + response.status); 4458 } 4459 4460 var contentType = response.headers.get("content-type") || ""; 4461 if (contentType.indexOf("text/event-stream") === -1) { 4462 return response.json().then(function(data) { 4463 hideTyping(); 4464 isLoading = false; 4465 var outfits = data.outfits || []; 4466 var replyContent = data.reply; 4467 if (data.allCandidatesExhausted && outfits.length === 0 && (!data.bundles || data.bundles.length === 0)) { 4468 replyContent += "\n\nYou've seen all available options for this style. Would you like to explore a different colour or style?"; 4469 } 4470 messages[streamingIdx] = { role: "assistant", content: replyContent, outfits: outfits }; 4471 if (data.sessionId) { 4472 sessionId = data.sessionId; 4473 try { localStorage.setItem("sg_session_" + shopDomain, sessionId); } catch(e) {} 4474 } 4475 // Hybrid model: best outfit â Complete Look zone; filterCmd â product grid 4476 var bestNS = (outfits.length > 0 ? outfits[0] : null) || (data.bundles && data.bundles.length > 0 ? data.bundles[0] : null); 4477 if (bestNS) { 4478 var revealNS = (function (o, d, best) { return function () { 4479 lastOutfits = o.length > 0 ? o : (d.bundles || []); 4480 if (lastOutfits.length) persistLastOutfits(lastOutfits); 4481 trackShownProducts(lastOutfits); 4482 mergeIntoSessionLook(best); 4483 renderSessionLook(); 4484 if (d.occasion) window.__sgLastOccasion = d.occasion; 4485 if (d.filterCmd) { 4486 applyAIFilters(d.filterCmd, { chatPreview: true }); 4487 } else { 4488 showDiscussedProducts([{ items: sessionLook }]); 4489 } 4490 showViewProductsChip(T("viewOutfit")); 4491 }; })(outfits, data, bestNS); 4492 if (config.leadGate && !sgLeadDone()) { 4493 var gateSummaryNS = (outfits.length > 0 ? outfits : (data.bundles || [])).slice(0, 2) 4494 .map(function (b) { return b.label || b.occasion || b.title || ""; }).filter(Boolean).join(", "); 4495 setTimeout(function () { sgShowLeadGate(revealNS, gateSummaryNS || null); }, 300); 4496 } else { 4497 revealNS(); 4498 } 4499 } else if (data.filterCmd) { 4500 applyAIFilters(data.filterCmd, { chatPreview: true }); 4501 } 4502 if (data.audioUrl) { playAudio(data.audioUrl, data.audioUrl2); } else { speakFallback(replyContent); } 4503 attachSuggestions(data.suggestedQuestions); 4504 renderMessages(); 4505 if (!bestNS && (data.filterCmd || proactiveFilterApplied)) { 4506 showViewProductsChip(T("viewProducts")); 4507 } 4508 if (data.handoff) { 4509 (function(h, q) { setTimeout(function() { showHandoffForm(h.reason || null, q); }, 400); })(data.handoff, text); 4510 } 4511 if (data.sizeFinder) { 4512 setTimeout(openSizeFinderUI, 400); 4513 } 4514 if (data.sizes) mergeFitcoreSizes(data.sizes); 4515 if (data.addToCart) agentAddLookToCart(data.outfits); 4516 if (data.openCart) agentOpenCart(); 4517 // Only check capture intent after at least 3 complete exchanges (6 messages) 4518 if (messages.length >= 6) { 4519 var detectedIntentNS = detectIntent(text, replyContent); 4520 if (detectedIntentNS) { 4521 var outfitNamesNS = lastOutfits && lastOutfits.length > 0 4522 ? lastOutfits.slice(0, 2).map(function(b) { return b.label || b.occasion || ""; }
4522).filter(Boolean).join(", ") 4523 : ""; 4524 setTimeout(function() { showCapturePrompt(detectedIntentNS, outfitNamesNS || null); }, 1500); 4525 } 4526 } 4527 }); 4528 } 4529 4530 // Keep the typing indicator up until the first token arrives â status 4531 // events update its label while the agent works its tools. 4532 var reader = response.body.getReader(); 4533 var decoder = new TextDecoder(); 4534 var buffer = ""; 4535 4536 function processStream() { 4537 return reader.read().then(function(result) { 4538 if (result.done) { 4539 isLoading = false; 4540 hideTyping(); 4541 renderMessages(); 4542 if (streamErrored) showRetryChip(); 4543 return; 4544 } 4545 4546 buffer += decoder.decode(result.value, { stream: true }); 4547 var lines = buffer.split("\n"); 4548 buffer = lines.pop() || ""; 4549 4550 for (var i = 0; i < lines.length; i++) { 4551 var line = lines[i]; 4552 if (line.indexOf("data: ") !== 0) continue; 4553 try { 4554 var evt = JSON.parse(line.substring(6)); 4555 if (evt.type === "token") { 4556 hideTyping(); // first token replaces the dots (and any status label) 4557 messages[streamingIdx].content += evt.content; 4558 renderMessages(); 4559 } else if (evt.type === "status") { 4560 setTypingStatus(evt.label || ""); 4561 } else if (evt.type === "audio") { 4562 // First-sentence clip, announced while the reply is still 4563 // streaming â start speaking now, in step with the text. 4564 sgEarlyAudioUrl = evt.audioUrl; 4565 playAudio(evt.audioUrl); 4566 } else if (evt.type === "error") { 4567 messages[streamingIdx].content = evt.message || "Sorry, I'm having trouble right now. Please try again."; 4568 isLoading = false; 4569 streamErrored = true; 4570 hideTyping(); 4571 renderMessages(); 4572 showRetryChip(); 4573 } else if (evt.type === "done") { 4574 hideTyping(); 4575 var replyText = evt.reply; 4576 if (evt.allCandidatesExhausted && (!evt.outfits || evt.outfits.length === 0) && (!evt.bundles || evt.bundles.length === 0)) { 4577 replyText += "\n\nYou've seen all available options for this style. Would you like to explore a different colour or style?"; 4578 } 4579 messages[streamingIdx].content = replyText; 4580 messages[streamingIdx].outfits = evt.outfits || []; 4581 if (evt.sessionId) { 4582 sessionId = evt.sessionId; 4583 try { localStorage.setItem("sg_session_" + shopDomain, sessionId); } catch(e) {} 4584 } 4585 // Hybrid model: best outfit â Complete Look zone; filterCmd â product grid 4586 var bestST = (evt.outfits && evt.outfits.length > 0 ? evt.outfits[0] : null) 4587 || (evt.bundles && evt.bundles.length > 0 ? evt.bundles[0] : null); 4588 var revealST = null; 4589 if (bestST) { 4590 revealST = (function (e, best) { return function () { 4591 lastOutfits = e.outfits && e.outfits.length > 0 ? e.outfits : (e.bundles || []); 4592 if (lastOutfits.length) persistLastOutfits(lastOutfits); 4593 trackShownProducts(lastOutfits); 4594 mergeIntoSessionLook(best); 4595 renderSessionLook(); 4596 if (e.filterCmd) { 4597 applyAIFilters(e.filterCmd, { chatPreview: true }); 4598 } else { 4599 showDiscussedProducts([{ items: sessionLook }]); 4600 } 4601 showViewProductsChip(T("viewOutfit")); 4602 }; })(evt, bestST); 4603 } else if (evt.filterCmd) { 4604 applyAIFilters(evt.filterCmd, { chatPreview: true }); 4605 } 4606 if (evt.audioUrl) { 4607 if (sgEarlyAudioUrl && evt.audioUrl === sgEarlyAudioUrl) { 4608 // First sentence already speaking â just line up the rest. 4609 if (evt.audioUrl2) queueFollowUpAudio(evt.audioUrl2); 4610 } else { 4611 playAudio(evt.audioUrl, evt.audioUrl2); 4612 } 4613 } else if (!sgEarlyAudioUrl) { 4614 speakFallback(replyText); 4615 } 4616 sgEarlyAudioUrl = null; 4617 isLoading = false;
4618 attachSuggestions(evt.suggestedQuestions); 4619 renderMessages(); 4620 if (revealST) { 4621 if (config.leadGate && !sgLeadDone()) { 4622 var gateSummaryST = (evt.outfits && evt.outfits.length > 0 ? evt.outfits : (evt.bundles || [])).slice(0, 2) 4623 .map(function (b) { return b.label || b.occasion || b.title || ""; }).filter(Boolean).join(", "); 4624 (function (r, s) { setTimeout(function () { sgShowLeadGate(r, s || null); }, 300); })(revealST, gateSummaryST); 4625 } else { 4626 revealST(); 4627 } 4628 } else if (evt.filterCmd || proactiveFilterApplied) { 4629 showViewProductsChip(T("viewProducts")); 4630 } 4631 if (evt.handoff) { 4632 (function(h, q) { setTimeout(function() { showHandoffForm(h.reason || null, q); }, 400); })(evt.handoff, text); 4633 } 4634 if (evt.sizeFinder) { 4635 setTimeout(openSizeFinderUI, 400); 4636 } 4637 if (evt.sizes) mergeFitcoreSizes(evt.sizes); 4638 if (evt.addToCart) agentAddLookToCart(evt.outfits); 4639 if (evt.openCart) agentOpenCart(); 4640 // Only check capture intent after at least 3 complete exchanges (6 messages) 4641 if (messages.length >= 6) { 4642 var detectedIntent = detectIntent(text, replyText); 4643 if (detectedIntent) { 4644 var outfitNames = lastOutfits && lastOutfits.length > 0 4645 ? lastOutfits.slice(0, 2).map(function(b) { return b.label || b.occasion || ""; }).filter(Boolean).join(", ") 4646 : ""; 4647 setTimeout(function() { showCapturePrompt(detectedIntent, outfitNames || null); }, 1500); 4648 } 4649 } 4650 } 4651 } catch(e) { 4652 // Never break the stream on a UI error, but don't hide it either. 4653 try { console.error("[StyleGuru] stream event error:", e); } catch (e2) {} 4654 } 4655 } 4656 return processStream(); 4657 }); 4658 } 4659 4660 return processStream(); 4661 }).catch(function(err) { 4662 isLoading = false; 4663 hideTyping(); 4664 messages[streamingIdx] = { role: "assistant", content: "Sorry, I'm having trouble right now. Please try again." }; 4665 renderMessages(); 4666 showRetryChip(); 4667 }); 4668 } 4669 4670 function renderMessages() { 4671 var container = document.getElementById("sg-messages"); 4672 if (!container) return; 4673 var html = ""; 4674 4675 if (messages.length === 0) { 4676 html += '<div class="sg-welcome-section">'; 4677 html += '<div class="sg-welcome-title">' + escapeHtml(config.stylistName) + '</div>'; 4678 var sgReturning = false; 4679 try { sgReturning = localStorage.getItem("sg_used_" + shopDomain) === "1"; } catch (e) {} 4680 html += '<div class="sg-welcome-sub">' + escapeHtml(sgReturning ? T("welcomeBack") : config.welcomeMessage) + '</div>'; 4681 // Starter chips: only while the conversation is fresh (this whole welcome 4682 // section renders only when messages.length === 0, so the chips disappear 4683 // as soon as the first user message is sent). 4684 html += '<div class="sg-suggestions">'; 4685 html += '<button class="sg-suggestion" data-starter="1" data-msg="Build me an outfit" data-testid="suggestion-outfit">' + T("starterOutfit") + '</button>'; 4686 html += '<button class="sg-suggestion" data-starter="1" data-msg="What\'s my size?" data-testid="suggestion-size">' + T("starterSize") + '</button>'; 4687 html += '<button class="sg-suggestion" data-starter="1" data-msg="Where\'s my order?" data-testid="suggestion-order">' + T("starterOrder") + '</button>'; 4688 html += '</div>'; 4689 html += '</div>'; 4690 } else { 4691 for (var i = 0; i < messages.length; i++) { 4692 var msg = messages[i]; 4693 html += '<div class="sg-msg sg-msg-' + msg.role + '">'; 4694 if (msg.storeReply) { 4695 html += '<div style="font-size:10px;letter-spacing:0.08em;text-transform:uppercase;opacity:0.6;margin:0 0 3px 6px;">' + escapeHtml(config.storeName || "") + ' â ' + T("storeReply") + '</div>'; 4696 html += '<div class="sg-msg-bubble" style="background:linear-gradient(135deg,#f0faf2,#f9fcf9);border:1px solid #b8dcbc;">' + formatMessageText(msg.content); 4697 } else { 4698 html += '<div class="sg-msg-bubble">' + formatMessageText(msg.content); 4699 } 4700 if (msg.role === "assistant" && msg.outfits && msg.outfits.length > 0) { 4701 html += renderInlineItems(msg.outfits, i); 4702 } 4703 html += '</div>'; 4704 // Thumbs on real answers (an assistant reply to a user message): 4705 // up = quiet signal, down = lands in the merchant's Corrections queue. 4706 if (msg.role === "assistant" && i === messages.length - 1 && !isLoading && msg.suggestedQuestions && msg.suggestedQuestions.length) { 4707 html += '<div class="sg-sq-row" style="display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 2px 6px;">'; 4708 for (var sq = 0; sq < msg.suggestedQuestions.length; sq++) { 4709 html += '<button class="sg-suggestion" data-sq="' + escapeHtml(msg.suggestedQuestions[sq]) + '" data-testid="chip-suggestion-' + sq + '">' + escapeHtml(msg.suggestedQuestions[sq]) + '</button>'; 4710 } 4711 html += '</div>'; 4712 } 4713 if (msg.role === "assistant" && i > 0 && messages[i - 1] && messages[i - 1].role === "user" && msg.content) { 4714 if (feedbackVotes[i]) { 4715 html += '<div class="sg-fb-thanks">' + T("thanksFeedback") + '</div>'; 4716 } else { 4717 html += '<div class="sg-fb-row" data-fb-row="' + i + '">' + 4718 '<button class="sg-fb-btn" data-fb-idx="' + i + '" data-fb-rating="up" title="Helpful" aria-label="Helpful">' + 4719 '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg></button>' + 4720 '<button class="sg-fb-btn" data-fb-idx="' + i + '" data-fb-rating="down" title="Not right" aria-label="Not right">' + 4721 '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg></button>' + 4722 '</div>'; 4723 } 4724 } 4725 html += '</div>'; 4726 } 4727 } 4728 4729 container.innerHTML = html; 4730 container.scrollTop = container.scrollHeight; 4731 4732 var inlineItems = container.querySelectorAll(".sg-inline-item"); 4733 for (var ii = 0; ii < inlineItems.length; ii++) { 4734 var testId = inlineItems[ii].getAttribute("data-testid") || ""; 4735 var match = testId.match(/inline-item-(\d+)-(\d+)/); 4736 if (match) { 4737 var msgI = parseInt(match[1], 10); 4738 var itemI = parseInt(match[2], 10); 4739 if (messages[msgI] && messages[msgI].outfits) { 4740 var flatInline = []; 4741 for (var fo = 0; fo < messages[msgI].outfits.length; fo++) { 4742 if (messages[msgI].outfits[fo].items) { 4743 for (var fi2 = 0; fi2 < messages[msgI].outfits[fo].items.length; fi2++) { 4744 flatInline.push(messages[msgI].outfits[fo].items[fi2]); 4745 } 4746 } 4747 } 4748 if (flatInline[itemI]) { 4749 inlineItems[ii]._sgItemData = flatInline[itemI]; 4750 } 4751 inlineItems[ii].addEventListener("click", function() { 4752 if (this._sgItemData) focusPaneProduct(this._sgItemData); 4753 }); 4754 } 4755 } 4756 } 4757 4758 var sqBtns = container.querySelectorAll("[data-sq]"); 4759 for (var sqi = 0; sqi < sqBtns.length; sqi++) {
4760 sqBtns[sqi].addEventListener("click", function () { 4761 if (isLoading) return; 4762 var q = this.getAttribute("data-sq"); 4763 if (!q) return; 4764 trackEvent("suggestion_chip_used", { q: q.slice(0, 60) }); 4765 var row = this.closest(".sg-sq-row"); 4766 if (row) row.remove(); 4767 messages.push({ role: "user", content: q }); 4768 renderMessages(); 4769 sendToAPI(q); 4770 }); 4771 } 4772 4773 var fbBtns = container.querySelectorAll(".sg-fb-btn"); 4774 for (var fb = 0; fb < fbBtns.length; fb++) { 4775 fbBtns[fb].addEventListener("click", function() { 4776 var idx = parseInt(this.getAttribute("data-fb-idx"), 10); 4777 var rating = this.getAttribute("data-fb-rating"); 4778 if (feedbackVotes[idx]) return; 4779 feedbackVotes[idx] = rating; 4780 var row = container.querySelector('[data-fb-row="' + idx + '"]'); 4781 if (row) { row.outerHTML = '<div class="sg-fb-thanks">' + T("thanksFeedback") + '</div>'; } 4782 trackEvent("answer_feedback", { rating: rating }); 4783 try { 4784 var fbXhr = new XMLHttpRequest(); 4785 fbXhr.open("POST", apiUrl("/feedback")); 4786 fbXhr.setRequestHeader("Content-Type", "application/json"); 4787 fbXhr.send(JSON.stringify({ 4788 rating: rating, 4789 question: String(messages[idx - 1].content || "").slice(0, 600), 4790 answer: String(messages[idx].content || "").slice(0, 3000), 4791 anonId: (sessionId && sessionId.length >= 8) ? sessionId : undefined 4792 })); 4793 } catch (e) {} 4794 }); 4795 } 4796 4797 var suggestions = container.querySelectorAll(".sg-suggestion"); 4798 for (var j = 0; j < suggestions.length; j++) { 4799 suggestions[j].addEventListener("click", function() { 4800 var msgText = this.getAttribute("data-msg"); 4801 if (msgText) { 4802 if (this.getAttribute("data-starter")) { 4803 trackEvent("starter_chip_used", { label: this.textContent }); 4804 } 4805 var chatInput = document.getElementById("sg-chat-input"); 4806 chatInput.value = msgText; 4807 sendMessage(); 4808 } 4809 }); 4810 } 4811 4812 var inlineCartBtns = container.querySelectorAll(".sg-inline-add-btn:not(:disabled)"); 4813 for (var ib = 0; ib < inlineCartBtns.length; ib++) { 4814 inlineCartBtns[ib].addEventListener("click", function() { 4815 inlineAddToCart(this); 4816 }); 4817 } 4818 4819 var inlineSelects = container.querySelectorAll(".sg-inline-variant-select:not(.sg-inline-component-select)"); 4820 for (var is2 = 0; is2 < inlineSelects.length; is2++) { 4821 inlineSelects[is2].addEventListener("change", function() { 4822 var sel = this; 4823 var vid = sel.value; 4824 if (!vid) return; // back on the "Select size" placeholder 4825 clearSizeNudge(sel); 4826 var inlineItem = sel.closest(".sg-inline-item"); 4827 if (inlineItem) { 4828 var btn = inlineItem.querySelector(".sg-inline-add-btn"); 4829 if (btn) { 4830 btn.setAttribute("data-variant-id", vid); 4831 btn.removeAttribute("data-product-id"); 4832 } 4833 } 4834 }); 4835 } 4836 4837 var inlineCompSelects = container.querySelectorAll(".sg-inline-component-select"); 4838 for (var ics = 0; ics < inlineCompSelects.length; ics++) { 4839 inlineCompSelects[ics].addEventListener("change", function() { 4840 var sel = this; 4841 if (sel.value) sel.classList.remove("sg-needs-size"); 4842 var inlineItem = sel.closest(".sg-inline-item"); 4843 if (!inlineItem) return; 4844 var allComps = inlineItem.querySelectorAll(".sg-inline-component-select"); 4845 var selections = []; 4846 var allFilled = true; 4847 for (var ai = 0; ai < allComps.length; ai++) { 4848 var s = allComps[ai]; 4849 // Advisory selectors capture a preference only â they must neither 4850 // gate variant resolution nor participate in matching. 4851 if (s.getAttribute("data-advisory") === "true") continue; 4852 if (!s.value) { allFilled = false; continue; } 4853 selections.push({ 4854 optionIndex: parseInt(s.getAttribute("data-option-index") || "0", 10), 4855 splitIndex: s.hasAttribute("data-split-index") ? parseInt(s.getAttribute("data-split-index"), 10) : undefined, 4856 value: s.value 4857 }); 4858 } 4859 if (!allFilled) return; 4860 var itemData = inlineItem._sgItemData; 4861 if (!itemData || !itemData.variants) return; 4862 var matched = findMatchingVariant(itemData.variants, selections); 4863 if (matched) { 4864 clearSizeNudge(sel); 4865 var btn = inlineItem.querySelector(".sg-inline-add-btn"); 4866 if (btn) { 4867 btn.setAttribute("data-variant-id", matched.id); 4868 btn.removeAttribute("data-product-id"); 4869 if (!matched.available) { 4870 btn.disabled = true; 4871 btn.classList.add("sg-sold-out");
4872 btn.textContent = "Sold Out"; 4873 } else { 4874 btn.disabled = false; 4875 btn.classList.remove("sg-sold-out"); 4876 var iconColor = isLightColor(config.primaryColor || "#C6A85B") ? "#111" : "#fff"; 4877 btn.innerHTML = cartIcon(iconColor) + ' Add'; 4878 } 4879 } 4880 } 4881 }); 4882 } 4883 } 4884 4885 function renderInlineItems(outfits, msgIdx) { 4886 var items = []; 4887 for (var o = 0; o < outfits.length; o++) { 4888 if (outfits[o].items) { 4889 for (var it = 0; it < outfits[o].items.length; it++) { 4890 items.push(outfits[o].items[it]); 4891 } 4892 } 4893 } 4894 if (items.length === 0) return ""; 4895 4896 // Slim references only: the chat TALKS about the pieces; sizing and 4897 // add-to-bag live in the products pane. Tapping a row jumps there. 4898 var html = '<div class="sg-inline-items">'; 4899 for (var k = 0; k < items.length; k++) { 4900 var item = items[k]; 4901 var displayPrice = formatDisplayPrice(item.formattedPrice || item.price, item.currencyCode); 4902 html += '<div class="sg-inline-item sg-inline-slim" role="button" tabindex="0" data-testid="inline-item-' + msgIdx + '-' + k + '">'; 4903 if (item.image) { 4904 html += '<img class="sg-inline-item-img" src="' + escapeHtml(item.image) + '" alt="' + escapeHtml(item.name) + '" loading="lazy" />'; 4905 } 4906 html += '<div class="sg-inline-item-info">'; 4907 html += '<div class="sg-inline-item-name">' + escapeHtml(item.name) + '</div>'; 4908 if (displayPrice) html += '<div class="sg-inline-item-price">' + escapeHtml(displayPrice) + (item.soldOut === true ? ' <span class="sg-inline-soldout">Sold out</span>' : '') + '</div>'; 4909 html += '</div>'; 4910 html += '<span class="sg-inline-chevron" aria-hidden="true">›</span>'; 4911 html += '</div>'; 4912 } 4913 html += '</div>'; 4914 return html; 4915 } 4916 4917 // ââ Size gate âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4918 // A sized product must have a size actively chosen before Add works â 4919 // otherwise the first variant rides silently into the cart and comes back 4920 // as a return. The nudge highlights the picker and offers the size tools. 4921 function showSizeNudge(btn) { 4922 var card = btn.closest(".sg-product-card") || btn.closest(".sg-inline-item"); 4923 if (!card) return; 4924 var selects = card.querySelectorAll(".sg-variant-select:not([data-advisory='true']), .sg-inline-variant-select:not([data-advisory='true'])"); 4925 for (var i = 0; i < selects.length; i++) { 4926 if (!selects[i].value) selects[i].classList.add("sg-needs-size"); 4927 } 4928 if (!card.querySelector(".sg-size-msg")) { 4929 var msg = document.createElement("div"); 4930 msg.className = "sg-size-msg"; 4931 msg.innerHTML = 'Choose your size first â <a data-testid="link-size-help">not sure? Ask me</a>'; 4932 var actions = card.querySelector(".sg-product-actions"); 4933 if (actions) actions.parentNode.insertBefore(msg, actions.nextSibling); 4934 else card.appendChild(msg); 4935 var ask = msg.querySelector("a"); 4936 if (ask) ask.addEventListener("click", function() { 4937 var input = document.getElementById("sg-chat-input"); 4938 if (input) { input.value = "What's my size?"; sendMessage(); } 4939 }); 4940 } 4941 trackEvent("size_gate_blocked"); 4942 var first = card.querySelector(".sg-needs-size"); 4943 if (first && first.scrollIntoView) first.scrollIntoView({ block: "nearest", behavior: "smooth" }); 4944 } 4945 4946 function clearSizeNudge(el) { 4947 var card = el.closest(".sg-product-card") || el.closest(".sg-inline-item"); 4948 if (!card) return; 4949 var marked = card.querySelectorAll(".sg-needs-size"); 4950 for (var i = 0; i < marked.length; i++) marked[i].classList.remove("sg-needs-size"); 4951 var msg = card.querySelector(".sg-size-msg"); 4952 if (msg && msg.parentNode) msg.parentNode.removeChild(msg); 4953 } 4954 4955 function inlineAddToCart(btn) { 4956 var variantId = btn.getAttribute("data-variant-id"); 4957 var productId = btn.getAttribute("data-product-id"); 4958 if (btn.getAttribute("data-size-required") === "1" && !variantId) { 4959 showSizeNudge(btn); 4960 return; 4961 } 4962 if (!variantId && !productId) return; 4963 4964 btn.disabled = true; 4965 var origHtml = btn.innerHTML; 4966 btn.innerHTML = '<span style="width:10px;height:10px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;display:inline-block;animation:sg-spin 0.6s linear infinite"></span>'; 4967 4968 var styleEl = document.getElementById("sg-spin-style"); 4969 if (!styleEl) { 4970 styleEl = document.createElement("style"); 4971 styleEl.id = "sg-spin-style"; 4972 styleEl.textContent = '@keyframes sg-spin { to { transform: rotate(360deg); } }'; 4973 document.head.appendChild(styleEl); 4974 } 4975 4976 var iconColor = isLightColor(config.primaryColor || "#C6A85B") ? "#111" : "#fff"; 4977 4978 if (variantId) { 4979 doInlineCartAdd(variantId, btn, iconColor); 4980 } else if (productId) { 4981 var inlineCard = btn.closest(".sg-inline-item"); 4982 var linkEl = inlineCard ? inlineCard.querySelector(".sg-inline-item-name a") : null; 4983 var handle = null; 4984 if (linkEl) { 4985 var href = linkEl.getAttribute("href") || ""; 4986 var hMatch = href.match(/\/products\/([^?#]+)/); 4987 if (hMatch) handle = hMatch[1]; 4988 } 4989 if (!handle) { 4990 inlineCartError(btn, iconColor); 4991 return; 4992 } 4993 var productUrl = "https://" + shopDomain + "/products/" + handle + ".json"; 4994 var xhr = new XMLHttpRequest(); 4995 xhr.open("GET", productUrl); 4996 xhr.onreadystatechange = function() { 4997 if (xhr.readyState === 4) { 4998 if (xhr.status === 200) { 4999 try { 5000 var data = JSON.parse(xhr.responseText); 5001 var vid = data.product && data.product.variants && data.product.variants[0] ? data.product.variants[0].id : null; 5002 if (vid) { 5003 doInlineCartAdd(vid, btn, iconColor); 5004 } else { 5005 inlineCartError(btn, iconColor); 5006 } 5007 } catch(e) { inlineCartError(btn, iconColor); } 5008 } else { 5009 inlineCartError(btn, iconColor); 5010 } 5011 } 5012 }; 5013 xhr.send(); 5014 } 5015 } 5016 5017 function doInlineCartAdd(variantId, btn, iconColor) { 5018 var xhr = new XMLHttpRequest(); 5019 xhr.open("POST", "/cart/add.js"); 5020 xhr.setRequestHeader("Content-Type", "application/json"); 5021 xhr.onreadystatechange = function() { 5022 if (xhr.readyState === 4) { 5023 if (xhr.status === 200) { 5024 markCartAttributed(); 5025 trackEvent("add_bundle_success", { variantId: variantId, source: "inline_chat" }); 5026 btn.classList.add("sg-added"); 5027 btn.innerHTML = checkIcon("#fff") + ' Added'; 5028 btn.disabled = true; 5029 setTimeout(function() { 5030 btn.classList.remove("sg-added"); 5031 btn.innerHTML = cartIcon(iconColor) + ' Add'; 5032 btn.disabled = false;
5033 }, 3000); 5034 } else { 5035 inlineCartError(btn, iconColor); 5036 } 5037 } 5038 }; 5039 xhr.send(JSON.stringify({ id: variantId, quantity: 1 })); 5040 } 5041 5042 function inlineCartError(btn, iconColor) { 5043 btn.innerHTML = 'N/A'; 5044 btn.disabled = true; 5045 setTimeout(function() { 5046 btn.innerHTML = cartIcon(iconColor) + ' Add'; 5047 btn.disabled = false; 5048 }, 2000); 5049 } 5050 5051 function bundleIcon(color) { 5052 return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="' + color + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>'; 5053 } 5054 5055 function isSuitProduct(item) { 5056 if (item.isSuit === true) return true; 5057 if (item.isSuit === false) return false; 5058 var name = (item.name || "").toLowerCase(); 5059 // A single garment whose name merely MENTIONS "suit" â "Wedding Suit 5060 // Jacket", "Navy Suit Trousers" â is not a suit. Offering three-piece 5061 // sizing under a lone jacket or trouser listing is wrong and confusing. 5062 if (/\b(jacket|blazer|trouser|trousers|waistcoat|gilet)\b/.test(name)) return false; 5063 var tags = (item.tags || []).map(function(t) { return t.toLowerCase(); }); 5064 return name.indexOf("suit") !== -1 || tags.indexOf("suit") !== -1 || tags.indexOf("3-piece") !== -1 || tags.indexOf("three-piece") !== -1 || tags.indexOf("three piece") !== -1; 5065 } 5066 5067 function detectSuitComponents(item) { 5068 if (!isSuitProduct(item)) return null; 5069 if (!item.options || item.options.length === 0) return null; 5070 5071 var componentMap = { 5072 "jacket": ["jacket", "chest", "jacket size", "jacket chest", "coat size", "chest size", "coat"], 5073 "waistcoat": ["waistcoat", "vest", "waistcoat size", "gilet", "waist coat"], 5074 "trousers": ["trouser", "trousers", "trouser size", "waist", "leg", "inside leg", "trouser waist", "pant", "pants", "trouser waist size"] 5075 }; 5076 var components = []; 5077 for (var oi = 0; oi < item.options.length; oi++) { 5078 var optName = (item.options[oi] || "").toLowerCase().trim(); 5079 var matched = false; 5080 for (var comp in componentMap) { 5081 var keywords = componentMap[comp]; 5082 for (var ki = 0; ki < keywords.length; ki++) { 5083 if (optName.indexOf(keywords[ki]) !== -1) { 5084 components.push({ label: comp.charAt(0).toUpperCase() + comp.slice(1), optionIndex: oi }); 5085 matched = true; 5086 break; 5087 } 5088 } 5089 if (matched) break; 5090 } 5091 if (!matched) { 5092 components.push({ label: item.options[oi], optionIndex: oi }); 5093 } 5094 } 5095 5096 // Handle slash-encoded suit sizes: "38R / 30 / 38" (Jacket / Trouser / Waistcoat) 5097 if (components.length <= 1 && item.options.length === 1) { 5098 var singleOpt = (item.options[0] || "").toLowerCase(); 5099 if (singleOpt === "size" || singleOpt === "title") { 5100 var hasSlash = false; 5101 if (item.variants && item.variants.length > 0) { 5102 var firstTitle = item.variants[0].title || ""; 5103 if (firstTitle.indexOf(" / ") !== -1) hasSlash = true; 5104 } 5105 if (hasSlash) { 5106 var firstTitle2 = item.variants[0].title || ""; 5107 var slashParts = firstTitle2.split(" / "); 5108 if (slashParts.length >= 3) { 5109 components = [ 5110 { label: "Jacket", optionIndex: 0, splitIndex: 0 }, 5111 { label: "Waistcoat", optionIndex: 0, splitIndex: 1 }, 5112 { label: "Trousers", optionIndex: 0, splitIndex: 2 } 5113 ]; 5114 } else { 5115 components = [ 5116 { label: "Jacket", optionIndex: 0, splitIndex: 0 }, 5117 { label: "Trousers", optionIndex: 0, splitIndex: 1 } 5118 ]; 5119 } 5120 } 5121 } 5122 } 5123 5124 if (components.length === 0) return null; 5125 5126 // Normalise component labels â if only one option found and it's "Size" or similar, 5127 // treat it as jacket size (primary variant) and add advisory selectors for trouser/waistcoat. 5128 var hasTrouser = components.some(function(c) { return c.label.toLowerCase().indexOf("trouser") !== -1 || c.label.toLowerCase().indexOf("pant") !== -1; }); 5129 var hasWaistcoat = components.some(function(c) { return c.label.toLowerCase().indexOf("waistcoat") !== -1 || c.label.toLowerCase().indexOf("vest") !== -1; }); 5130 var hasJacket = components.some(function(c) { return c.label.toLowerCase().indexOf("jacket") !== -1 || c.label.toLowerCase().indexOf("coat") !== -1 || c.label.toLowerCase().indexOf("chest") !== -1; }); 5131 5132 // If component label is generic ("Size", "Title"), rename to "Jacket" 5133 if (!hasJacket && components.length === 1) { 5134 components[0].label = "Jacket"; 5135 hasJacket = true; 5136 } 5137 5138 var tags = (item.tags || []).map(function(t) { return t.toLowerCase(); }); 5139 var name = (item.name || "").toLowerCase(); 5140 var isThreePiece = tags.indexOf("3-piece") !== -1 || tags.indexOf("three-piece") !== -1 || 5141 tags.indexOf("three piece") !== -1 || name.indexOf("three") !== -1 || name.indexOf("3 piece") !== -1; 5142 5143 // Add advisory trouser selector if not already present 5144 if (!hasTrouser) { 5145 components.push({ label: "Trousers", optionIndex: -1, advisory: true }); 5146 }
5147 // Add advisory waistcoat selector for ALL suit products (not just 3-piece) 5148 // Leonard Silver and most menswear retailers sell suits with waistcoats as standard 5149 if (!hasWaistcoat) { 5150 components.push({ label: "Waistcoat", optionIndex: -1, advisory: true }); 5151 } 5152 5153 return components; 5154 } 5155 5156 function getUniqueOptionValues(variants, optionIndex, splitIndex) { 5157 var seen = {}; 5158 var values = []; 5159 for (var v = 0; v < variants.length; v++) { 5160 var vr = variants[v]; 5161 var val; 5162 if (splitIndex !== undefined) { 5163 var parts = (vr.title || "").split(" / "); 5164 val = (parts[splitIndex] || "").trim(); 5165 } else { 5166 val = vr["option" + (optionIndex + 1)] || ""; 5167 } 5168 if (val && !seen[val]) { 5169 seen[val] = true; 5170 var anyAvailable = false; 5171 for (var c = 0; c < variants.length; c++) { 5172 var cv; 5173 if (splitIndex !== undefined) { 5174 var cp = (variants[c].title || "").split(" / "); 5175 cv = (cp[splitIndex] || "").trim(); 5176 } else { 5177 cv = variants[c]["option" + (optionIndex + 1)] || ""; 5178 } 5179 if (cv === val && variants[c].available) { anyAvailable = true; break; } 5180 } 5181 values.push({ value: val, available: anyAvailable }); 5182 } 5183 } 5184 return values; 5185 } 5186 5187 function findMatchingVariant(variants, selections) { 5188 for (var v = 0; v < variants.length; v++) { 5189 var vr = variants[v]; 5190 var match = true; 5191 for (var s = 0; s < selections.length; s++) { 5192 var sel = selections[s]; 5193 var val; 5194 if (sel.splitIndex !== undefined) { 5195 var parts = (vr.title || "").split(" / "); 5196 val = (parts[sel.splitIndex] || "").trim(); 5197 } else { 5198 val = vr["option" + (sel.optionIndex + 1)] || ""; 5199 } 5200 if (val !== sel.value) { match = false; break; } 5201 } 5202 if (match) return vr; 5203 } 5204 return null; 5205 } 5206 5207 function componentDesiredValue(comp, values, selectedId, variants) { 5208 // 1) the preselected variant's own value for this component 5209 if (selectedId && variants) { 5210 for (var i = 0; i < variants.length; i++) { 5211 if (String(variants[i].id) === String(selectedId)) { 5212 var v = variants[i]; 5213 var val = null; 5214 if (comp.splitIndex !== undefined) val = (v.title || "").split(" / ")[comp.splitIndex]; 5215 else if (comp.optionIndex >= 0) val = v["option" + (comp.optionIndex + 1)] || (v.title || "").split(" / ")[comp.optionIndex]; 5216 if (val) { 5217 val = String(val).trim(); 5218 for (var j = 0; j < values.length; j++) if (values[j].value === val) return val; 5219 } 5220 break; 5221 } 5222 } 5223 } 5224 // 2) the shopper's known FitCore sizes 5225 var szs = storedFitcoreSizes() || {}; 5226 var lbl = (comp.label || "").toLowerCase(); 5227 if (lbl.indexOf("fit") !== -1 && szs.fit) { 5228 // Match by fit family: FitCore "tailored" belongs with "Classic"; 5229 // slim/x-slim with "Slim". Never preselect a contradicting cut. 5230 var classicWords = ["classic", "regular", "tailored"]; 5231 var slimWords = ["slim", "x-slim", "xslim", "skinny", "contemporary"]; 5232 var fitWord = String(szs.fit).toLowerCase().split(/[\s_]+/)[0]; 5233 var family = classicWords.indexOf(fitWord) !== -1 ? classicWords : slimWords.indexOf(fitWord) !== -1 ? slimWords : null; 5234 if (family) { 5235 for (var fv = 0; fv < values.length; fv++) { 5236 var valWord = String(values[fv].value).toLowerCase().split(/[\s_]+/)[0]; 5237 if (family.indexOf(valWord) !== -1 && values[fv].available !== false) return values[fv].value; 5238 } 5239 } 5240 return null; 5241 } 5242 if ((lbl.indexOf("shoe") !== -1 || lbl.indexOf("foot") !== -1) && szs.shoe) { 5243 // Shoe sizes are their own system ("UK 9 F" = system + number + Cheaney 5244 // width letter): match on the number, and on system/width only when 5245 // BOTH sides state one â "UK 9 F" belongs with a plain "9", never "9 G". 5246 var shoeRe = /^(UK|EU|US|JPN?)?(\d{1,2}(?:\.\d)?)([EFGH])?$/; 5247 var normSys = function (s) { return s === "JPN" ? "JP" : (s || null); }; 5248 var wm = shoeRe.exec(String(szs.shoe).toUpperCase().replace(/[\s"â]+/g, "")); 5249 if (wm) { 5250 for (var sk = 0; sk < values.length; sk++) { 5251 var vm = shoeRe.exec(String(values[sk].value).toUpperCase().replace(/[\s"â]+/g, "")); 5252 if (vm && Number(vm[2]) === Number(wm[2]) && 5253 (!normSys(vm[1]) || !normSys(wm[1]) || normSys(vm[1]) === normSys(wm[1])) && 5254 (!vm[3] || !wm[3] || vm[3] === wm[3]) && 5255 values[sk].available !== false) return values[sk].value; 5256 } 5257 } 5258 return null; 5259 } 5260 var want = (lbl.indexOf("trouser") !== -1 || lbl.indexOf("pant") !== -1) ? szs.trouser 5261 : (lbl.indexOf("waistcoat") !== -1 || lbl.indexOf("vest") !== -1) ? szs.size 5262 : (lbl.indexOf("jacket") !== -1 || lbl.indexOf("chest") !== -1 || lbl.indexOf("coat") !== -1 || lbl.indexOf("size") !== -1) ? szs.size 5263 : null; 5264 if (!want) return null; 5265 var wantU = String(want).toUpperCase().replace(/\s+/g, ""); 5266 var num = (String(want).match(/\d{2}(\.\d)?/) || [])[0] || null; 5267 for (var k = 0; k < values.length; k++) { 5268 var valU = String(values[k].value).toUpperCase().replace(/\s+/g, "").replace(/^UK/, ""); 5269 if (valU === wantU && values[k].available !== false) return values[k].value; 5270 } 5271 if (num) { 5272 for (var k2 = 0; k2 < values.length; k2++) { 5273 if (String(values[k2].value).indexOf(num) === 0 && values[k2].available !== false) return values[k2].value; 5274 } 5275 } 5276 return null; 5277 } 5278 5279 function renderVariantPicker(variants, selectedId, index, item) { 5280 if (!variants || variants.length === 0) return ""; 5281 var suitComponents = item ? detectSuitComponents(item) : null; 5282 5283 // --- SUIT PRODUCT: structured multi-component selectors --- 5284 if (suitComponents) { 5285 var html = '<div class="sg-variant-picker sg-suit-picker" data-testid="variant-picker-' + index + '" data-suit="true">'; 5286 for (var ci = 0; ci < suitComponents.length; ci++) { 5287 var comp = suitComponents[ci]; 5288 html += '<div class="sg-variant-select-group">'; 5289 // Pass-through option names ("Size", "Colour") already say what they are â 5290 // only append " Size" to garment-part labels (Jacket/Waistcoat/Trousers).
5291 var compLabelText = /size|colour|color|title/i.test(comp.label) ? comp.label : comp.label + ' Size'; 5292 html += '<label class="sg-variant-select-label">' + escapeHtml(compLabelText) + '</label>'; 5293 5294 if (comp.advisory) { 5295 // Advisory selector: captures preference but does not resolve a Shopify variant. 5296 // Standard trouser waist sizes (inches) or waistcoat chest sizes. 5297 var isWaistcoat = comp.label.toLowerCase().indexOf("waistcoat") !== -1; 5298 var advisorySizes = isWaistcoat 5299 ? ["36", "38", "40", "42", "44", "46", "48"] 5300 : ["28", "30", "32", "34", "36", "38", "40", "42", "44"]; 5301 html += '<select class="sg-variant-select sg-component-select sg-advisory-select"'; 5302 html += ' data-option-index="-1" data-advisory="true"'; 5303 html += ' data-component="' + comp.label.toLowerCase() + '"'; 5304 html += ' data-testid="variant-select-' + index + '-' + ci + '">'; 5305 html += '<option value="">Select ' + escapeHtml(compLabelText.toLowerCase()) + '</option>'; 5306 var advStored = storedFitcoreSizes() || {}; 5307 var advSource = isWaistcoat ? advStored.size : advStored.trouser; 5308 var advDesired = advSource ? (String(advSource).match(/\d{2}/) || [])[0] : null; 5309 for (var ai = 0; ai < advisorySizes.length; ai++) { 5310 html += '<option value="' + escapeHtml(advisorySizes[ai]) + '"' + (advisorySizes[ai] === advDesired ? ' selected' : '') + '>' + escapeHtml(advisorySizes[ai]) + '</option>'; 5311 } 5312 html += '</select>'; 5313 html += '<span class="sg-advisory-note">Preference noted â not a stock size</span>'; 5314 } else { 5315 // Primary selector: maps to an actual Shopify variant. 5316 var values = getUniqueOptionValues(variants, comp.optionIndex, comp.splitIndex); 5317 html += '<select class="sg-variant-select sg-component-select"'; 5318 html += ' data-option-index="' + comp.optionIndex + '"'; 5319 if (comp.splitIndex !== undefined) html += ' data-split-index="' + comp.splitIndex + '"'; 5320 html += ' data-component="' + comp.label.toLowerCase() + '"'; 5321 html += ' data-testid="variant-select-' + index + '-' + ci + '">'; 5322 html += '<option value="">Select ' + escapeHtml(compLabelText.toLowerCase()) + '</option>'; 5323 var compDesired = componentDesiredValue(comp, values, selectedId, variants); 5324 for (var vi = 0; vi < values.length; vi++) { 5325 var ov = values[vi]; 5326 html += '<option value="' + escapeHtml(ov.value) + '"' + (ov.available ? '' : ' disabled') + (ov.value === compDesired && ov.available ? ' selected' : '') + '>'; 5327 html += escapeHtml(ov.value) + (ov.available ? '' : ' (Sold Out)'); 5328 html += '</option>'; 5329 } 5330 html += '</select>'; 5331 } 5332 html += '</div>'; 5333 } 5334 html += '</div>'; 5335 return html; 5336 } 5337 5338 // --- STANDARD PRODUCT: one dropdown per option dimension, not raw variant dump --- 5339 var options = item && item.options ? item.options : null; 5340 5341 if (options && options.length > 0) { 5342 var html = '<div class="sg-variant-picker" data-testid="variant-picker-' + index + '">'; 5343 for (var oi = 0; oi < options.length; oi++) { 5344 var rawOptLabel = options[oi] || ("Option " + (oi + 1)); 5345 // Strip redundant trailing " Size" â e.g. "Fit Size" â "Fit", "Size Size" â "Size" 5346 var optLabelParts = rawOptLabel.trim().split(/\s+/); 5347 var optLabel = (optLabelParts.length > 1 && optLabelParts[optLabelParts.length - 1].toLowerCase() === 'size') 5348 ? optLabelParts.slice(0, -1).join(' ') 5349 : rawOptLabel; 5350 var values = getUniqueOptionValues(variants, oi); 5351 if (values.length === 0) continue; 5352 5353 html += '<div class="sg-variant-select-group">'; 5354 html += '<label class="sg-variant-select-label">' + escapeHtml(optLabel) + '</label>'; 5355 5356 if (values.length === 1 && options.length === 1) { 5357 // Single value, single option: show as static label â no dropdown needed 5358 html += '<span class="sg-variant-static-value">' + escapeHtml(values[0].value); 5359 if (!values[0].available) html += ' <span style="color:#ef4444">(Sold Out)</span>'; 5360 html += '</span>'; 5361 } else { 5362 var stdDesired = componentDesiredValue({ optionIndex: oi, label: optLabel }, values, selectedId, variants); 5363 html += '<select class="sg-variant-select sg-component-select"'; 5364 html += ' data-option-index="' + oi + '"'; 5365 html += ' data-testid="variant-select-' + index + '-' + oi + '">'; 5366 html += '<option value="">Select ' + escapeHtml(optLabel.toLowerCase()) + '</option>'; 5367 for (var vi2 = 0; vi2 < values.length; vi2++) { 5368 var ov2 = values[vi2]; 5369 html += '<option value="' + escapeHtml(ov2.value) + '"' + (ov2.value === stdDesired && ov2.available !== false ? ' selected' : '') + (ov2.available ? '' : ' disabled') + '>'; 5370 html += escapeHtml(ov2.value) + (ov2.available ? '' : ' (Sold Out)'); 5371 html += '</option>'; 5372 } 5373 html += '</select>'; 5374 } 5375 html += '</div>'; 5376 } 5377 html += '</div>'; 5378 return html; 5379 } 5380 5381 // --- FALLBACK: no options metadata, single size dropdown using variant titles --- 5382 if (variants.length <= 1) return ""; 5383 var html = '<div class="sg-variant-picker" data-testid="variant-picker-' + index + '">'; 5384 html += '<div class="sg-variant-select-group">'; 5385 html += '<label class="sg-variant-select-label">Size</label>'; 5386 html += '<select class="sg-variant-select sg-component-select" data-option-index="0"'; 5387 html += ' data-testid="variant-select-' + index + '">'; 5388 html += '<option value="">Select size</option>'; 5389 for (var v = 0; v < variants.length; v++) { 5390 var vr = variants[v]; 5391 var lbl = vr.option1 || vr.title || String(v + 1); 5392 html += '<option value="' + escapeHtml(lbl) + '"' + (vr.available ? '' : ' disabled') + '>'; 5393 html += escapeHtml(lbl) + (vr.available ? '' : ' (Sold Out)'); 5394 html += '</option>'; 5395 } 5396 html += '</select>'; 5397 html += '</div>'; 5398 html += '</div>'; 5399 return html; 5400 } 5401 5402 function renderProductCard(item, index, btnIconColor, viewIconColor) { 5403 var itemUrl = item.url || ""; 5404 if (shopDomain && itemUrl && itemUrl.charAt(0) === "/") { 5405 itemUrl = "https://" + shopDomain + itemUrl; 5406 } 5407 // Normalise protocol-relative URLs (//cdn.shopify.com/...) â https://... 5408 if (item.image && item.image.startsWith('//')) item.image = 'https:' + item.image; 5409 var hasImage = item.image && item.image.length > 10 && (item.image.startsWith('http://') || item.image.startsWith('https://')); 5410 if (!hasImage) console.log('[StyleGuru] No image for product:', item.name, '| raw:', item.image); 5411 var imgInner = hasImage 5412 ? '<img class="sg-product-img" src="' + escapeHtml(item.image) + '" alt="' + escapeHtml(item.name) + '" loading="lazy" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'" /><div class="sg-product-img-placeholder" style="display:none">
5412' + sparklesIcon() + '</div>' 5413 : '<div class="sg-product-img-placeholder">' + sparklesIcon() + '</div>'; 5414 var imgHtml = hasImage 5415 ? '<a class="sg-img-zoom-trigger" href="' + escapeHtml(itemUrl) + '" data-img="' + escapeHtml(item.image) + '" data-name="' + escapeHtml(item.name) + '" tabindex="-1" aria-label="View ' + escapeHtml(item.name) + ' full size">' + imgInner + '</a>' 5416 : imgInner; 5417 5418 var variantHtml = ""; 5419 var hasMultipleVariants = item.variants && item.variants.length > 1; 5420 if (!hasMultipleVariants && item.variantTitle) { 5421 variantHtml = '<div class="sg-product-variant"><span class="sg-product-variant-dot"></span> Size: ' + escapeHtml(item.variantTitle) + '</div>'; 5422 } 5423 5424 var roleHtml = ""; 5425 if (item.role && item.role !== "complement") { 5426 var roleLabel = item.role === "hero" ? "Key Piece" : item.role === "accessory" ? "Accessory" : ""; 5427 if (roleLabel) { 5428 roleHtml = '<span class="sg-item-role">' + roleLabel + '</span>'; 5429 } 5430 } 5431 5432 var cartDataAttrs = ''; 5433 // Sized product, size not established â gate Add until the picker is used. 5434 if (hasMultipleVariants && !item.variantId) { 5435 cartDataAttrs = ' data-size-required="1"'; 5436 } else if (item.variantId) { 5437 cartDataAttrs = ' data-variant-id="' + escapeHtml(item.variantId) + '"'; 5438 } else if (item.productId) { 5439 cartDataAttrs = ' data-product-id="' + escapeHtml(item.productId) + '"'; 5440 } 5441 5442 var isSoldOut = item.soldOut === true; 5443 5444 var html = '<div class="sg-product-card" data-testid="card-product-' + index + '">'; 5445 html += imgHtml; 5446 html += '<div class="sg-product-info">'; 5447 if (roleHtml) html += roleHtml; 5448 html += '<div class="sg-product-name"><a href="' + escapeHtml(itemUrl) + '" target="_blank" rel="noopener">' + escapeHtml(item.name) + '</a></div>'; 5449 var displayPrice = formatDisplayPrice(item.formattedPrice || item.price, item.currencyCode); 5450 if (displayPrice) html += '<div class="sg-product-price">' + escapeHtml(displayPrice) + '</div>'; 5451 if (item.sizedForYou && item.variantTitle) { 5452 html += '<div class="sg-sized-chip" style="display:inline-block;margin:2px 0 4px;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600;background:rgba(' + (hexToRgb(config.primaryColor || "#C6A85B") || "198,168,91") + ',0.14);color:' + (config.primaryColor || "#C6A85B") + ';" data-testid="chip-sized-' + index + '">Sized for you · ' + escapeHtml(item.variantTitle) + '</div>'; 5453 } 5454 html += variantHtml; 5455 html += '</div>'; 5456 if (hasMultipleVariants) { 5457 html += renderVariantPicker(item.variants, item.variantId, index, item); 5458 } 5459 html += '<div class="sg-product-actions">'; 5460 if (isSoldOut) { 5461 html += '<button class="sg-btn-cart sg-sold-out" data-testid="button-add-cart-' + index + '" disabled>Sold Out</button>'; 5462 } else { 5463 html += '<button class="sg-btn-cart" data-testid="button-add-cart-' + index + '"' + cartDataAttrs + '>' + cartIcon(btnIconColor) + cartLabel() + '</button>'; 5464 } 5465 html += '<a class="sg-btn-view" href="' + escapeHtml(itemUrl) + '" target="_blank" rel="noopener" data-testid="link-view-' + index + '">View ' + externalIcon(viewIconColor) + '</a>'; 5466 html += '</div>'; 5467 html += '</div>'; 5468 return html; 5469 } 5470 5471 function resolveAndAddToCart(itemBtn, callback) { 5472 var variantId = itemBtn.getAttribute("data-variant-id"); 5473 var productId = itemBtn.getAttribute("data-product-id"); 5474 var iconColor = getCartIconColor(); 5475 5476 if (variantId) { 5477 doCartAddWithCallback(variantId, itemBtn, callback); 5478 } else if (productId) { 5479 var handle = productId; 5480 var viewLink = itemBtn.closest(".sg-product-card"); 5481 if (viewLink) { 5482 var linkEl = viewLink.querySelector(".sg-btn-view"); 5483 if (linkEl) { 5484 var href = linkEl.getAttribute("href") || ""; 5485 var hMatch = href.match(/\/products\/([^?#]+)/); 5486 if (hMatch) handle = hMatch[1]; 5487 } 5488 } 5489 var productUrl = "https://" + shopDomain + "/products/" + handle + ".json"; 5490 var xhr = new XMLHttpRequest(); 5491 xhr.open("GET", productUrl); 5492 xhr.onreadystatechange = function() { 5493 if (xhr.readyState === 4) { 5494 if (xhr.status === 200) { 5495 try { 5496 var data = JSON.parse(xhr.responseText); 5497 var vid = data.product && data.product.variants && data.product.variants[0] ? data.product.variants[0].id : null; 5498 if (vid) { 5499 doCartAddWithCallback(vid, itemBtn, callback); 5500 } else { 5501 callback(false); 5502 } 5503 } catch(e) { callback(false); } 5504 } else { 5505 callback(false); 5506 } 5507 } 5508 }; 5509 xhr.send(); 5510 } else { 5511 callback(false); 5512 } 5513 } 5514 5515 function doCartAddWithCallback(variantId, itemBtn, callback) { 5516 var xhr = new XMLHttpRequest(); 5517 xhr.open("POST", "/cart/add.js"); 5518 xhr.setRequestHeader("Content-Type", "application/json"); 5519 xhr.onreadystatechange = function() { 5520 if (xhr.readyState === 4) { 5521 if (xhr.status === 200) { 5522 markCartAttributed(); 5523 itemBtn.classList.add("sg-added"); 5524 itemBtn.innerHTML = checkIcon("#fff") + ' Added'; 5525 itemBtn.disabled = true; 5526 callback(true); 5527 } else { 5528 callback(false); 5529 } 5530 } 5531 }; 5532 xhr.send(JSON.stringify({ id: variantId, quantity: 1 })); 5533 } 5534 5535 function showBundleToast(bundleEl, message, type) { 5536 var existing = bundleEl.querySelector(".sg-bundle-toast"); 5537 if (existing) existing.remove(); 5538 var toast = document.createElement("div"); 5539 toast.className = "sg-bundle-toast sg-toast-" + (type || "success"); 5540 var icon = type === "warn" 5541 ? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/>
5541<line x1="12" y1="17" x2="12.01" y2="17"/></svg>' 5542 : type === "error" 5543 ? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>' 5544 : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>'; 5545 toast.innerHTML = icon + '<span>' + message + '</span>'; 5546 var footer = bundleEl.querySelector(".sg-bundle-footer"); 5547 if (footer) { 5548 bundleEl.insertBefore(toast, footer); 5549 } else { 5550 bundleEl.appendChild(toast); 5551 } 5552 setTimeout(function() { if (toast.parentNode) toast.remove(); }, 5000); 5553 } 5554 5555 function showSuitSelectionSummary(bundleEl, cs) { 5556 // Remove any prior summary 5557 var existing = bundleEl.querySelector(".sg-suit-summary"); 5558 if (existing) existing.remove(); 5559 5560 var hasJacket = cs.jacketSize && cs.jacketSize.trim(); 5561 var hasTrouser = cs.trouserSize && cs.trouserSize.trim(); 5562 var hasWaistcoat = cs.waistcoatSize && cs.waistcoatSize.trim(); 5563 if (!hasJacket && !hasTrouser && !hasWaistcoat) return; 5564 5565 var summaryEl = document.createElement("div"); 5566 summaryEl.className = "sg-suit-summary"; 5567 5568 var html = '<span class="sg-suit-summary-label">Your selections:</span>'; 5569 if (hasJacket) html += '<span class="sg-suit-summary-tag">Jacket ' + escapeHtml(cs.jacketSize) + '</span>'; 5570 if (hasTrouser) html += '<span class="sg-suit-summary-tag">Trouser ' + escapeHtml(cs.trouserSize) + '</span>'; 5571 if (hasWaistcoat) html += '<span class="sg-suit-summary-tag">Waistcoat ' + escapeHtml(cs.waistcoatSize) + '</span>'; 5572 5573 // Advisory note only when at least one advisory (trouser/waistcoat) preference is present 5574 if (hasTrouser || hasWaistcoat) { 5575 html += '<span class="sg-suit-summary-advisory">Trouser & waistcoat sizes are recorded as your preferences â not direct stock reservations.</span>'; 5576 } 5577 5578 summaryEl.innerHTML = html; 5579 5580 var footer = bundleEl.querySelector(".sg-bundle-footer"); 5581 if (footer) { 5582 bundleEl.insertBefore(summaryEl, footer); 5583 } else { 5584 bundleEl.appendChild(summaryEl); 5585 } 5586 // Auto-clear on successful add (will be removed by finishBundleAdd rebuild) 5587 } 5588 5589 function addAllToCart(btn) { 5590 var bundleEl = btn.closest(".sg-bundle"); 5591 if (!bundleEl) return; 5592 var itemBtns = bundleEl.querySelectorAll(".sg-btn-cart:not(.sg-sold-out):not(:disabled)"); 5593 if (itemBtns.length === 0) return; 5594 5595 var pickers = bundleEl.querySelectorAll(".sg-variant-picker, .sg-inline-variant-select"); 5596 var unselected = []; 5597 for (var p = 0; p < pickers.length; p++) { 5598 var picker = pickers[p]; 5599 if (picker.classList.contains("sg-variant-picker")) { 5600 // Only check non-advisory selects â advisory selects are optional preference fields 5601 var selects = picker.querySelectorAll(".sg-variant-select:not([data-advisory='true'])"); 5602 var hasUnselected = false; 5603 for (var si = 0; si < selects.length; si++) { 5604 if (!selects[si].value) { hasUnselected = true; break; } 5605 } 5606 if (hasUnselected) { 5607 var card = picker.closest(".sg-product-card"); 5608 var nameEl = card ? card.querySelector(".sg-product-name") : null; 5609 unselected.push(nameEl ? nameEl.textContent.trim() : "an item"); 5610 } 5611 } 5612 } 5613 if (unselected.length > 0) { 5614 showBundleToast(bundleEl, "Please select your size for: " + unselected.join(", "), "warn"); 5615 return; 5616 } 5617 5618 var lines = []; 5619 for (var i = 0; i < itemBtns.length; i++) { 5620 var vid = itemBtns[i].getAttribute("data-variant-id"); 5621 if (vid) { 5622 lines.push({ variantId: vid, quantity: 1 }); 5623 } 5624 } 5625 5626 if (lines.length === 0) { 5627 showBundleToast(bundleEl, "No variant information available. Try adding items individually.", "warn"); 5628 return; 5629 } 5630 5631 // Collect customSelections from suit pickers (jacket/trouser/waistcoat sizes) 5632 var customSelections = {}; 5633 // Build a set of numeric variant IDs belonging to suit products, so we can 5634 // attach Shopify line item properties to those specific cart lines. 5635 var suitVariantNumericIds = {}; 5636 var suitPickers = bundleEl.querySelectorAll(".sg-suit-picker"); 5637 for (var sp = 0; sp < suitPickers.length; sp++) { 5638 var jacketSel = suitPickers[sp].querySelector("[data-component='jacket']"); 5639 var trouserSel = suitPickers[sp].querySelector("[data-component='trousers']"); 5640 var waistcoatSel = suitPickers[sp].querySelector("[data-component='waistcoat']"); 5641 if (jacketSel && jacketSel.value) customSelections.jacketSize = jacketSel.value; 5642 if (trouserSel && trouserSel.value) customSelections.trouserSize = trouserSel.value; 5643 if (waistcoatSel && waistcoatSel.value) customSelections.waistcoatSize = waistcoatSel.value; 5644 5645 // Record which variant ID belongs to this suit card 5646 var suitCard = suitPickers[sp].closest(".sg-product-card"); 5647 if (suitCard) { 5648 var suitCartBtn = suitCard.querySelector(".sg-btn-cart"); 5649 if (suitCartBtn) { 5650 var suitVid = suitCartBtn.getAttribute("data-variant-id"); 5651 if (suitVid) suitVariantNumericIds[suitVid.replace(/\D/g, "")] = true; 5652 } 5653 } 5654 } 5655 5656 // Show confirmation summary above the Add button if any suit sizes were selected 5657 if (Object.keys(customSelections).length > 0) { 5658 showSuitSelectionSummary(bundleEl, customSelections); 5659 } 5660 5661 trackEvent("add_bundle_clicked", { itemCount: lines.length }); 5662 5663 btn.disabled = true; 5664 var iconColor = getCartIconColor(); 5665 btn.innerHTML = '<span style="width:16px;height:16px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;display:inline-block;animation:sg-spin 0.6s linear infinite"></span> Adding ' + lines.length + ' items...'; 5666 5667 var styleEl = document.getElementById("sg-spin-style"); 5668 if (!styleEl) { 5669 styleEl = document.createElement("style"); 5670 styleEl.id = "sg-spin-style"; 5671 styleEl.textContent = '@keyframes sg-spin { to { transform: rotate(360deg); } }'; 5672 document.head.appendChild(styleEl); 5673 } 5674 5675 var bundleUrl = apiUrl("/cart/add-bundle"); 5676 var xhr = new XMLHttpRequest(); 5677 xhr.open("POST", bundleUrl); 5678 xhr.setRequestHeader("Content-Type", "application/json"); 5679 xhr.onreadystatechange = function() { 5680 if (xhr.readyState !== 4) return; 5681 5682 if (xhr.status === 429) { 5683 btn.disabled = false;
5684 btn.innerHTML = cartIcon(iconColor) + ' Add Complete Outfit'; 5685 showBundleToast(bundleEl, "Please wait a moment before trying again.", "warn"); 5686 return; 5687 } 5688 5689 try { 5690 var data = JSON.parse(xhr.responseText); 5691 5692 if (!data.ok) { 5693 btn.disabled = false; 5694 btn.innerHTML = cartIcon(iconColor) + ' Add Complete Outfit'; 5695 showBundleToast(bundleEl, data.error || "Failed to add to cart.", "error"); 5696 return; 5697 } 5698 5699 var addedCount = data.added ? data.added.length : 0; 5700 var removedCount = data.removedUnavailable ? data.removedUnavailable.length : 0; 5701 5702 if (addedCount > 0) { 5703 var cartItems = []; 5704 for (var ci = 0; ci < data.added.length; ci++) { 5705 var numId = data.added[ci].variantId.replace(/\D/g, ""); 5706 var cartItem = { id: parseInt(numId, 10), quantity: data.added[ci].quantity }; 5707 5708 // Attach Shopify line item properties for suit items so staff can see 5709 // the customer's size selections in the Shopify admin order view. 5710 if (suitVariantNumericIds[numId] && Object.keys(customSelections).length > 0) { 5711 cartItem.properties = {}; 5712 if (customSelections.jacketSize) cartItem.properties["Jacket Size"] = customSelections.jacketSize; 5713 if (customSelections.trouserSize) cartItem.properties["Trouser Size (your preference)"] = customSelections.trouserSize; 5714 if (customSelections.waistcoatSize) cartItem.properties["Waistcoat Size (your preference)"] = customSelections.waistcoatSize; 5715 } 5716 5717 cartItems.push(cartItem); 5718 } 5719 var cartXhr = new XMLHttpRequest(); 5720 cartXhr.open("POST", "/cart/add.js"); 5721 cartXhr.setRequestHeader("Content-Type", "application/json"); 5722 cartXhr.onreadystatechange = function() { 5723 if (cartXhr.readyState === 4) { 5724 if (cartXhr.status === 200 || cartXhr.status === 201) { 5725 finishBundleAdd(btn, bundleEl, iconColor, itemBtns, data); 5726 } else if (cartXhr.status === 422) { 5727 btn.disabled = false; 5728 btn.innerHTML = cartIcon(iconColor) + ' Add Complete Outfit'; 5729 showBundleToast(bundleEl, "Some items could not be added. They may be sold out.", "warn"); 5730 } else { 5731 btn.disabled = false; 5732 btn.innerHTML = cartIcon(iconColor) + ' Add Complete Outfit'; 5733 showBundleToast(bundleEl, "Could not add to cart. Please try again.", "error"); 5734 } 5735 } 5736 }; 5737 cartXhr.send(JSON.stringify({ items: cartItems })); 5738 } else { 5739 finishBundleAdd(btn, bundleEl, iconColor, itemBtns, data); 5740 } 5741 } catch(e) { 5742 btn.disabled = false; 5743 btn.innerHTML = cartIcon(iconColor) + ' Add Complete Outfit'; 5744 showBundleToast(bundleEl, "Something went wrong. Please try again.", "error"); 5745 } 5746 }; 5747 xhr.send(JSON.stringify({ lines: lines, customSelections: Object.keys(customSelections).length > 0 ? customSelections : undefined })); 5748 } 5749 5750 function finishBundleAdd(btn, bundleEl, iconColor, itemBtns, data) { 5751 var addedCount = data.added ? data.added.length : 0; 5752 var removedCount = data.removedUnavailable ? data.removedUnavailable.length : 0; 5753 var totalRequested = addedCount + removedCount; 5754 5755 if (addedCount > 0) { 5756 markCartAttributed(); 5757 trackEvent("add_bundle_success", { addedCount: addedCount, removedCount: removedCount }); 5758 btn.classList.add("sg-added"); 5759 btn.innerHTML = checkIcon("#fff") + ' ' + addedCount + ' Added to Bag'; 5760 5761 var addedIds = {}; 5762 if (data.added) { 5763 for (var a = 0; a < data.added.length; a++) { 5764 var numId = data.added[a].variantId.replace(/\D/g, ""); 5765 addedIds[numId] = true; 5766 addedIds[data.added[a].variantId] = true; 5767 } 5768 } 5769 5770 for (var b = 0; b < itemBtns.length; b++) { 5771 var vid = itemBtns[b].getAttribute("data-variant-id") || ""; 5772 var numVid = vid.replace(/\D/g, ""); 5773 if (addedIds[vid] || addedIds[numVid]) { 5774 itemBtns[b].classList.add("sg-added"); 5775 itemBtns[b].innerHTML = checkIcon("#fff") + ' Added'; 5776 itemBtns[b].disabled = true; 5777 } 5778 } 5779 } 5780 5781 if (removedCount > 0 && addedCount > 0) { 5782 showBundleToast(bundleEl, "Added " + addedCount + " items. " + removedCount + " item" + (removedCount > 1 ? "s" : "") + " sold out.", "warn"); 5783 } else if (removedCount > 0 && addedCount === 0) { 5784 showBundleToast(bundleEl, "All items are currently sold out.", "error"); 5785 } else if (addedCount > 0) { 5786 showBundleToast(bundleEl, addedCount + " item" + (addedCount > 1 ? "s" : "") + " added to your bag!", "success"); 5787 } 5788 5789 setTimeout(function() { 5790 btn.classList.remove("sg-added"); 5791 btn.innerHTML = cartIcon(iconColor) + ' Add Complete Outfit'; 5792 btn.disabled = false;
5793 var addedBtns = bundleEl.querySelectorAll(".sg-btn-cart.sg-added"); 5794 for (var c = 0; c < addedBtns.length; c++) { 5795 addedBtns[c].classList.remove("sg-added"); 5796 addedBtns[c].innerHTML = cartIcon(iconColor) + cartLabel(); 5797 addedBtns[c].disabled = false; 5798 } 5799 }, 4000); 5800 } 5801 5802 // The conversation builds ONE look across turns: every piece the stylist 5803 // presents joins sessionLook (deduped) so earlier items â the black oxfords â 5804 // stay in the pane when the shirts arrive. The zone's â starts a fresh look. 5805 var sessionLook = []; 5806 5807 function sgLookKey(item) { 5808 return String(item.productId || item.variantId || item.url || item.name || ""); 5809 } 5810 5811 function mergeIntoSessionLook(outfit) { 5812 if (!outfit || !outfit.items) return; 5813 for (var i = 0; i < outfit.items.length; i++) { 5814 var it = outfit.items[i]; 5815 var k = sgLookKey(it); 5816 if (!k) continue; 5817 var exists = false; 5818 for (var j = 0; j < sessionLook.length; j++) { 5819 if (sgLookKey(sessionLook[j]) === k) { exists = true; break; } 5820 } 5821 if (!exists) sessionLook.push(it); 5822 } 5823 // A look, not a wardrobe â keep the freshest pieces. 5824 if (sessionLook.length > 8) sessionLook = sessionLook.slice(sessionLook.length - 8); 5825 } 5826 5827 function sessionLookTotal() { 5828 var total = 0, symbol = ""; 5829 for (var i = 0; i < sessionLook.length; i++) { 5830 var p = sessionLook[i].price; 5831 if (p === undefined || p === null) continue; 5832 var s = String(p); 5833 if (!symbol) { var m = s.match(/^[^\d\s.,-]+/); if (m) symbol = m[0]; } 5834 var n = parseFloat(s.replace(/[^0-9.]/g, "")); 5835 if (!isNaN(n)) total += n; 5836 } 5837 return total > 0 ? (symbol || "") + total.toFixed(2) : ""; 5838 } 5839
5840 function renderSessionLook() { 5841 if (!sessionLook.length) { hideCompleteLook(); return; } 5842 showCompleteLook({ 5843 title: "Your look", 5844 items: sessionLook, 5845 bundlePrice: sessionLookTotal(), 5846 removable: true, 5847 }); 5848 } 5849 5850 function removeFromSessionLook(key) { 5851 var removedName = ""; 5852 for (var i = sessionLook.length - 1; i >= 0; i--) { 5853 if (sgLookKey(sessionLook[i]) === key) { 5854 removedName = sessionLook[i].name || ""; 5855 sessionLook.splice(i, 1); 5856 } 5857 } 5858 trackEvent("look_item_removed", { name: removedName, remaining: sessionLook.length }); 5859 renderSessionLook(); 5860 showDiscussedProducts([{ items: sessionLook }]); 5861 } 5862 5863 // The grid mirrors the conversation: when the stylist presents pieces, 5864 // show those pieces rather than reloading the generic catalogue. 5865 function showDiscussedProducts(outfits) { 5866 if (!outfits || !outfits.length) return; 5867 try { 5868 renderProducts(outfits, { noTabSwitch: true }); 5869 var lbl = document.getElementById("sg-products-header-label"); 5870 if (lbl) lbl.textContent = "In this look"; 5871 } catch (e) { 5872 try { console.error("[StyleGuru] showDiscussedProducts error:", e); } catch (e2) {} 5873 } 5874 } 5875 5876 // Chat rows are references â the pane is where products are handled. 5877 // Jump there and briefly spotlight the matching card. 5878 function focusPaneProduct(item) { 5879 switchTab("products"); 5880 var grid = document.getElementById("sg-products-grid"); 5881 if (!grid || !item) return; 5882 var cards = grid.querySelectorAll(".sg-product-card"); 5883 var target = null; 5884 for (var i = 0; i < cards.length; i++) { 5885 var d = cards[i]._sgItemData; 5886 if (d && ((d.productId && item.productId && String(d.productId) === String(item.productId)) || (d.name && d.name === item.name))) { 5887 target = cards[i]; 5888 break; 5889 } 5890 } 5891 if (!target) { 5892 // The grid has moved on since this chat row was shown â render the 5893 // tapped product into the pane as a standalone card so the tap always 5894 // lands somewhere. Clear/browse restores the full catalogue. 5895 renderProducts([{ title: item.name, items: [item] }], { noTabSwitch: true, source: "browse" }); 5896 target = grid.querySelector(".sg-product-card"); 5897 } 5898 if (target) { 5899 target.scrollIntoView({ behavior: "smooth", block: "center" }); 5900 target.classList.remove("sg-pane-flash"); 5901 void target.offsetWidth; // restart the animation on repeat taps 5902 target.classList.add("sg-pane-flash"); 5903 } 5904 } 5905 5906 function renderProducts(outfits, opts) { 5907 var grid = document.getElementById("sg-products-grid"); 5908 var countEl = document.getElementById("sg-product-count"); 5909 if (!grid) return; 5910 5911 var totalItems = 0; 5912 for (var i = 0; i < outfits.length; i++) { 5913 if (outfits[i].items) totalItems += outfits[i].items.length; 5914 } 5915 5916 if (countEl) { 5917 countEl.textContent = totalItems > 0 ? "(" + totalItems + " items)" : ""; 5918 } 5919 5920 // Update the mobile tab badge with item count 5921 var tabCountEl = document.getElementById("sg-tab-count"); 5922 if (tabCountEl) { 5923 if (totalItems > 0) { 5924 tabCountEl.textContent = totalItems; 5925 tabCountEl.style.display = "inline-block"; 5926 } else { 5927 tabCountEl.style.display = "none"; 5928 } 5929 } 5930 var browseCountEl = document.getElementById("sg-browse-count"); 5931 if (browseCountEl) { 5932 if (totalItems > 0 && totalItems <= 99 && !(opts && opts.source === "browse")) { 5933 browseCountEl.textContent = totalItems; 5934 browseCountEl.style.display = "block"; 5935 } else { 5936 browseCountEl.style.display = "none"; 5937 } 5938 } 5939 5940 if (totalItems === 0) { 5941 grid.innerHTML = '<div class="sg-empty-products">' + searchIcon() + '<h4>No recommendations yet</h4><p>Ask me about outfits, sizes, or styles and I\'ll show you matching products here.</p></div>'; 5942 return; 5943 } 5944 5945 // Auto-switch to products tab on mobile when recommendations arrive â 5946 // suppressed mid-chat, where the "View the outfit" chip handles it. 5947 // Auto-jump to the products tab only for AI recommendations â background 5948 // catalogue loads must never yank a mobile user out of the chat. 5949 if (window.innerWidth <= 640 && !(opts && opts.noTabSwitch) && !(opts && opts.source === "browse")) { 5950 switchTab("products"); 5951 } 5952 5953 if (opts && opts.source === "browse") { 5954 // Browsing is not a recommendation â log it lightly so grid loads stop 5955 // polluting the "top recommended products" numbers with the whole catalogue. 5956 trackEvent("catalog_viewed", { itemCount: totalItems }); 5957 } else { 5958 var trackItems = []; 5959 for (var ti = 0; ti < outfits.length; ti++) { 5960 if (outfits[ti].items) { 5961 for (var tj = 0; tj < outfits[ti].items.length; tj++) { 5962 var tIt = outfits[ti].items[tj]; 5963 trackItems.push({ name: tIt.name || "", productId: tIt.productId || "" }); 5964 } 5965 } 5966 } 5967 var bsPayload = { source: "ai", bundleCount: outfits.length, itemCount: totalItems, items: trackItems.slice(0, 20) }; 5968 if (opts && opts.occasion) bsPayload.occasion = opts.occasion; 5969 else if (window.__sgLastOccasion) bsPayload.occasion = window.__sgLastOccasion; 5970 trackEvent("bundles_shown", bsPayload); 5971 } 5972 5973 var btnIconColor = isLightColor(config.primaryColor || "#C6A85B") ? "#111" : "#fff"; 5974 var viewIconColor = isLightColor(config.backgroundColor || "#fff") ? "#555" : "#ccc"; 5975 var pc = config.primaryColor || "#C6A85B"; 5976 5977 var html = ""; 5978 var globalIdx = 0; 5979 5980 for (var b = 0; b < outfits.length; b++) { 5981 var bundle = outfits[b]; 5982 if (!bundle.items || bundle.items.length === 0) continue; 5983
5984 var hasMultipleBundles = bundle.items.length >= 2; 5985 5986 if (hasMultipleBundles) { 5987 html += '<div class="sg-bundle" data-testid="bundle-' + b + '">'; 5988 html += '<div class="sg-bundle-header">'; 5989 html += '<div class="sg-bundle-title">' + bundleIcon(pc) + ' ' + escapeHtml(bundle.title || "Complete Look") + '</div>'; 5990 html += '<div class="sg-bundle-meta">'; 5991 if (bundle.bundlePrice) { 5992 html += '<span class="sg-bundle-price">' + escapeHtml(bundle.bundlePrice) + ' total</span>'; 5993 } 5994 html += '<span class="sg-bundle-count">' + bundle.items.length + ' items</span>'; 5995 html += '</div>'; 5996 html += '</div>'; 5997 html += '<div class="sg-bundle-items">'; 5998 } 5999 6000 for (var j = 0; j < bundle.items.length; j++) { 6001 html += renderProductCard(bundle.items[j], globalIdx, btnIconColor, viewIconColor); 6002 globalIdx++; 6003 } 6004 6005 if (hasMultipleBundles) { 6006 html += '</div>'; 6007 var availableCount = 0; 6008 for (var ac = 0; ac < bundle.items.length; ac++) { 6009 if (!bundle.items[ac].soldOut && (bundle.items[ac].variantId || bundle.items[ac].productId)) availableCount++; 6010 } 6011 if (availableCount >= 2) { 6012 html += '<div class="sg-bundle-footer">'; 6013 html += '<button class="sg-btn-add-all" data-testid="button-add-all-' + b + '">' + cartIcon(btnIconColor) + ' Add Complete Outfit</button>'; 6014 html += '</div>'; 6015 } 6016 html += '</div>'; 6017 } 6018 } 6019 6020 var hasBundles = outfits.length > 0 && outfits.some(function(o) { return o.items && o.items.length >= 2; }); 6021 if (hasBundles) { 6022 grid.classList.add("sg-has-bundles"); 6023 } else { 6024 grid.classList.remove("sg-has-bundles"); 6025 } 6026 6027 grid.innerHTML = html; 6028 6029 var allCards = grid.querySelectorAll(".sg-product-card"); 6030 var flatItems = []; 6031 for (var fi = 0; fi < outfits.length; fi++) { 6032 if (outfits[fi].items) { 6033 for (var fj = 0; fj < outfits[fi].items.length; fj++) { 6034 flatItems.push(outfits[fi].items[fj]); 6035 } 6036 } 6037 } 6038 for (var ci = 0; ci < allCards.length && ci < flatItems.length; ci++) { 6039 allCards[ci]._sgItemData = flatItems[ci]; 6040 } 6041 6042 var cartBtns = grid.querySelectorAll(".sg-btn-cart"); 6043 for (var cb = 0; cb < cartBtns.length; cb++) { 6044 cartBtns[cb].addEventListener("click", function() { 6045 addToCart(this); 6046 }); 6047 } 6048 6049 var variantSelects = grid.querySelectorAll(".sg-variant-select:not(.sg-component-select)"); 6050 for (var vs = 0; vs < variantSelects.length; vs++) { 6051 variantSelects[vs].addEventListener("change", function() { 6052 var sel = this; 6053 var vid = sel.value; 6054 var card = sel.closest(".sg-product-card"); 6055 if (card && vid) { 6056 clearSizeNudge(sel); 6057 var cartBtn = card.querySelector(".sg-btn-cart"); 6058 if (cartBtn) { 6059 cartBtn.setAttribute("data-variant-id", vid); 6060 cartBtn.removeAttribute("data-product-id"); 6061 } 6062 } 6063 }); 6064 } 6065 6066 var componentSelects = grid.querySelectorAll(".sg-component-select"); 6067 for (var cs = 0; cs < componentSelects.length; cs++) { 6068 componentSelects[cs].addEventListener("change", function() { 6069 var sel = this; 6070 if (sel.value) sel.classList.remove("sg-needs-size"); 6071 var picker = sel.closest(".sg-variant-picker"); 6072 if (!picker) return; 6073 var card = picker.closest(".sg-product-card"); 6074 if (!card) return; 6075 6076 var allSelects = picker.querySelectorAll(".sg-component-select"); 6077 var selections = []; 6078 var allFilled = true; 6079 for (var ai = 0; ai < allSelects.length; ai++) { 6080 var s = allSelects[ai]; 6081 // Skip advisory selects â they don't affect variant resolution 6082 if (s.getAttribute("data-advisory") === "true") continue; 6083 if (!s.value) { allFilled = false; continue; } 6084 var optIdx = parseInt(s.getAttribute("data-option-index") || "0", 10); 6085 if (optIdx < 0) continue; // advisory selector with invalid index 6086 selections.push({ 6087 optionIndex: optIdx, 6088 splitIndex: s.hasAttribute("data-split-index") ? parseInt(s.getAttribute("data-split-index"), 10) : undefined, 6089 value: s.value 6090 }); 6091 } 6092 6093 if (!allFilled) return; 6094 6095 var cartBtn = card.querySelector(".sg-btn-cart"); 6096 if (!cartBtn) return; 6097 var cardIndex = card.getAttribute("data-testid").replace("card-product-", ""); 6098 var itemData = card._sgItemData; 6099 if (!itemData || !itemData.variants) return; 6100 6101 var matched = findMatchingVariant(itemData.variants, selections); 6102 if (matched) { 6103 clearSizeNudge(sel); 6104 cartBtn.setAttribute("data-variant-id", matched.id); 6105 cartBtn.removeAttribute("data-product-id"); 6106 if (!matched.available) { 6107 cartBtn.disabled = true; 6108 cartBtn.classList.add("sg-sold-out");
6109 cartBtn.textContent = "Sold Out"; 6110 } else { 6111 cartBtn.disabled = false; 6112 cartBtn.classList.remove("sg-sold-out"); 6113 var iconColor = getCartIconColor(); 6114 cartBtn.innerHTML = cartIcon(iconColor) + cartLabel(); 6115 } 6116 } 6117 }); 6118 } 6119 6120 var addAllBtns = grid.querySelectorAll(".sg-btn-add-all"); 6121 for (var ab = 0; ab < addAllBtns.length; ab++) { 6122 addAllBtns[ab].addEventListener("click", function() { 6123 addAllToCart(this); 6124 }); 6125 } 6126 6127 // Reset scroll to top and refresh fade indicators 6128 var scrollWrap = document.getElementById("sg-products-scroll"); 6129 if (scrollWrap) scrollWrap.scrollTop = 0; 6130 updateScrollFade(); 6131 } 6132 6133 function addToCart(btn) { 6134 var variantId = btn.getAttribute("data-variant-id"); 6135 var productId = btn.getAttribute("data-product-id"); 6136 6137 if (btn.getAttribute("data-size-required") === "1" && !variantId) { 6138 showSizeNudge(btn); 6139 return; 6140 } 6141 if (!variantId && !productId) return; 6142 6143 btn.disabled = true; 6144 btn.innerHTML = '<span style="width:14px;height:14px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;display:inline-block;animation:sg-spin 0.6s linear infinite"></span> Adding...'; 6145 6146 var styleEl = document.getElementById("sg-spin-style"); 6147 if (!styleEl) { 6148 styleEl = document.createElement("style"); 6149 styleEl.id = "sg-spin-style"; 6150 styleEl.textContent = '@keyframes sg-spin { to { transform: rotate(360deg); } }'; 6151 document.head.appendChild(styleEl); 6152 } 6153 6154 if (variantId) { 6155 doCartAdd(variantId, btn); 6156 } else { 6157 var handle = productId; 6158 var viewLink = btn.closest(".sg-product-card"); 6159 if (viewLink) { 6160 var linkEl = viewLink.querySelector(".sg-btn-view"); 6161 if (linkEl) { 6162 var href = linkEl.getAttribute("href") || ""; 6163 var hMatch = href.match(/\/products\/([^?#]+)/); 6164 if (hMatch) handle = hMatch[1]; 6165 } 6166 } 6167 var productUrl = "https://" + shopDomain + "/products/" + handle + ".json"; 6168 var xhr = new XMLHttpRequest(); 6169 xhr.open("GET", productUrl); 6170 xhr.onreadystatechange = function() { 6171 if (xhr.readyState === 4) { 6172 if (xhr.status === 200) { 6173 try { 6174 var data = JSON.parse(xhr.responseText); 6175 var vid = data.product && data.product.variants && data.product.variants[0] 6176 ? data.product.variants[0].id : null; 6177 if (vid) { 6178 doCartAdd(vid, btn); 6179 } else { 6180 cartError(btn); 6181 } 6182 } catch(e) { cartError(btn); } 6183 } else { 6184 cartError(btn); 6185 } 6186 } 6187 }; 6188 xhr.send(); 6189 } 6190 } 6191 6192 function getCartIconColor() { 6193 return isLightColor(config.primaryColor || "#C6A85B") ? "#111" : "#fff"; 6194 } 6195 6196 function doCartAdd(variantId, btn) { 6197 var iconColor = getCartIconColor(); 6198 var xhr = new XMLHttpRequest(); 6199 xhr.open("POST", "/cart/add.js"); 6200 xhr.setRequestHeader("Content-Type", "application/json"); 6201 xhr.onreadystatechange = function() { 6202 if (xhr.readyState === 4) { 6203 if (xhr.status === 200) { 6204 markCartAttributed(); 6205 btn.classList.add("sg-added"); 6206 btn.innerHTML = checkIcon("#fff") + ' Added'; 6207 btn.disabled = true; 6208 setTimeout(function() { 6209 btn.classList.remove("sg-added"); 6210 btn.innerHTML = cartIcon(iconColor) + cartLabel(); 6211 btn.disabled = false; 6212 }, 3000); 6213 } else { 6214 cartError(btn); 6215 } 6216 } 6217 }; 6218 xhr.send(JSON.stringify({ id: variantId, quantity: 1 })); 6219 } 6220 6221 function cartError(btn) { 6222 var iconColor = getCartIconColor(); 6223 btn.innerHTML = 'Unavailable'; 6224 btn.disabled = true; 6225 setTimeout(function() { 6226 btn.innerHTML = cartIcon(iconColor) + cartLabel(); 6227 btn.disabled = false; 6228 }, 2000); 6229 } 6230 6231 function showTyping() { 6232 var container = document.getElementById("sg-messages"); 6233 if (!container) return; 6234 var el = document.createElement("div"); 6235 el.className = "sg-msg sg-msg-assistant"; 6236 el.id = "sg-typing"; 6237 el.innerHTML = '<div class="sg-msg-bubble"><div class="sg-typing"><div class="sg-typing-dot"></div><div class="sg-typing-dot"></div><div class="sg-typing-dot"></div></div></div>'; 6238 container.appendChild(el); 6239 container.scrollTop = container.scrollHeight; 6240 } 6241 6242 function hideTyping() { 6243 var el = document.getElementById("sg-typing"); 6244 if (el) el.remove(); 6245 } 6246 6247 // Tool-aware status: while the agent runs a tool, show what it's doing next 6248 // to the animated dots ("Looking through the collectionâ¦"). No-op once the 6249 // reply has started streaming and the typing indicator is gone. 6250 function setTypingStatus(label) { 6251 var el = document.getElementById("sg-typing"); 6252 if (!el) return; 6253 var wrap = el.querySelector(".sg-typing"); 6254 if (!wrap) return; 6255 var span = wrap.querySelector(".sg-typing-label"); 6256 if (!label) { 6257 if (span) span.remove(); 6258 return; 6259 } 6260 if (!span) { 6261 span = document.createElement("span"); 6262 span.className = "sg-typing-label"; 6263 wrap.appendChild(span); 6264 } 6265 span.textContent = label; 6266 var container = document.getElementById("sg-messages"); 6267 if (container) container.scrollTop = container.scrollHeight; 6268 } 6269 6270 function rebuildWidget() { 6271 var existing = document.getElementById("sg-fab"); 6272 if (existing) existing.remove(); 6273 var existingOverlay = document.getElementById("sg-overlay"); 6274 if (existingOverlay) existingOverlay.remove(); 6275 6276 var container = document.getElementById("style-guru-container"); 6277 if (container) { 6278 var shopAttr = container.getAttribute("data-shop"); 6279 var apiAttr = container.getAttribute("data-api"); 6280 if (shopAttr) shopDomain = shopAttr;
6281 if (apiAttr) API_BASE = apiAttr; 6282 } 6283 6284 applyContainerOverrides(); 6285 injectStyles(); 6286 hookSearchBox(); 6287 createFAB(); 6288 } 6289 6290 var _containerObserver = null; 6291 6292 function attachContainerObserver() { 6293 if (_containerObserver) { 6294 _containerObserver.disconnect(); 6295 _containerObserver = null; 6296 } 6297 var container = document.getElementById("style-guru-container"); 6298 if (!container) return; 6299 6300 _containerObserver = new MutationObserver(function(mutations) { 6301 var settingChanged = false; 6302 for (var i = 0; i < mutations.length; i++) { 6303 if (mutations[i].type === "attributes" && mutations[i].attributeName && mutations[i].attributeName.indexOf("data-") === 0) { 6304 var attr = mutations[i].attributeName; 6305 if (attr !== "data-shop" && attr !== "data-api") { 6306 settingChanged = true; 6307 break; 6308 } 6309 } 6310 } 6311 if (settingChanged) { 6312 rebuildWidget(); 6313 } 6314 }); 6315 _containerObserver.observe(container, { attributes: true }); 6316 } 6317 6318 function setupThemeEditorListeners() { 6319 document.addEventListener("shopify:section:load", function() { 6320 var container = document.getElementById("style-guru-container"); 6321 if (!container) return; 6322 rebuildWidget(); 6323 attachContainerObserver(); 6324 }); 6325 6326 document.addEventListener("shopify:section:select", function() { 6327 var container = document.getElementById("style-guru-container"); 6328 if (!container) return; 6329 rebuildWidget(); 6330 attachContainerObserver(); 6331 }); 6332 6333 document.addEventListener("shopify:section:deselect", function() { 6334 if (_containerObserver) { 6335 _containerObserver.disconnect(); 6336 _containerObserver = null; 6337 } 6338 }); 6339 6340 attachContainerObserver(); 6341 } 6342 6343 window.StyleGuru = { 6344 open: function(query) { openModal(query || ""); }, 6345 close: function() { closeModal(); }, 6346 toggle: function(query) { 6347 if (isModalOpen) { closeModal(); } 6348 else { openModal(query || ""); } 6349 } 6350 }; 6351 6352 document.addEventListener("click", function(e) { 6353 var el = e.target; 6354 var trigger = null; 6355 while (el && el !== document) { 6356 if (el.hasAttribute && el.hasAttribute("data-styleguru-open")) { 6357 trigger = el; 6358 break; 6359 } 6360 el = el.parentElement; 6361 } 6362 if (trigger) { 6363 e.preventDefault(); 6364 var query = trigger.getAttribute("data-styleguru-query") || ""; 6365 openModal(query); 6366 } 6367 }); 6368 6369 if (document.readyState === "loading") { 6370 document.addEventListener("DOMContentLoaded", function() { 6371 init(); 6372 setupThemeEditorListeners(); 6373 }); 6374 } else { 6375 init(); 6376 setupThemeEditorListeners(); 6377 } 6378})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.