1(function() { 2let Bubblehouse = window.Bubblehouse = Object.assign(window.Bubblehouse ?? {}, { 3 actionQueryParam: "bh-action", 4 verbose: location.search.match(/bh-verbose/), 5 root: "https://app.bubblehouse.com/s/shopify-maryruth-organics", 6 data: {"ppc":0.01,"ppcs":0.01,"prm":"down","tester":true,"popups":{"History1":{"style":{"dialogStyle":"width: 80vw; height: 80vh; max-width: 700px; max-height: 950px; padding: 0; border: none; border-radius: 0; overflow: hidden; box-shadow: 0 0 30px 0 rgba(0, 0, 0, 0.2)"},"trigger":{"type":"manual"}},"Unredeemed1":{"style":{"dialogStyle":"width: 80vw; height: 80vh; max-width: 700px; max-height: 950px; padding: 0; border: none; border-radius: 0; overflow: hidden; box-shadow: 0 0 30px 0 rgba(0, 0, 0, 0.2)"},"trigger":{"type":"manual"}},"message":{"style":{"dialogStyle":"width: min(calc(100vw - 20px), 500px); height: 80vh; max-width: 500px; max-height: 300px; padding: 0; border: none; border-radius: 10px; overflow: hidden; box-shadow: 0 0 30px 0 rgba(0, 0, 0, 0.2)"},"trigger":{"type":"manual"}}},"popupMode":"outer/inner","popupUrl":"https://www.maryruthorganics.com/apps/bubblehouse/blocks/POPUPNAME","callUrl":"https://app.bubblehouse.com/api/v2023061/shopify-maryruth-organics/FUNCNAME","cartErrorMessage":"We couldnât update your cart. Please try again.","cartCouponErrorMessage":"We couldnât update your cart. Click the coupon code on your rewards page to retry, or enter this code at checkout:","userPointBalance":0,"userPointBalanceStr":"0","userPointBalanceValue":"0.000000","userPointBalanceValueStr":"$0.00","userPointBalanceAvail":0,"userPointBalanceAvailStr":"0","userPointBalanceAvailValue":"0.000000","userPointBalanceAvailValueStr":"$0.00","userTierName":""}, 7 isInner: false, 8 isAdmin: false, 9 extra: {"actionProxyPaths":["/apps/bubblehouse"],"actionShopSlugs":["shopify-maryruth-organics"],"iframeStyle":""} 10}); 11try { 12 Bubblehouse.data.authToken = Bubblehouse.data.authToken || localStorage.getItem('bubblehouse-auth-token'); 13} catch { 14 if (Bubblehouse.verbose) console.log('[Bubblehouse] bubblelogin relay source=bootstrap storage=blocked'); 15} 16Bubblehouse.isLoggedIn = !!Bubblehouse.data.authToken; 17 18/* blockClientDispatcher.js */ 19;(function() { 20window.addEventListener('message', handleMessage); 21Bubblehouse.handleBlockClientCall = handleBlockClientCall; 22 23const sandboxedAdminPreviewFrameCalls = new Set(['Resize1', 'Title1']); 24 25function handleMessage(ev) { 26 let data = ev.data; 27 if (!data) return; 28 Bubblehouse.handleBlockClientCall(data, ev) 29} 30 31function handleBlockClientCall(data, ev = null) { 32 let call = data.bubblehouseBlockClientCall; 33 if (!call) return; 34 35 const adminSourceFrame = adminMessageSourceFrame(ev); 36 if (adminSourceFrame === null) { 37 console.warn(`[Bubblehouse] BlockClient.${call} ignored from unknown admin message source`); 38 return 39 } 40 if (isSandboxedAdminPreviewFrame(adminSourceFrame) && !sandboxedAdminPreviewFrameCalls.has(call)) { 41 console.warn(`[Bubblehouse] BlockClient.${call} ignored from sandboxed admin preview frame`); 42 return 43 } 44 45 if (call === "RunCustomJS") { 46 if (Bubblehouse.verbose) console.log(`[Bubblehouse] BlockClient.${call}:`, data); 47 Bubblehouse.handleRunCustomJS(data, ev); 48 return 49 } 50 51 let instance = data.instance; 52 53 let el = (instance === '#self' ? document : document.getElementById(instance)) 54 if (el) { 55 if (Bubblehouse.verbose) console.log(`[Bubblehouse] BlockClient.${call} from ${instance}:`, data); 56 let detail = ev ? Object.assign({}, data, { sourceWindow: ev.source, sourceOrigin: ev.origin }) : data; 57 el.dispatchEvent(new CustomEvent('bhclient:' + call, { detail, bubbles: true })); 58 } else { 59 console.warn(`[Bubblehouse] invalid call BlockClient.${call} from unknown instance ${instance}:`, data); 60 } 61} 62 63function isSandboxedAdminPreviewFrame(frame) { 64 return frame?.hasAttribute('sandbox') && frame.getAttribute('data-block-preview-frame') === 'true' 65} 66 67function adminMessageSourceFrame(ev) { 68 if (!Bubblehouse.isAdmin || !ev || !ev.source) return undefined; 69 for (const frame of document.querySelectorAll('iframe')) { 70 if (frame.contentWindow === ev.source) { 71 return frame 72 } 73 } 74 return null 75} 76})() || true; 77 78/* handleRunCustomJSStub.js */ 79;(function() { 80Bubblehouse.handleRunCustomJS = function handleRunCustomJS(data, ev = null) {
81 console.error('[Bubblehouse] BlockClient.RunCustomJS: JS exec disabled'); 82}; 83})() || true; 84 85/* flashCommands.js */ 86;(function() { 87Bubblehouse.processFlashCommands = processFlashCommands; 88 89function processFlashCommands(flash, options = {}) { 90 options = options || {} 91 const commands = Array.isArray(flash?.c) ? flash.c : Array.isArray(flash?.commands) ? flash.commands : [] 92 for (const command of commands) { 93 processFlashCommand(command, options) 94 } 95} 96 97function processFlashCommand(command, options) { 98 const name = command?.n || command?.name 99 const params = command?.p || command?.params || {} 100 const instance = currentBlockClientInstance(options) 101 102 let message 103 switch (name) { 104 case 'ApplyCoupon': 105 if (!params.code) { 106 return 107 } 108 message = { 109 bubblehouseBlockClientCall: 'ApplyDiscountCode1', 110 instance, 111 code: params.code, 112 } 113 break 114 115 case 'AddProductToCart1': 116 if (!params.variantId || !params.discountCode) { 117 return 118 } 119 message = { 120 bubblehouseBlockClientCall: 'AddProductToCart1', 121 instance, 122 variantId: params.variantId, 123 quantity: 1, 124 discountCode: params.discountCode, 125 cartUrl: params.cartUrl || null, 126 } 127 break 128 129 default: 130 return 131 } 132 133 if (typeof Bubblehouse.sendBlockClientCall === 'function') { 134 Bubblehouse.sendBlockClientCall(message) 135 } else if (window.parent !== window) { 136 window.parent.postMessage(message, '*') 137 } else { 138 console.log(`[Bubblehouse] Block Client call ignored because of no viable transport: ${message.bubblehouseBlockClientCall}`) 139 } 140} 141 142function currentBlockClientInstance(options) { 143 return options.instance || 144 instanceFromURL() || 145 dataValue(document.body, 'instance') || 146 firstAttributeValue('data-resizeparentiframe-frame-name-value') || 147 firstAttributeValue('data-blockclient-instance-value') || 148 '#self' 149} 150 151function instanceFromURL() { 152 return new URL(window.location.href).searchParams.get('instance') || '' 153} 154 155function dataValue(el, key) { 156 if (!el) { 157 return '' 158 } 159 return el.dataset?.[key] || el.getAttribute?.(`data-${key}`) || '' 160} 161 162function firstAttributeValue(name) { 163 for (const el of document.querySelectorAll('*')) { 164 if (el.hasAttribute?.(name)) { 165 return el.getAttribute(name) || '' 166 } 167 } 168 return '' 169} 170})() || true; 171 172/* shopify/marketQueryParams.js */ 173const marketQueryParamNames = [ 174 "market", 175 "market_domain", 176 "market_path", 177 "external_path", 178 "currency", 179 "locale", 180 "country", 181] 182 183function shopifyRootPath() { 184 let root = window.Shopify?.routes?.root 185 if (!root) { 186 return "" 187 } 188 try { 189 return new URL(root, window.location.href).pathname 190 } catch { 191 return root 192 } 193} 194 195function collectCurrentMarketQueryParams() { 196 let pageUrl = new URL(window.location.href) 197 let queryParams = {} 198 199 for (let name of marketQueryParamNames) { 200 let value = pageUrl.searchParams.get(name) 201 if (value) { 202 queryParams[name] = value 203 } 204 } 205 206 if (window.Shopify) { 207 if (!queryParams.market_domain && window.location.host) { 208 queryParams.market_domain = window.location.host 209 } 210 if (!queryParams.market_path) { 211 let rootPath = shopifyRootPath() 212 if (rootPath) { 213 queryParams.market_path = rootPath 214 } 215 } 216 if (!queryParams.external_path && window.location.pathname) { 217 queryParams.external_path = window.location.pathname 218 } 219 if (!queryParams.currency && window.Shopify.currency?.active) { 220 queryParams.currency = window.Shopify.currency.active 221 } 222 if (!queryParams.locale) { 223 let locale = (window.Shopify.locale || "").trim() 224 if (locale) { 225 queryParams.locale = locale 226 } 227 } 228 if (!queryParams.country && window.Shopify.country) { 229 queryParams.country = window.Shopify.country 230 } 231 } 232 233 return queryParams 234} 235 236 237/* util/bubblehouseFrames.js */ 238function collectBubblehouseFrames(root) { 239 return collectBubblehouseDocumentFrames(root).concat(collectBubblehouseBlockFrames(root)) 240} 241 242function collectBubblehouseDocumentFrames(root) { 243 try { 244 return Array.from(root.querySelectorAll('iframe')) 245 } catch { 246 return [] 247 } 248} 249 250function collectBubblehouseBlockFrames(root) { 251 let result = [] 252 for (let host of collectBubblehouseBlockHosts(root)) { 253 if (host.shadowRoot) { 254 result.push(...collectBubblehouseDocumentFrames(host.shadowRoot)) 255 } 256 } 257 return result 258} 259 260function collectBubblehouseBlockHosts(root) { 261 try {
262 return Array.from(root.querySelectorAll('bubblehouse-block')) 263 } catch { 264 return [] 265 } 266} 267 268function bubblehouseShopSlug(url) { 269 let parts = url.pathname.split('/').filter(Boolean) 270 if (parts[0] === 's' && parts[1] && isBubblehousePageKind(parts[2]) && parts[3]) { 271 return parts[1] 272 } 273 if (isBubblehousePageKind(parts[0]) && parts[1]) { 274 if (/^v\d+$/.test(parts[1])) { 275 return parts[2] && parts[3] ? parts[2] : '' 276 } 277 return parts[2] ? parts[1] : '' 278 } 279 return '' 280} 281 282function isBubblehouseAppBlockOrPageURL(url) { 283 return bubblehouseShopSlug(url) !== '' 284} 285 286function isBubblehouseAuthFrameURL(url, appOrigin, pageOrigin) { 287 return isBubblehouseAppFrameURL(url, appOrigin) || 288 isShopifyBubblehousePageOrBlockURL(url, pageOrigin) 289} 290 291function isBubblehouseActionFrameURL(url, appOrigin, pageOrigin, shopSlugs, proxyPaths) { 292 let appFrame = bubblehouseActionAppFrame(url); 293 return (url.origin === appOrigin && 294 appFrame !== null && 295 shopSlugs.includes(appFrame.shopSlug) && 296 isBubblehouseActionPageName(appFrame.pageName)) || 297 (url.origin === pageOrigin && 298 isBubblehouseActionPageName(bubblehouseActionProxyPageName(url, proxyPaths))) 299} 300 301function isBubblehouseAppFrameURL(url, appOrigin) { 302 return appOrigin !== "" && 303 url.origin === appOrigin && 304 isBubblehouseAppBlockOrPageURL(url) 305} 306 307function isShopifyBubblehousePageOrBlockURL(url, pageOrigin) { 308 if (url.origin !== pageOrigin) { 309 return false 310 } 311 let parts = url.pathname.split('/').filter(Boolean) 312 return parts[0] === "apps" && 313 parts[1] === "bubblehouse" && 314 !!parts[2] && 315 parts[3] === "content" 316} 317 318function isBubblehousePageKind(kind) { 319 return kind === 'blocks' || kind === 'pages' 320} 321 322function bubblehouseAppOrigin() { 323 for (let value of [Bubblehouse?.data?.callUrl, Bubblehouse?.root]) { 324 if (!value) { 325 continue 326 } 327 try { 328 return new URL(value, location.href).origin 329 } catch { 330 } 331 } 332 return "" 333} 334 335function bubblehouseActionAppFrame(url) { 336 let parts = url.pathname.split('/').filter(Boolean) 337 if (parts.length === 4 && parts[0] === 's' && parts[1] && parts[2] === 'blocks' && parts[3]) { 338 return { shopSlug: parts[1], pageName: parts[3] } 339 } 340 if (parts.length === 4 && parts[0] === 'blocks' && /^v\d+$/.test(parts[1]) && parts[2] && parts[3]) { 341 return { shopSlug: parts[2], pageName: parts[3] } 342 } 343 return null 344} 345 346function bubblehouseActionProxyPageName(url, proxyPaths) { 347 let parts = url.pathname.split('/').filter(Boolean) 348 for (let proxyPath of proxyPaths) { 349 let proxyParts; 350 try { 351 proxyParts = new URL(proxyPath, url.origin).pathname.split('/').filter(Boolean) 352 } catch { 353 continue 354 } 355 if (proxyParts.length === 0) { 356 continue 357 } 358 let proxyStart = parts.length - proxyParts.length - 2; 359 if (proxyStart < 0 || parts[parts.length - 1] !== 'content') { 360 continue 361 } 362 if (proxyParts.every((part, index) => parts[proxyStart + index] === part)) { 363 return parts[parts.length - 2] || '' 364 } 365 } 366 return '' 367} 368 369function isBubblehouseActionPageName(name) { 370 return name === 'rewards' || name === 'Rewards7' 371} 372 373function bubblehouseRootShopSlug(root, baseURL) { 374 try { 375 let parts = new URL(root, baseURL).pathname.split('/').filter(Boolean) 376 return parts[0] === 's' && parts[1] ? parts[1] : '' 377 } catch { 378 return '' 379 } 380} 381 382 383/* util/nextId.js */ 384let nextId = Bubblehouse.nextId = (function () { 385 let lastId = 0; 386 387 return function nextId() { 388 lastId++; 389 return lastId; 390 } 391})() 392 393 394/* util/simpleglob.js */ 395const GlobStar_URL = '[^/]*'; 396 397// Provides simple glob-like match function with the following rules: 398// - ** matches any sequence of symbols 399// - * matches pattern specified by opts.star 400// - ? matches any symbol 401function glob(pattern, haystack, opts = { star: GlobStar_URL, whole: true }) { 402 pattern = pattern.replace(/([*]{1,2}|[?])/g, (match) => { 403 switch (match) { 404 case '**': return '.*'; 405 case '*': return opts.star; 406 case '?': return '.'; 407 } 408 }); 409 410 if (opts.whole) { 411 pattern = '^' + pattern + '$'; 412 } 413 414 pattern = new RegExp(pattern, 'g'); 415 416 return haystack.match(pattern) != null; 417} 418 419 420/* Popup.js */ 421const enabledPopups = Bubblehouse.data.popups || {}; 422const processedPopupRequestURLs = new Set(); 423 424 425function popupUrlWithQuery(blockName, queryParams) { 426 let url = new URL( 427 Bubblehouse.data.popupUrl ? Bubblehouse.data.popupUrl.replace('POPUPNAME', blockName) : `${Bubblehouse.root}/blocks/${blockName}`, 428 location.href, 429 ) 430 for (let [key, value] of Object.entries(queryParams)) { 431 url.searchParams.set(key, value) 432 } 433 return url.toString() 434} 435class Popup { 436 static defaultDialogStyle = ` 437 display: flex; 438 position: fixed; 439 z-index: 1000; 440 ` 441 442 constructor(blockName, popupStyle, params, clientY = null, authToken = null) { 443 this.blockName = blockName; 444 this.clientY = clientY; 445
446 let instance = this.instance = `bubblehouse-${this.blockName}-${nextId()}`; 447 448 let dialog = document.createElement('dialog'); 449 dialog.id = instance + '-dialog'; 450 dialog.style = Popup.defaultDialogStyle + popupStyle.dialogStyle; 451 452 dialog.addEventListener('click', this._handleDialogClick.bind(this)) 453 dialog.addEventListener('close', this._handleDialogClose.bind(this)) 454 455 let queryParams = { 'popup': true }; 456 queryParams.instance = instance; 457 458 let popupUrl = Bubblehouse.data.popupUrl ? Bubblehouse.data.popupUrl.replace('POPUPNAME', this.blockName) : `${Bubblehouse.root}/blocks/${this.blockName}`; 459 460 let auth = authToken || Bubblehouse.data.authToken || authTokenFromQueryString() || authTokenFromExistingBubblehouseFrame(popupUrl); 461 if (auth) { 462 queryParams.auth = auth 463 } 464 465 if (params && Object.keys(params).length > 0) { 466 queryParams.params = JSON.stringify(params) 467 } 468 469 Object.assign(queryParams, collectCurrentMarketQueryParams()) 470 471 let blockFrame = this.blockFrame = document.createElement('iframe'); 472 blockFrame.id = instance; 473 blockFrame.src = popupUrlWithQuery(this.blockName, queryParams) 474 blockFrame.style = `width: 100%; height: 100%; border: 0; outline: 0; box-shadow: none;`; 475 blockFrame.setAttribute('sandbox', 'allow-top-navigation allow-scripts allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-same-origin'); 476 blockFrame.setAttribute('allow', 'clipboard-write'); 477 blockFrame.addEventListener('bhclient:ClosePopup1', this._ClosePopup1.bind(this)) 478 blockFrame.addEventListener('bhclient:Title1', this._Title1.bind(this)) 479 blockFrame.addEventListener('bhclient:Resize1', this._Resize1.bind(this)) 480 dialog.appendChild(blockFrame); 481 482 this._isLoaded = false 483 this._isShowingRequested = false 484 this._isShown = false 485 this._isClosed = false 486 blockFrame.addEventListener('load', this._handleLoaded.bind(this)) 487 488 this.dialog = dialog; 489 this.iframe = blockFrame; 490 491 document.body.appendChild(dialog); 492 } 493 494 show() { 495 if (this._isShowingRequested) { 496 return 497 } 498 this._isShowingRequested = true 499 if (this._isLoaded) { 500 this._doShow() 501 } else { 502 if (Bubblehouse.verbose) console.log(`[Bubblehouse] Popup ${this.instance} loading, will show when finished.`); 503 setTimeout(this._showOnLoadingTimeout.bind(this), 2000) 504 } 505 } 506 507 close() { 508 if (this._isClosed) { 509 return 510 } 511 this._isClosed = true 512 if (this.dialog.open) { 513 this.dialog.close(); 514 } else { 515 this._cleanup(); 516 } 517 } 518 519 _handleLoaded() { 520 if (this._isLoaded) { 521 return 522 } 523 this._isLoaded = true 524 if (this._isShowingRequested && !this._isShown && !this._isClosed) { 525 this._doShow() 526 } 527 } 528 529 _showOnLoadingTimeout() { 530 if (!this._isShown && !this._isClosed) { 531 if (Bubblehouse.verbose) console.log(`[Bubblehouse] Popup ${this.instance} loading timed out, showing anyway.`); 532 this._doShow() 533 } 534 } 535 536 _doShow() { 537 if (this._isShown) { 538 return 539 } 540 541 if (Bubblehouse.verbose) console.log(`[Bubblehouse] Popup ${this.instance} showing`); 542 if (Bubblehouse.isInner) { 543 let height = this.dialog.offsetHeight; 544 if (height > 0) { 545 let top = 20; 546 if (this.clientY && this.clientY > height + top) { 547 top = this.clientY - height; 548 this.dialog.style.marginTop = top + 'px'; 549 } 550 } 551 } 552 this.dialog.showModal(); 553 this._isShown = true; 554 } 555 556 _ClosePopup1(ev) { 557 if (Bubblehouse.verbose) console.log(`[Bubblehouse] Popup ${this.instance} ClosePopup1`); 558 this.close(); 559 if (ev.detail.reload) { 560 if (ev.detail.flash) { 561 let pageUrl = new URL(window.location.href) 562 pageUrl.searchParams.set('flash', JSON.stringify(ev.detail.flash)) 563 window.location.href = pageUrl.toString() 564 } else { 565 window.location.reload() 566 } 567 } 568 } 569 570 _Resize1(ev) { 571 ev.stopPropagation() 572 this._setFrameTitleFromEvent(ev) 573 } 574 575 _Title1(ev) { 576 ev.stopPropagation() 577 this._setFrameTitleFromEvent(ev) 578 } 579 580 _setFrameTitleFromEvent(ev) {
581 if (this.blockFrame.title || ev.detail.sourceWindow !== this.blockFrame.contentWindow || typeof ev.detail.title !== 'string') { 582 return 583 } 584 let title = ev.detail.title.trim() 585 if (title) { 586 this.blockFrame.title = title 587 } 588 } 589 590 _handleDialogClick(ev) { 591 const d = this.dialog.getBoundingClientRect() 592 if (ev.clientX < d.left || ev.clientX > d.right || ev.clientY < d.top || ev.clientY > d.bottom) { 593 this.close() 594 } 595 } 596 597 _handleDialogClose(ev) { 598 if (Bubblehouse.verbose) console.log(`[Bubblehouse] Popup ${this.instance} closed`); 599 this._cleanup(); 600 } 601 602 _cleanup() { 603 if (this.dialog.parentNode) { 604 document.body.removeChild(this.dialog); 605 } 606 } 607} 608 609function isSimilarPopupOpen(name) { 610 return document.querySelector(`dialog[id^="bubblehouse-${name}-"]`) !== null; 611} 612 613function isPopupAvailable(name) { 614 return !!enabledPopups[name]; 615} 616 617function authTokenFromQueryString() { 618 let windowUrl = new URL(location.href) 619 return windowUrl.searchParams.get('auth') || '' 620} 621 622function authTokenFromExistingBubblehouseFrame(popupUrl) { 623 let popupURL = new URL(popupUrl, location.href) 624 let popupShop = bubblehouseShopSlug(popupURL) 625 if (!popupShop) { 626 return '' 627 } 628 for (let frame of collectBubblehouseFrames(document)) { 629 let frameUrl 630 try { 631 frameUrl = new URL(frame.src, location.href) 632 } catch { 633 continue 634 } 635 if (frameUrl.origin === popupURL.origin && 636 frameUrl.searchParams.has('auth') && 637 bubblehouseShopSlug(frameUrl) === popupShop) { 638 return frameUrl.searchParams.get('auth') 639 } 640 } 641 return '' 642} 643 644function openPopupHere(name, params = {}, options = {}) { 645 if (isSimilarPopupOpen(name)) { 646 return; 647 } 648 649 let popupStyle; 650 if (options.url) { 651 popupStyle = options 652 } else { 653 let popupSettings = enabledPopups[name]; 654 if (!popupSettings) { 655 throw new Error(`unknown Bubblehouse popup "${name}"`) 656 } 657 popupStyle = options.legacyStyle || popupSettings.style; 658 } 659 660 let popup = new Popup(name, popupStyle, params, options.clientY || null, options.authToken || null) 661 662 if (options.handlers) { 663 for (let [key, value] of Object.entries(options.handlers)) { 664 popup.blockFrame.addEventListener('bhclient:' + key, value) 665 } 666 } 667 668 popup.show(); 669 return popup; 670} 671 672function openPopup(popupName, params = {}, options = {}) { 673 let isInverted = !!options.isModeInverted; 674 let effMode, mode = Bubblehouse.data.popupMode 675 if (mode === 'api/inner') { 676 effMode = (isInverted ? 'inner' : 'api') 677 } else if (mode === 'outer/inner') { 678 effMode = (isInverted ? 'inner' : 'outer') 679 } else if (mode === 'inner/api') { 680 effMode = (!isInverted ? 'inner' : 'api') 681 } else if (mode === 'inner/outer') { 682 effMode = (!isInverted ? 'inner' : 'outer') 683 } else { 684 effMode = 'inner' 685 } 686 687 if (effMode === 'inner') { 688 console.log(`[Bubblehouse] popup(${mode} => ${effMode}): inner mode`); 689 openPopupHere(popupName, params, options); 690 } else if (effMode === 'outer') { 691 let message = { bubblehouseBlockClientCall: 'OpenInternalPopup1', instance: '#self', name: popupName, params }; 692 if (Bubblehouse.data.propagateAuthTokenToOuterFrame) { 693 message.authToken = Bubblehouse.data.authToken; 694 } 695 console.log(`[Bubblehouse] popup(${mode} => ${effMode}): calling ${message.bubblehouseBlockClientCall}:`, message) 696 window.parent.postMessage(message, '*') 697 } else if (effMode === 'api') { 698 let popupSettings = enabledPopups[popupName]; 699 if (!popupSettings) { 700 throw new Error(`unknown Bubblehouse popup "${popupName}"`) 701 } 702 let message = { bubblehouseBlockClientCall: 'OpenPopup1', instance: '#self', name: popupName, params, url: Bubblehouse.data.popupUrl.replace('POPUPNAME', popupName), dialog: popupSettings.style.dialogStyle }; 703 if (Bubblehouse.data.propagateAuthTokenToOuterFrame) { 704 message.authToken = Bubblehouse.data.authToken; 705 } 706 console.log(`[Bubblehouse] popup(${mode} => ${effMode}): calling ${message.bubblehouseBlockClientCall}:`, message) 707 window.parent.postMessage(message, '*') 708 } 709} 710 711function processPopupRequestURL(rawURL, cleanupURL = null) { 712 let pageUrl = new URL(rawURL, window.location.href) 713 if (pageUrl.searchParams.has('bh-popup')) { 714 let requestURL = pageUrl.toString() 715 if (processedPopupRequestURLs.has(requestURL)) { 716 return true 717 } 718 processedPopupRequestURLs.add(requestURL) 719 720 let req = parsePopupRequest(pageUrl.searchParams.get('bh-popup')) 721 if (cleanupURL && !pageUrl.searchParams.has('bh-popup-keep')) { 722 pageUrl.searchParams.delete('bh-popup') 723 cleanupURL(pageUrl) 724 }
725 Bubblehouse.processFlashCommands(req.params?.flash) 726 openPopup(req.name, req.params) 727 return true; 728 } 729 730 return false; 731} 732 733function processRequestedPopup() { 734 return processPopupRequestURL(window.location.href, (pageUrl) => window.history.replaceState({}, '', pageUrl.toString())); 735} 736 737function processTurboFrameRequestedPopup(ev) { 738 let frame = ev.target 739 if (!frame || frame.tagName !== 'TURBO-FRAME') { 740 return 741 } 742 743 let src = frame.getAttribute('src') 744 if (!src) { 745 return 746 } 747 748 processPopupRequestURL(src) 749} 750 751function parsePopupRequest(str) { 752 let idx = str.indexOf(':'); 753 if (idx < 0) { 754 return { name: str.trim(), params: {} }; 755 } 756 757 let name = str.slice(0, idx).trim(); 758 let raw = str.slice(idx + 1).trim(); 759 return { name, params: JSON.parse(raw) }; 760} 761 762 763function processPageVisitPopups() { 764 let pageUrl = new URL(window.location.href); 765 if (pageUrl.searchParams.has("popup")) return; 766 if (Bubblehouse.isAdmin) return; 767 Object.entries(enabledPopups).forEach(([name, opts]) => { 768 if (opts.trigger.type === "page_visit") { 769 const matches = opts.trigger.criteria.some(({ url }) => { 770 if (!url) return false; 771 return glob(url, pageUrl.pathname); 772 }); 773 774 if (matches) { 775 setTimeout(() => openPopupHere(name), opts.trigger.delay_secs * 1000); 776 } 777 } 778 }); 779} 780 781function processPostPurchasePopups() { 782 let pageUrl = new URL(window.location.href); 783 if (!glob("**/checkouts/**/thank?you", pageUrl.pathname)) { 784 return false; 785 } 786 return Object.entries(enabledPopups).some(([name, opts]) => { 787 if (opts.trigger.type === "post_purchase") { 788 setTimeout(() => openPopupHere(name), opts.trigger.delay_secs * 1000); 789 return true; 790 } else { 791 return false; 792 } 793 }); 794} 795 796function handleOpenInternalPopup1(ev) { 797 openPopupHere(ev.detail.name, ev.detail.params, { 798 clientY: ev.detail.clientY, 799 authToken: ev.detail.authToken, 800 }) 801} 802 803function handleOpenApiPopup1(ev) { 804 openPopupHere(ev.detail.name, ev.detail.params, ev.detail) 805} 806 807const processors = [processRequestedPopup, processPostPurchasePopups, processPageVisitPopups]; 808 809processors.some((f) => f()); 810 811document.addEventListener('bhclient:OpenInternalPopup1', handleOpenInternalPopup1); 812document.addEventListener('bhclient:OpenPopup1', handleOpenApiPopup1); 813document.addEventListener('turbo:frame-load', processTurboFrameRequestedPopup); 814 815Bubblehouse.openPopup = openPopup; 816Bubblehouse.openPopupHere = openPopupHere; 817Bubblehouse.isPopupAvailable = isPopupAvailable; 818 819 820/* util/onDOMContentLoaded.js */ 821function onDOMContentLoaded(func) { 822 if (document.readyState === "loading") { 823 document.addEventListener("DOMContentLoaded", func); 824 } else { 825 func(); 826 } 827} 828 829 830/* nonshopify/bubblelogin-authtoken.js */ 831const BUBBLEHOUSE_AUTH_TOKEN_STORAGE_KEY = "bubblehouse-auth-token"; 832const BUBBLEHOUSE_HASH_TOKEN_PREFIX = "#bh-token="; 833 834let bubblehouseAuthTokenObserver = null; 835 836processBubblehouseLoginCredentials("load"); 837onDOMContentLoaded(() => processBubblehouseLoginCredentials("domcontentloaded")); 838window.addEventListener("hashchange", () => { 839 if (location.hash.startsWith(BUBBLEHOUSE_HASH_TOKEN_PREFIX)) { 840 processBubblehouseLoginCredentials("hashchange"); 841 } 842}); 843 844function processBubblehouseLoginCredentials(source) { 845 let stats = bubblehouseLoginStats(source); 846 let hashToken = readBubblehouseHashToken(stats); 847 if (hashToken !== null) { 848 storeBubblehouseAuthToken(hashToken, stats); 849 clearBubblehouseLoginHash(stats); 850 Bubblehouse.data.authToken = hashToken; 851 } else if (!Bubblehouse.data.authToken) { 852 Bubblehouse.data.authToken = readStoredBubblehouseAuthToken(stats); 853 } 854 855 Bubblehouse.isLoggedIn = !!Bubblehouse.data.authToken; 856 let foundAuthFrame = applyBubblehouseAuthToken(Bubblehouse.data.authToken, stats); 857 if (!foundAuthFrame) { 858 observeLateBubblehouseAuthFrames(Bubblehouse.data.authToken, stats); 859 } 860 logBubblehouseLoginStats(stats); 861} 862 863function bubblehouseLoginStats(source) { 864 return { 865 source, 866 hash: "absent", 867 storage: "unchanged", 868 history: "unchanged", 869 candidates: 0, 870 matched: 0, 871 reloaded: 0, 872 skippedAuth: 0, 873 skippedShape: 0, 874 components: 0, 875 componentUpdates: 0, 876 componentSkips: 0, 877 componentUnavailable: 0, 878 errors: 0, 879 } 880} 881 882function readBubblehouseHashToken(stats) { 883 if (!location.hash.startsWith(BUBBLEHOUSE_HASH_TOKEN_PREFIX)) { 884 return null; 885 } 886 let rawToken = location.hash.slice(BUBBLEHOUSE_HASH_TOKEN_PREFIX.length); 887 stats.hash = rawToken ? "present" : "empty"; 888 try { 889 return decodeURIComponent(rawToken); 890 } catch { 891 stats.errors++; 892 stats.hash = "decode-error"; 893 return rawToken; 894 } 895} 896 897function storeBubblehouseAuthToken(token, stats) { 898 try { 899 if (token) { 900 localStorage.setItem(BUBBLEHOUSE_AUTH_TOKEN_STORAGE_KEY, token); 901 stats.storage = "stored"; 902 } else if (localStorage.removeItem) { 903 localStorage.removeItem(BUBBLEHOUSE_AUTH_TOKEN_STORAGE_KEY); 904 stats.storage = "cleared"; 905 } else { 906 localStorage.setItem(BUBBLEHOUSE_AUTH_TOKEN_STORAGE_KEY, ""); 907 stats.storage = "cleared"; 908 } 909 } catch { 910 stats.storage = "blocked"; 911 stats.errors++; 912 } 913} 914 915function readStoredBubblehouseAuthToken(stats) { 916 try { 917 return localStorage.getItem(BUBBLEHOUSE_AUTH_TOKEN_STORAGE_KEY) || ""; 918 } catch { 919 stats.storage = "blocked"; 920 stats.errors++; 921 return ""; 922 } 923} 924 925function clearBubblehouseLoginHash(stats) { 926 try { 927 let url = new URL(location.href); 928 url.hash = ""; 929 history.replaceState(null, "", url.toString()); 930 stats.history = "cleared"; 931 } catch { 932 stats.history = "blocked"; 933 stats.errors++; 934 } 935} 936 937function applyBubblehouseAuthToken(token, stats) { 938 return applyBubblehouseAuthTokenToNode(token, stats, document) 939} 940 941function applyBubblehouseAuthTokenToNode(token, stats, node) { 942 if (!token) { 943 return false 944 } 945 let foundAuthFrame = applyBubblehouseAuthTokenToStaticFrames(token, stats, node); 946 applyBubblehouseAuthTokenToBlockHosts(token, stats, node); 947 return foundAuthFrame 948} 949 950function applyBubblehouseAuthTokenToStaticFrames(token, stats, root) { 951 let appOrigin = bubblehouseAppOrigin(); 952 let pageOrigin = location.origin; 953 let foundAuthFrame = false;
954 for (let frame of collectBubblehouseDocumentFramesIncludingRoot(root)) { 955 stats.candidates++; 956 let frameUrl; 957 try { 958 frameUrl = new URL(frame.src, location.href); 959 } catch { 960 stats.errors++; 961 continue 962 } 963 if (!isBubblehouseAuthFrameURL(frameUrl, appOrigin, pageOrigin)) { 964 stats.skippedShape++; 965 continue 966 } 967 foundAuthFrame = true; 968 stats.matched++; 969 if (frameUrl.searchParams.has("auth")) { 970 stats.skippedAuth++; 971 continue 972 } 973 reloadBubblehouseFrameWithAuth(frame, frameUrl, token, stats); 974 } 975 return foundAuthFrame 976} 977 978function applyBubblehouseAuthTokenToBlockHosts(token, stats, root) { 979 for (let host of collectBubblehouseBlockHostsIncludingRoot(root)) { 980 stats.components++; 981 try { 982 if (typeof host.applyBubblehouseAuthToken !== "function") { 983 stats.componentUnavailable++; 984 } else if (host.applyBubblehouseAuthToken(token)) { 985 stats.componentUpdates++; 986 } else { 987 stats.componentSkips++; 988 } 989 } catch { 990 stats.errors++; 991 } 992 } 993} 994 995function observeLateBubblehouseAuthFrames(token, stats) { 996 if (!token || bubblehouseAuthTokenObserver || typeof MutationObserver !== "function") { 997 return 998 } 999 let root = document.body || document.documentElement; 1000 if (!root) { 1001 return 1002 } 1003 try { 1004 // Some headless storefronts load this relay before React inserts the rewards iframe. 1005 // Safari cannot fall back to the app-domain cookie, so the late iframe still needs the hash token. 1006 bubblehouseAuthTokenObserver = new MutationObserver((mutations) => { 1007 let mutationStats = bubblehouseLoginStats("mutation"); 1008 for (let mutation of mutations) { 1009 for (let node of mutation.addedNodes || []) { 1010 applyBubblehouseAuthTokenToNode(Bubblehouse.data.authToken, mutationStats, node); 1011 } 1012 } 1013 logBubblehouseLoginStats(mutationStats); 1014 }); 1015 bubblehouseAuthTokenObserver.observe(root, { childList: true, subtree: true }); 1016 } catch { 1017 stats.errors++; 1018 } 1019} 1020 1021function collectBubblehouseDocumentFramesIncludingRoot(root) { 1022 let frames = collectBubblehouseDocumentFrames(root); 1023 if (root && root.tagName && root.tagName.toLowerCase() === "iframe") { 1024 frames.unshift(root); 1025 } 1026 return frames 1027} 1028 1029function collectBubblehouseBlockHostsIncludingRoot(root) { 1030 let hosts = collectBubblehouseBlockHosts(root); 1031 if (root && root.tagName && root.tagName.toLowerCase() === "bubblehouse-block") { 1032 hosts.unshift(root); 1033 } 1034 return hosts 1035} 1036 1037function reloadBubblehouseFrameWithAuth(frame, frameUrl, token, stats) { 1038 try { 1039 if (typeof Bubblehouse.prepareFrameURLForAuthReload === "function") { 1040 Bubblehouse.prepareFrameURLForAuthReload(frameUrl); 1041 } 1042 frameUrl.searchParams.set("auth", token); 1043 let newFrame = frame.cloneNode(); 1044 newFrame.src = frameUrl.toString(); 1045 let parent = frame.parentNode; 1046 parent.insertBefore(newFrame, frame); 1047 parent.removeChild(frame); 1048 stats.reloaded++; 1049 } catch { 1050 stats.errors++; 1051 } 1052} 1053 1054function logBubblehouseLoginStats(stats) { 1055 if (!Bubblehouse.verbose) { 1056 return 1057 } 1058 console.log( 1059 `[Bubblehouse] bubblelogin relay source=${stats.source} hash=${stats.hash} storage=${stats.storage} history=${stats.history} ` + 1060 `frames candidates=${stats.candidates} matched=${stats.matched} reloaded=${stats.reloaded} skipped_auth=${stats.skippedAuth} skipped_shape=${stats.skippedShape} ` + 1061 `components hosts=${stats.components} updated=${stats.componentUpdates} skipped=${stats.componentSkips} unavailable=${stats.componentUnavailable} errors=${stats.errors}`, 1062 ); 1063} 1064 1065 1066/* actionTokenRelay.js */ 1067const BUBBLEHOUSE_ACTION_QUERY_PARAM = Bubblehouse.actionQueryParam; 1068 1069let bubblehouseActionToken = readBubblehouseActionToken(); 1070let bubblehouseRejectedActionToken = ""; 1071let bubblehouseActionTokenConflict = false; 1072let bubblehouseActionTokenObserver = null; 1073 1074processBubblehouseActionToken(); 1075onDOMContentLoaded(processBubblehouseActionToken); 1076window.addEventListener("message", handleBubblehouseActionTokenStatus); 1077Bubblehouse.prepareFrameURLForAuthReload = prepareBubblehouseActionFrameURLForAuthReload; 1078 1079function readBubblehouseActionToken() { 1080 try { 1081 let values = new URL(location.href).searchParams.getAll(BUBBLEHOUSE_ACTION_QUERY_PARAM); 1082 return values.length === 1 ? values[0] : ""; 1083 } catch { 1084 return ""; 1085 } 1086} 1087 1088function processBubblehouseActionToken() { 1089 if (!bubblehouseActionToken) { 1090 return 1091 } 1092 applyBubblehouseActionTokenToNode(document); 1093 observeLateBubblehouseActionFrames(); 1094} 1095 1096function applyBubblehouseActionTokenToNode(node) { 1097 let appOrigin = bubblehouseActionAppOrigin(); 1098 let shopSlugs = bubblehouseActionShopSlugs(); 1099 let proxyPaths = bubblehouseActionProxyPaths(); 1100 let candidates = []; 1101 for (let frame of collectBubblehouseActionFramesIncludingRoot(node)) { 1102 let frameURL; 1103 try { 1104 frameURL = new URL(frame.src, location.href); 1105 } catch { 1106 continue 1107 } 1108 if (!isBubblehouseActionFrameURL(frameURL, appOrigin, location.origin, shopSlugs, proxyPaths)) { 1109 continue 1110 } 1111 candidates.push({ frame, frameURL }); 1112 } 1113 for (let { frame, frameURL } of candidates) { 1114 if (bubblehouseRejectedActionToken && 1115 frameURL.searchParams.get(BUBBLEHOUSE_ACTION_QUERY_PARAM) === bubblehouseRejectedActionToken) {
1116 try { 1117 frameURL.searchParams.delete(BUBBLEHOUSE_ACTION_QUERY_PARAM); 1118 frame.src = frameURL.toString(); 1119 } catch { 1120 } 1121 } 1122 } 1123 if (!bubblehouseActionToken || bubblehouseActionTokenConflict) { 1124 return 1125 } 1126 let conflictingCandidates = candidates.filter(({ frameURL }) => { 1127 let values = frameURL.searchParams.getAll(BUBBLEHOUSE_ACTION_QUERY_PARAM); 1128 return values.length > 0 && (values.length !== 1 || values[0] !== bubblehouseActionToken) 1129 }); 1130 if (conflictingCandidates.length > 0) { 1131 bubblehouseActionTokenConflict = true; 1132 if (Bubblehouse.verbose) { 1133 console.log("[Bubblehouse] Action Token relay conflict; no outer token selected"); 1134 } 1135 return 1136 } 1137 for (let { frame, frameURL } of candidates) { 1138 let values = frameURL.searchParams.getAll(BUBBLEHOUSE_ACTION_QUERY_PARAM); 1139 if (values.length > 0) { 1140 continue 1141 } 1142 try { 1143 frameURL.searchParams.set(BUBBLEHOUSE_ACTION_QUERY_PARAM, bubblehouseActionToken); 1144 frame.src = frameURL.toString(); 1145 } catch { 1146 } 1147 } 1148} 1149 1150function observeLateBubblehouseActionFrames() { 1151 if (bubblehouseActionTokenObserver || typeof MutationObserver !== "function") { 1152 return 1153 } 1154 let root = document.body || document.documentElement; 1155 if (!root) { 1156 return 1157 } 1158 try { 1159 bubblehouseActionTokenObserver = new MutationObserver((mutations) => { 1160 for (let mutation of mutations) { 1161 for (let node of mutation.addedNodes || []) { 1162 applyBubblehouseActionTokenToNode(node); 1163 } 1164 } 1165 }); 1166 bubblehouseActionTokenObserver.observe(root, { childList: true, subtree: true }); 1167 } catch { 1168 } 1169} 1170 1171function collectBubblehouseActionFramesIncludingRoot(root) { 1172 let frames = collectBubblehouseFrames(root); 1173 if (root && root.tagName && root.tagName.toLowerCase() === "iframe") { 1174 frames.unshift(root); 1175 } 1176 return frames 1177} 1178 1179function bubblehouseActionAppOrigin() { 1180 for (let value of [Bubblehouse.data.callUrl, Bubblehouse.root]) { 1181 if (!value) { 1182 continue 1183 } 1184 try { 1185 return new URL(value, location.href).origin 1186 } catch { 1187 } 1188 } 1189 return "" 1190} 1191 1192function handleBubblehouseActionTokenStatus(ev) { 1193 let status = ev.data && ev.data.bubblehouseActionTokenStatus; 1194 let acknowledgedToken = ev.data && ev.data.bubblehouseActionToken; 1195 let trustedTokenStatus = acknowledgedToken === bubblehouseActionToken || 1196 (status === "rejected" && acknowledgedToken === ""); 1197 if (!bubblehouseActionToken || 1198 !ev.data || !trustedTokenStatus || 1199 (bubblehouseActionTokenConflict && status !== "rejected") || 1200 (status !== "captured" && status !== "rejected")) { 1201 return 1202 } 1203 if (!isTrustedBubblehouseActionTokenStatus(ev)) { 1204 return 1205 } 1206 let token = bubblehouseActionToken; 1207 if (status === "rejected") { 1208 bubblehouseRejectedActionToken = token; 1209 bubblehouseActionToken = ""; 1210 } 1211 clearOuterBubblehouseActionToken(token); 1212} 1213 1214function isTrustedBubblehouseActionTokenStatus(ev) { 1215 let appOrigin = bubblehouseActionAppOrigin(); 1216 let shopSlugs = bubblehouseActionShopSlugs(); 1217 let proxyPaths = bubblehouseActionProxyPaths(); 1218 for (let frame of collectBubblehouseFrames(document)) { 1219 let frameURL; 1220 try { 1221 frameURL = new URL(frame.src, location.href); 1222 } catch { 1223 continue 1224 } 1225 let actionValues = frameURL.searchParams.getAll(BUBBLEHOUSE_ACTION_QUERY_PARAM); 1226 if (frame.contentWindow === ev.source && 1227 frameURL.origin === ev.origin && 1228 actionValues.length === 1 && 1229 actionValues[0] === bubblehouseActionToken && 1230 isBubblehouseActionFrameURL(frameURL, appOrigin, location.origin, shopSlugs, proxyPaths)) { 1231 return true 1232 } 1233 } 1234 return false 1235} 1236 1237function bubblehouseActionShopSlugs() { 1238 let configured = Bubblehouse.extra && Bubblehouse.extra.actionShopSlugs; 1239 let slugs = Array.isArray(configured) ? configured.filter((slug) => typeof slug === "string" && slug) : []; 1240 let rootSlug = bubblehouseRootShopSlug(Bubblehouse.root, location.href); 1241 if (rootSlug && !slugs.includes(rootSlug)) { 1242 slugs.push(rootSlug); 1243 } 1244 return slugs 1245} 1246 1247function bubblehouseActionProxyPaths() { 1248 let configured = Bubblehouse.extra && Bubblehouse.extra.actionProxyPaths; 1249 return Array.isArray(configured) ? configured.filter((path) => typeof path === "string" && path) : [] 1250} 1251 1252function prepareBubblehouseActionFrameURLForAuthReload(url) { 1253 if (bubblehouseRejectedActionToken && 1254 url.searchParams.get(BUBBLEHOUSE_ACTION_QUERY_PARAM) === bubblehouseRejectedActionToken) { 1255 url.searchParams.delete(BUBBLEHOUSE_ACTION_QUERY_PARAM); 1256 } 1257} 1258 1259function clearOuterBubblehouseActionToken(token) { 1260 try { 1261 let url = new URL(location.href); 1262 if (url.searchParams.get(BUBBLEHOUSE_ACTION_QUERY_PARAM) !== token) { 1263 return 1264 } 1265 url.searchParams.delete(BUBBLEHOUSE_ACTION_QUERY_PARAM); 1266 history.replaceState(history.state, "", url.toString()); 1267 } catch { 1268 } 1269} 1270 1271 1272/* util/wantSuccessfulJson.js */ 1273function wantSuccessfulJson(response) { 1274 if (response.ok) { 1275 return response.json() 1276 } else { 1277 throw new Error(`${response.url} returned HTTP ${response.status} ${response.statusText}`) 1278 } 1279} 1280 1281 1282/* util/waitAll.js */ 1283function waitAll(results) { 1284 return Promise.all(results.map((v) => Promise.resolve(v))) 1285} 1286 1287 1288/* util/reportError.js */ 1289function reportError(error, context = '') { 1290 console.error(`[Bubblehouse] error: ${context ? context + ': ' : ''}${error.message || error}`) 1291 if (error.stack) { 1292 console.error(error.stack) 1293 } 1294} 1295 1296function reportWarning(error, context = '') {
1297 console.warn(`[Bubblehouse] warning: ${context ? context + ': ' : ''}${error.message || error}`) 1298} 1299 1300 1301/* util/debounceAsyncWithRepeat.js */ 1302function debounceAsyncWithRepeat(debugName, debounceInterval, f) { 1303 const IDLE = 0, PENDING = 1, RUNNING = 2, RUNNING_AND_PENDING = 3; 1304 let state = -1; setState(IDLE); 1305 let pendingArgs = []; 1306 return request; 1307 1308 function request(...args) { 1309 pendingArgs = args; 1310 if (state === IDLE) { 1311 schedule() 1312 } else if (state === RUNNING) { 1313 setState(RUNNING_AND_PENDING) 1314 } 1315 } 1316 1317 function schedule() { 1318 setState(PENDING); 1319 setTimeout(start, debounceInterval); 1320 } 1321 1322 function start() { 1323 let args = pendingArgs; 1324 pendingArgs = []; 1325 setState(RUNNING); 1326 let promise = f.apply(null, args); 1327 if (promise && promise.then) { 1328 promise.then(finish, finish) 1329 } else { 1330 finish() 1331 } 1332 } 1333 1334 function finish(result) { 1335 if (result instanceof Error) { 1336 console.error(`[Bubblehouse] ${debugName} failed:`, result) 1337 } else { 1338 if (Bubblehouse.verbose) console.log(`[Bubblehouse] ${debugName} finished:`, result) 1339 } 1340 if (state === RUNNING_AND_PENDING) { 1341 start(); 1342 } else { 1343 setState(IDLE); 1344 } 1345 } 1346 1347 function setState(newState) { 1348 // if (Bubblehouse.verbose) console.log(`[Bubblehouse] ${debugName} state transition: ${state} => ${newState}`); 1349 state = newState; 1350 } 1351} 1352 1353 1354/* shopify/cartApi.js */ 1355// updateCart = debounceAsyncWithRepeat('updateCart', 1, updateCart); 1356 1357Bubblehouse.loadCart = loadCart; 1358Bubblehouse.addToCart = addToCart; 1359Bubblehouse.updateCart = updateCart; 1360Bubblehouse.callStorefront = callStorefront; 1361Bubblehouse.addDiscountCode = addDiscountCode; 1362 1363function loadCart() { 1364 return fetch(Shopify.routes.root + "cart.js").then(wantSuccessfulJson); 1365} 1366 1367// items = [{ id: giftVariantId, quantity: 1 }] 1368function addToCart(items, { origin } = {}) { 1369 let body = { items }; 1370 console.log(`[Bubblehouse] adding to cart (${origin}):`, body); 1371 return fetch(Shopify.routes.root + "cart/add.js?bubblehouse-cart-ignore=1", { 1372 method: "POST", 1373 body: JSON.stringify(body), 1374 headers: { "Content-Type": "application/json" }, 1375 }).then(wantSuccessfulJson); 1376} 1377 1378// updates = { key1: quantity1, ... } 1379function updateCart(updates, { origin } = {}) { 1380 let body = { updates }; 1381 console.log(`[Bubblehouse] updating cart (${origin}):`, body); 1382 return fetch( 1383 `${Shopify.routes.root}cart/update.js?bubblehouse-cart-ignore=1`, 1384 { 1385 method: "POST", 1386 body: JSON.stringify(body), 1387 headers: { "Content-Type": "application/json" }, 1388 }, 1389 ); 1390} 1391 1392async function addDiscountCode(_cartToken, discountCode, codesToRemove = []) { 1393 const cart = await loadCart(); 1394 const existingDiscountCodes = (cart.discount_codes || []).map(({ code }) => code); 1395 const discountCodes = [...new Set([ 1396 ...existingDiscountCodes.filter((code) => !codesToRemove.includes(code)), 1397 discountCode, 1398 ])]; 1399 if (Bubblehouse.verbose) { 1400 console.log(`[BH Debug] new discountCodes = ${JSON.stringify(discountCodes)}`) 1401 } 1402 const updated = await setDiscountCodes(discountCodes); 1403 if (!updated.discount_codes?.some(({ code, applicable }) => 1404 code.toLowerCase() === discountCode.toLowerCase() && applicable 1405 )) { 1406 throw new Error("Shopify did not apply the discount code"); 1407 } 1408} 1409 1410function setDiscountCodes(discountCodes) { 1411 const body = { discount: discountCodes.join(",") }; 1412 return fetch( 1413 `${Shopify.routes.root}cart/update.js?bubblehouse-cart-ignore=1`, 1414 { 1415 method: "POST", 1416 body: JSON.stringify(body), 1417 headers: { "Content-Type": "application/json" }, 1418 }, 1419 ).then(wantSuccessfulJson); 1420} 1421 1422function isShopifyStorefrontApiAvailable() { 1423 return !!Bubblehouse.data.storefrontToken; 1424} 1425 1426async function callStorefront(query, variables) { 1427 let token = Bubblehouse.data.storefrontToken; 1428 if (!token) { 1429 throw new Error("misconfigured: missing storefront token"); 1430 } 1431 1432 let m = query.match(/result: (\w+)/); 1433 if (!m) { 1434 throw new Error("invalid GQL query"); 1435 } 1436 let callName = m[1]; 1437 1438 let response = await fetch("/api/2025-01/graphql.json", { 1439 method: "POST", 1440 headers: { 1441 "Content-Type": "application/json", 1442 "X-Shopify-Storefront-Access-Token": token, 1443 }, 1444 body: JSON.stringify({ query, variables }), 1445 }); 1446 let data = await response.json(); 1447 if (Bubblehouse.verbose) { 1448 console.log(`[Bubblehouse] callStorefront ${callName}(${JSON.stringify(variables)}) => ${JSON.stringify(data)}`) 1449} 1450 if (data?.errors) { 1451 // {"errors":[{"message":"Field 'xxcart' doesn't exist on type 'QueryRoot'","locations":[{"line":2,"column":5}],"path":["query","result"],"extensions":{"code":"undefinedField","typeName":"QueryRoot","fieldName":"xxcart"}},{"message":"Variable $cartId is declared by anonymous query but not used","locations":[{"line":1,"column":1}],"path":["query"],"extensions":{"code":"variableNotUsed","variableName":"cartId"}}]} 1452 1453 console.error(`[Bubblehouse] ${callName} GQL errors: ${JSON.stringify(data.errors)}`); 1454 throw new Error(`${callName}: GQL error: ${data.errors[0].message} (${data.errors.length})`); 1455 } 1456 let result = data.data?.result; 1457 if (result?.userErrors?.length) { 1458 console.error(`[Bubblehouse] ${callName} user errors:`, result.userErrors); 1459 throw new Error(`${callName}: GQL userErrors: ${result.userErrors}`); 1460 } 1461 return result; 1462} 1463 1464function navigateToCartOrReloadPage(cartUrl) { 1465 window.location.href = cartUrl ? cartUrl : "/cart"; 1466} 1467 1468 1469/* xhrMonitor.js */ 1470;(function() { 1471let origXHROpen = XMLHttpRequest.prototype.open; 1472let origFetch = window.fetch; 1473XMLHttpRequest.prototype.open = xhrOpenWrapperUnlikelyToBeRelatedToYourError; 1474window.fetch = fetchWrapperUnlikelyToBeRelatedToYourError; 1475if (Bubblehouse.verbose) console.log('[Bubblehouse] xhr monitoring enabled'); 1476 1477function xhrOpenWrapperUnlikelyToBeRelatedToYourError(method, url) { 1478 if (this.bubblehouseListener) 1479 this.removeEventListener('loadend', this.bubblehouseListener);
1480 this.bubblehouseListener = handleXMLHttpRequestLoaded.bind(this, method.toUpperCase(), url) 1481 this.addEventListener('loadend', this.bubblehouseListener); 1482 origXHROpen.apply(this, arguments); 1483} 1484 1485function handleXMLHttpRequestLoaded(method, url) { 1486 // if (Bubblehouse.verbose) console.log(`[Bubblehouse] xhr finished via XMLHttpRequest: ${method} ${url}`); 1487 document.dispatchEvent(new CustomEvent('bubblehouse:xhrend', { detail: { method: method, url: url, ok: true } })); 1488} 1489 1490function fetchWrapperUnlikelyToBeRelatedToYourError(resource, options) { 1491 let method = 'GET'; 1492 if (options && typeof options === 'object' && options.method && typeof options.method === 'string') { 1493 method = options.method.toUpperCase(); 1494 } 1495 let promise = origFetch.apply(this, arguments); 1496 promise.then(fetchWrapperSucceeded.bind(null, method)); 1497 return promise; 1498} 1499 1500function fetchWrapperSucceeded(method, response) { 1501 // if (Bubblehouse.verbose) console.log(`[Bubblehouse] xhr finished via fetch: ${method} ${response.url}`); 1502 document.dispatchEvent(new CustomEvent('bubblehouse:xhrend', { detail: { method: method, url: response.url, ok: response.ok } })); 1503} 1504})() || true; 1505 1506/* cartMonitor.js */ 1507;(function() { 1508Bubblehouse.cartUpdaters = [] 1509Bubblehouse.cartListeners = [] 1510document.addEventListener('bubblehouse:xhrend', considerUpdatingCart); 1511document.addEventListener('bubblehouse:external-cart-update', handleExternalCartUpdate); 1512setTimeout(() => { handleCartUpdate('pageLoad', false) }, 1) 1513 1514function considerUpdatingCart(ev) { 1515 let { method, url } = ev.detail; 1516 let m 1517 if ((method === 'POST') && (m = /\/cart\/(add|update|change)/.exec(url))) { 1518 let isBubblehouseOrigin = /bubblehouse-cart-ignore/.test(url) 1519 if (Bubblehouse.verbose) console.log(`[Bubblehouse] cart update detected (from ${isBubblehouseOrigin ? 'bubblehouse' : 'outside'})`); 1520 handleCartUpdate(m[1], isBubblehouseOrigin).then(null, reportError) 1521 // } else if ((method === 'POST') && (m = /\/api\/([0-9-]+)\/graphql\.json/.exec(url))) { 1522 } 1523} 1524 1525function handleExternalCartUpdate() { 1526 handleCartUpdate('external_update', false).then(null, reportError) 1527} 1528 1529// document.dispatchEvent(new Event('bubblehouse_cart_update', { detail: { origin: url } })) 1530 1531async function handleCartUpdate(cartAction, isBubblehouseOrigin) { 1532 if (Bubblehouse.cartListeners.length === 0 && Bubblehouse.cartUpdaters.length === 0) return; 1533 let cartBody 1534 try { 1535 cartBody = await loadCart() 1536 } catch (error) { 1537 reportWarning(error, `cart fetch failed after ${cartAction}`) 1538 return 1539 } 1540 if (Bubblehouse.verbose) console.log(`[Bubblehouse] cart after ${cartAction}:`, cartBody); 1541 if (!isBubblehouseOrigin) { 1542 await waitAll(Bubblehouse.cartUpdaters.map((f) => f(cartBody, cartAction))) 1543 } 1544 await waitAll(Bubblehouse.cartListeners.map((f) => f(cartBody, cartAction))) 1545 //await rerenderIfModified(cartBody) 1546} 1547 1548function rerenderIfModified(originalCart) { 1549 return loadCart().then((newCart) => { 1550 if (JSON.stringify(originalCart.items) != JSON.stringify(newCart.items)) { 1551 if (Bubblehouse.verbose) console.log(`[Bubblehouse] cart modified after running cart updaters`); 1552 return rerenderCart(); 1553 } else { 1554 if (Bubblehouse.verbose) console.log(`[Bubblehouse] cart unmodified after running cart updaters`); 1555 } 1556 }) 1557} 1558})() || true; 1559 1560/* shopify/isOnCartPage.js */ 1561Bubblehouse.isOnCartPage = isOnCartPage; 1562Bubblehouse.isCartVisible = isCartVisible; 1563 1564function isOnCartPage() { 1565 return /\/cart$/.test(location.pathname) 1566} 1567 1568function isCartVisible() { 1569 return isOnCartPage() || !!document.querySelector('#cart-drawer') 1570} 1571 1572 1573/* util/ignoreErr.js */ 1574function ignoreErr(debugPrefix, f) { 1575 try { 1576 let result = f() 1577 if (result && result.then) { 1578 return result['catch'](function (error) { 1579 console.error(`[Bubblehouse] ${debugPrefix} failed:`, error) 1580 }) 1581 } else { 1582 return result 1583 } 1584 } catch (error) { 1585 console.error(`[Bubblehouse] ${debugPrefix} failed:`, error) 1586 } 1587} 1588 1589 1590/* rerenderCart.js */ 1591Bubblehouse.rerenderCart = rerenderCart; 1592 1593function rerenderCart(force) { 1594 if (!isOnCartPage() && !force) { 1595 return 1596 } 1597 location.reload(); 1598 // return waitAll([ 1599 // ignoreErr('rerenderCart.shopify', () => { 1600 // let el = document.querySelector('cart-notification'); 1601 // if (el && 'function' === typeof el.renderContents) { 1602 // return fetch(Shopify.routes.root + 'cart?sections=cart-drawer,cart-icon-bubble', { cache: 'no-cache', credentials: 'same-origin' }).then(wantSuccessfulJson).then((body) => { 1603 // el.renderContents({ sections: body }) 1604 // }) 1605 // } 1606 // }), 1607 // ]) 1608} 1609 1610 1611/* freeSample.js */ 1612;(function() { 1613if (!Bubblehouse.data.freeSample) { 1614 return 1615} 1616let freeSampleVariantId = Bubblehouse.data.freeSample.variantId; 1617Bubblehouse.cartUpdaters.push(adjustGiftsInCart); 1618if (Bubblehouse.verbose) console.log(`[Bubblehouse] [free-sample] enabled with variant ID ${freeSampleVariantId}`) 1619 1620let minPurchaseAmountInCents = ~~(parseFloat(Bubblehouse.data.freeSample.minPurchaseAmount, 10) * 100 + 0.5) 1621 1622function adjustGiftsInCart(cart, cartAction) { 1623 let sampleItems = [], regularItems = [] 1624 for (let item of cart.items) { 1625 if (item.variant_id === freeSampleVariantId) { 1626 sampleItems.push(item) 1627 } else { 1628 regularItems.push(item) 1629 } 1630 } 1631 1632 let isFreeSampleOwed = (regularItems.length > 0) && (cart.total_price >= minPurchaseAmountInCents); 1633 1634 if (Bubblehouse.verbose) console.log(`[Bubblehouse] [free-sample] cart contains ${regularItems.length} regular items and ${sampleItems.length} sample items, amount ${cart.total_price} => ${isFreeSampleOwed ? 'ELIGIBLE' : 'NOT eligible'} for free sample.`) 1635 1636 if (isFreeSampleOwed && sampleItems.length === 0) { 1637 if (cartAction === 'add' || (cartAction === 'update' && Bubblehouse.data.freeSample.autoAdd) || (cartAction === 'pageLoad' && isCartVisible())) { 1638 if (Bubblehouse.data.freeSample.autoAdd) { 1639 addGiftToCart().then(() => rerenderCart(false)) 1640 } else { 1641 console.log(`[Bubblehouse] [free-sample] offering via popup`) 1642 1643 let popup 1644 popup = openPopupHere('free-sample', {}, { 1645 handlers: { 1646 'AddGiftToCart1': function (message) { doAddSampleToCart1(popup, message) }, 1647 } 1648 }) 1649 } 1650 } else { 1651 console.log(`[Bubblehouse] [free-sample] eligible, but not offered on ${cartAction} action`) 1652 } 1653 } else if (sampleItems.length > 0) { 1654 let firstItem = sampleItems.pop(); 1655 let firstItemQuantity = (isFreeSampleOwed ? 1 : 0); 1656 1657 let updates = {}; 1658 if (firstItem.quantity !== firstItemQuantity) { 1659 updates[firstItem.key] = firstItemQuantity; 1660 } 1661 for (let item of sampleItems) { 1662 updates[item.key] = 0; 1663 } 1664 1665 if (Object.keys(updates).length > 0) { 1666 if (Bubblehouse.verbose) console.log(`[Bubblehouse] [free-sample] removing from cart.`) 1667 1668 let u = updateCart(updates, { origin: 'free-sample' }) 1669 console.log('u = ', u) 1670 return u.then(() => rerenderCart(false)) 1671 } else { 1672 if (Bubblehouse.verbose) console.log(`[Bubblehouse] [free-sample] is up to date`) 1673 } 1674 } 1675} 1676 1677function doAddSampleToCart1(popup, message) { 1678 if (Bubblehouse.verbose) console.log(`[Bubblehouse] [free-sample] adding to cart`); 1679 addGiftToCart().then(() => { 1680 popup.close(); 1681 return rerenderCart(true); 1682 }) 1683} 1684 1685function addGiftToCart() { 1686 return addToCart([{ id: freeSampleVariantId, quantity: 1 }], { origin: 'free-sample' }) 1687} 1688})() || true; 1689 1690/* util/observeElements.js */ 1691function observeElements(selector, func) { 1692 let observer = new MutationObserver(function (mutations) { 1693 mutations.forEach(function (mutation) { 1694 for (let node of mutation.addedNodes) { 1695 if (node.nodeType === Node.ELEMENT_NODE && node.matches(selector)) { 1696 func(node) 1697 } 1698 } 1699 }) 1700 }) 1701 observer.observe(document, { subtree: true, childList: true }) 1702 1703 for (let node of document.querySelectorAll(selector)) { 1704 func(node) 1705 } 1706} 1707 1708 1709/* shopify/productPage.js */ 1710function isShopifyProductPage() { 1711 return ( 1712 /\/products\/[^/]+/.test(window.location.pathname) || 1713 window.ShopifyAnalytics?.meta?.page?.pageType === 'product' || 1714 window.meta?.product != null 1715 ) 1716} 1717 1718 1719/* inlinePanels.js */ 1720;(function() {
1721observeElements('.bubblehouse-product-points-panel', populateProductPointsPanel) 1722 1723function populateProductPointsPanel(panel) { 1724 if (!panel) { 1725 return 1726 } 1727 if (!Bubblehouse.data.ppc) { 1728 return 1729 } 1730 let valueSpan = panel.querySelector('.bubblehouse-product-points-value') 1731 let amount = panel.dataset.priceCents 1732 if (Bubblehouse.verbose) console.log(`[Bubblehouse] Found inline panel, ppc = ${Bubblehouse.data.ppc}, amount = ${amount}`) 1733 if (valueSpan && amount) { 1734 let points = Math.floor(Bubblehouse.data.ppc * +amount) 1735 valueSpan.textContent = points.toFixed(0) 1736 1737 if (Bubblehouse.data.prelaunch) { 1738 return 1739 } 1740 panel.style.visibility = '' 1741 panel.style.display = '' 1742 updatePointsOnVariantChange(valueSpan) 1743 } 1744} 1745 1746async function updatePointsOnVariantChange(valueSpan) { 1747 if (!isShopifyProductPage()) { 1748 return 1749 } 1750 1751 let url = new URL(window.location.href) 1752 url.pathname += '.js' 1753 1754 let data 1755 try { 1756 let response = await fetch(url.toString()) 1757 if (!response.ok) { 1758 console.error(`[Bubblehouse] error fetching product json`) 1759 return 1760 } 1761 data = await response.json() 1762 } catch { 1763 console.error(`[Bubblehouse] error fetching product json`) 1764 return 1765 } 1766 1767 if (!data || !Array.isArray(data.variants)) { 1768 console.error(`[Bubblehouse] product variants array is undefined`) 1769 return 1770 } 1771 1772 window.addEventListener('bubblehouse:variant-change', e => { 1773 let variant = data.variants.find(o => o.id == e.detail.variantId) 1774 if (!variant) { 1775 console.log(`[Bubblehouse] variant ${e.detail.variantId} not found`) 1776 return 1777 } 1778 let points = Math.floor(Bubblehouse.data.ppc * +variant.price) 1779 valueSpan.textContent = points.toFixed(0) 1780 }) 1781} 1782})() || true; 1783 1784/* util/h.js */ 1785function h(tagSpec, ...children) { 1786 let [tagName, ...classesAndIds] = tagSpec.match(/[#.]?[^#.]+/g) 1787 let element = document.createElement(tagName) 1788 for (let identifier of classesAndIds) { 1789 if (identifier.startsWith('#')) { 1790 element.id = identifier.slice(1) 1791 element.classList.add(identifier.slice(1)) 1792 } else if (identifier.startsWith('.')) { 1793 element.classList.add(identifier.slice(1)) 1794 } else { 1795 throw new Error(`invalid tag spec: "${tagSpec}" (invalid part is "${identifier}")`) 1796 } 1797 } 1798 while (children.length > 0 && children[0] && children[0].constructor === Object) { 1799 setAttrs(element, children.shift()) 1800 } 1801 if (children.length === 1 && typeof children[0] === 'string') { 1802 element.textContent = children[0] 1803 } else { 1804 for (let child of children) { 1805 appendChild(element, child) 1806 } 1807 } 1808 return element; 1809} 1810 1811function setAttrs(element, attrs) { 1812 for (const [attr, value] of Object.entries(attrs)) { 1813 if (value == null || value === false) { 1814 continue 1815 } else if (attr === 'class') { 1816 adjustClasses(element, value) 1817 } else if (attr.startsWith('on')) { 1818 let event = attr.substr(2) 1819 element.addEventListener(event, value) 1820 } else if (value === true) { 1821 element.setAttribute(attr, '') 1822 } else if (value && value.constructor === Object) { 1823 if (attr === 'style') { 1824 Object.assign(element.style, value) 1825 } else { 1826 throw new TypeError(`Invalid object value for attribute "${attr}"`) 1827 } 1828 } else { 1829 element.setAttribute(attr, value.toString()) 1830 } 1831 } 1832} 1833 1834// classes -> classItem | array of classes 1835// classItem -> falsy | classSpecString | {classSpecString: booleanish} 1836// classSpecString -> whitespace-separated list of classes 1837function adjustClasses(element, classes) { 1838 if (!classes) return; 1839 if (typeof classes === 'string') { 1840 element.classList.add(...classes.trim().split(/\s+/)) 1841 } else if (Array.isArray(classes)) { 1842 for (let item of classes) { 1843 adjustClasses(element, item) 1844 } 1845 } else if (classes && classes.constructor === Object) { 1846 for (let [name, included] of Object.entries(classes)) { 1847 element.classList.toggle(name, !!included) 1848 } 1849 } else { 1850 throw new TypeError('Classes must be strings, objects, or false/null/undefined'); 1851 } 1852} 1853 1854function appendChild(element, child) { 1855 if (child == null || child === false) return; 1856 if (child instanceof Node || typeof child === 'string') { 1857 element.append(child) 1858 } else if (typeof child === 'number') { 1859 element.append(child.toString()) 1860 } else if (Array.isArray(child)) { 1861 for (let nestedChild of child) { 1862 appendChild(element, nestedChild) 1863 } 1864 } else {
1865 throw new TypeError('Children must be elements, strings, numbers, arrays, or false/null/undefined'); 1866 } 1867 return child 1868} 1869 1870function assignElementRefs(host, root) { 1871 for (let child of root.children) { 1872 if (child.id) { 1873 host[child.id] = child 1874 } 1875 assignElementRefs(host, child) 1876 } 1877 return root 1878} 1879 1880 1881/* util/removeArrayElement.js */ 1882function removeArrayElement(array, element) { 1883 let index = array.indexOf(element) 1884 if (index >= 0) { 1885 array.splice(index, 1) 1886 } 1887} 1888 1889 1890/* util/parseStringTemplate.js */ 1891function parseStringTemplate(str, fs, fk, prefix = '{{.', suffix = '}}') { 1892 // Given "This is {{.foo}} sample {{.bar}} value", calls 1893 // - fs('This is ') 1894 // - fk('foo') 1895 // - fs(' sample ') 1896 // - fk('bar') 1897 // - fs(' value.') 1898 let remaining = str; 1899 let startIndex = 0, 1900 endIndex = 0; 1901 while ( 1902 (startIndex = remaining.indexOf(prefix)) >= 0 && 1903 (endIndex = remaining.indexOf(suffix, startIndex + prefix.length)) >= 0 1904 ) { 1905 if (startIndex > 0) { 1906 fs(remaining.substring(0, startIndex)); 1907 } 1908 fk(remaining.substring(startIndex + prefix.length, endIndex)); 1909 remaining = remaining.substring(endIndex + suffix.length); 1910 } 1911 if (remaining) { 1912 fs(remaining); 1913 } 1914} 1915 1916function mapStringTemplate(str, fs, fk) { 1917 let result = []; 1918 parseStringTemplate( 1919 str, 1920 (s) => { 1921 result.push(fs(s, result.length)); 1922 }, 1923 (s) => { 1924 result.push(fk(s, result.length)); 1925 }, 1926 ); 1927 return result; 1928} 1929 1930function parseTags(str, { validTags }) { 1931 // Given "This is <foo>sample</foo> value", returns 1932 // - ['This is ', {tag: 'foo', body: ['sample']}, ' value'] 1933 // Does not support nested tags so far. 1934 1935 var result = []; 1936 var body = result; 1937 var element = null; 1938 parseStringTemplate(str, (str) => { 1939 body.push(str); 1940 }, (rawStr) => { 1941 let innerStr = rawStr.trim(); 1942 if (innerStr === '') { 1943 body.push('(!EMPTY_TAG!)'); 1944 return; 1945 } 1946 1947 let isClosing = innerStr.startsWith('/'); 1948 let tag = isClosing ? innerStr.substring(1).trim() : innerStr; 1949 1950 if (!validTags.includes(tag)) { 1951 body.push('<' + rawStr + '>'); 1952 } else if (element != null) { 1953 if (!isClosing) { 1954 body.push('(!NESTED_TAG!)'); 1955 } else if (tag === element.tag) { 1956 body = result; 1957 element = null; 1958 } else { 1959 body.push('(!WRONG_CLOSING_TAG!)'); 1960 } 1961 } else if (isClosing) { 1962 body.push('(!UNOPENED_TAG!)'); 1963 } else { 1964 element = { tag: tag, body: [] }; 1965 body.push(element); 1966 body = element.body; 1967 } 1968 }, '<', '>'); 1969 return result; 1970} 1971 1972 1973/* util/localize.js */ 1974function localize(strings, key, vals = {}) { 1975 if (!strings.hasOwnProperty(key)) { 1976 throw new Error(`missing string "${key}"`); 1977 } 1978 let str = strings[key]; 1979 for (let k in vals) 1980 if (vals.hasOwnProperty(k)) { 1981 str = str.replaceAll("{{." + k + "}}", "" + vals[k]); 1982 } 1983 return str; 1984} 1985 1986 1987/* vm/RedeemVM.js */ 1988class RedeemVM { 1989 constructor( 1990 data, 1991 cartCost, 1992 itemCount, 1993 cartCouponCodes, 1994 desiredRedemptionAmountOrPoints, 1995 locale, 1996 overrides = {}, 1997 options = { subtractProvisionalAmount: false}, 1998 ) { 1999 data || (data = {}); 2000 let isFreeCart = 2001 data.enableWhenFree && 2002 cartCost != null && 2003 itemCount != null && 2004 cartCost == 0 && 2005 itemCount > 0; 2006 this.data = data; 2007 this.cartCost = cartCost; 2008 this.locale = locale; 2009 this.isLoaded = data && cartCost != null; // && (cartCost > 0 || isFreeCart); 2010 this.offers = (data && data.offers) || []; 2011 this.enablePointRedemption = data && data.enablePointRedemption; 2012 console.log(`[Bubblehouse] data =`, data); 2013 console.log( 2014 `[Bubblehouse] RedeemVM: cartCost = ${cartCost}, itemCount = ${itemCount}, isLoaded = ${this.isLoaded}, isFreeCart = ${isFreeCart}`, 2015 ); 2016 if (!this.isLoaded || !data.styles) { 2017 return; 2018 } 2019 2020 this.values = data.styles.v || {}; 2021 this.classes = data.styles.c || {}; 2022 this.stylesheetUrl = data.styles.css; 2023 this.col1 = 2024 overrides.col1 || this.resolveValue(".widget.presentation.col1.derived"); 2025 this.col2 = 2026 overrides.col2 || this.resolveValue(".widget.presentation.col2.derived"); 2027 if (!["amount", "points"].includes(this.col1)) { 2028 throw new Error(
2029 `<bubblehouse-redeem-panel>: internal error: col1 = ${this.col1}`, 2030 ); 2031 } 2032 if ( 2033 !["amount", "points", "none"].includes(this.col2) || 2034 this.col2 === this.col1 2035 ) { 2036 throw new Error( 2037 `<bubblehouse-redeem-panel>: internal error: col2 = ${this.col2}`, 2038 ); 2039 } 2040 2041 this.spendablePts = data.ptsSpendable; 2042 this.spendableAmount = this.convertPointsToAmount(this.spendablePts); 2043 2044 let maxRedeemPts = data.redeemPtsMax; 2045 if (cartCost != null) { 2046 let maxOrderAmount = cartCost; 2047 if (options.subtractProvisionalAmount && data.provisionalAmount != null) { 2048 maxOrderAmount = Math.max(0, cartCost - data.provisionalAmount); 2049 // if (data.provisionalCouponCode == null || cartCouponCodes == null || !cartCouponCodes.includes(data.provisionalCouponCode)) { 2050 // } 2051 } 2052 let maxOrderPts = Math.ceil(this.convertAmountToPoints(maxOrderAmount)); 2053 maxRedeemPts = Math.min(maxRedeemPts, maxOrderPts); 2054 } 2055 let defaultRedeemPts = 2056 Number.isFinite(data.redeemPtsDefault) ? data.redeemPtsDefault : maxRedeemPts; 2057 defaultRedeemPts = Math.min(Math.max(0, defaultRedeemPts), maxRedeemPts); 2058 if (defaultRedeemPts != 0 && defaultRedeemPts < data.redeemPtsMin) { 2059 defaultRedeemPts = 0; 2060 } 2061 2062 if ( 2063 desiredRedemptionAmountOrPoints == null || 2064 !isNaN(desiredRedemptionAmountOrPoints) 2065 ) { 2066 if (this.col1 === "amount") { 2067 if (desiredRedemptionAmountOrPoints == null) { 2068 this.effectiveRedemptionPts = defaultRedeemPts; 2069 } else { 2070 this.effectiveRedemptionPts = Math.ceil( 2071 this.convertAmountToPoints(desiredRedemptionAmountOrPoints), 2072 ); 2073 } 2074 this.effectiveRedemptionAmount = this.convertPointsToAmount( 2075 this.effectiveRedemptionPts, 2076 ); 2077 } else if (this.col1 === "points") { 2078 this.effectiveRedemptionPts = 2079 desiredRedemptionAmountOrPoints == null 2080 ? defaultRedeemPts 2081 : desiredRedemptionAmountOrPoints; 2082 this.effectiveRedemptionAmount = this.convertPointsToAmount( 2083 this.effectiveRedemptionPts, 2084 ); 2085 } 2086 } else { 2087 this.effectiveRedemptionPts = null; 2088 this.effectiveRedemptionAmount = null; 2089 } 2090 2091 this.amountError = null; 2092 let applyStatusGates = 2093 options.applyStatusGates === true || 2094 options.applyCheckoutStatusGates === true; 2095 let applyDiscountCodeGate = 2096 options.applyDiscountCodeGate === true || 2097 options.applyCheckoutStatusGates === true; 2098 this.redeemDisabledMessage = 2099 applyStatusGates && data.redeemDisabledMessage != null 2100 ? data.redeemDisabledMessage 2101 : ""; 2102 this.isRedemptionBlockedByStatusGate = 2103 applyStatusGates && 2104 ( 2105 this.redeemDisabledMessage !== "" || 2106 data.enabled === false || 2107 data.enablePointRedemption === false 2108 ); 2109 this.isRedemptionBlockedByDiscountCodeGate = 2110 applyDiscountCodeGate && 2111 data.disableIfDiscountApplied === true && 2112 (cartCouponCodes || []).length > 0; 2113 this.isRedemptionBlockedByCheckoutState = 2114 this.isRedemptionBlockedByStatusGate || 2115 this.isRedemptionBlockedByDiscountCodeGate; 2116 if (this.effectiveRedemptionPts != null) { 2117 if (this.effectiveRedemptionPts < 0) { 2118 this.amountError = this.localize(".error.negative.string"); 2119 } else if ( 2120 this.effectiveRedemptionPts != 0 && 2121 this.effectiveRedemptionPts < data.redeemPtsMin 2122 ) { 2123 let valueStr; 2124 if (this.col1 === "amount") { 2125 valueStr = this.formatBareCurrency( 2126 this.convertPointsToAmount(data.redeemPtsMin), 2127 ); 2128 } else if (this.col1 === "points") { 2129 valueStr = this.formatPoints(data.redeemPtsMin); 2130 } 2131 this.amountError = this.localize(".error.below-minimum.string", { 2132 value: valueStr, 2133 }); 2134 } else if ( 2135 this.effectiveRedemptionPts != 0 && 2136 this.effectiveRedemptionPts > maxRedeemPts 2137 ) { 2138 let pts; 2139 let valueStr; 2140 2141 if (maxRedeemPts === 0 && data.redeemPtsPeriodLimit > 0) { 2142 pts = data.redeemPtsPeriodLimit 2143 } else { 2144 pts = maxRedeemPts 2145 } 2146 2147 if (this.col1 === "amount") { 2148 valueStr = this.formatBareCurrency( 2149 this.convertPointsToAmount(pts), 2150 ); 2151 } else if (this.col1 === "points") { 2152 valueStr = this.formatPoints(pts); 2153 } 2154 this.amountError = this.localize(".error.above-balance.string", { 2155 value: valueStr, 2156 }); 2157 } 2158 2159 this.visibleRedemptionPts = null; 2160 if (this.effectiveRedemptionPts != null) { 2161 this.visibleRedemptionPts = this.effectiveRedemptionPts; 2162 if (this.resolveValue('.redeem-points.presentation.enum') === 'negative') { 2163 this.visibleRedemptionPts = -this.visibleRedemptionPts; 2164 } 2165 } 2166 } 2167 2168 this.isRedeemEnabled = 2169 this.effectiveRedemptionAmount != null && 2170 (this.effectiveRedemptionAmount != 0 || isFreeCart) && 2171 this.amountError == null && 2172 !this.isRedemptionBlockedByCheckoutState; 2173 if (this.effectiveRedemptionAmount != null) { 2174 if (this.effectiveRedemptionAmount == 0 && isFreeCart) { 2175 this.redeemLabel = this.localize(".redeem-button.free.string"); 2176 } else { 2177 this.redeemLabel = this.localize(".redeem-button.active.string", { 2178 amount: this.formatCurrency(this.effectiveRedemptionAmount), 2179 pts: this.formatPoints(this.effectiveRedemptionPts), 2180 }); 2181 } 2182 } else { 2183 this.redeemLabel = this.localize(".redeem-button.disabled.string"); 2184 } 2185 2186 // if (shopDetails.isVerbose) { 2187 // console.log(`[Bubblehouse] spendableAmount = ${spendableAmount}, cartCost = ${cartCost}, defaultRedemptionAmount = ${defaultRedemptionAmount}, unroundedEffectiveRedemptionAmount = ${unroundedEffectiveRedemptionAmount}, effectiveRedemptionPts = ${effectiveRedemptionPts}, effectiveRedemptionAmount = ${effectiveRedemptionAmount}`) 2188 // } 2189 } 2190 2191 minimalCurrencyStep() { 2192 return Math.pow(10, -this.data.shopCurrencyPrecision); 2193 } 2194 formatCurrency(amount) { 2195 return ( 2196 this.data.shopCurrencyPrefix + 2197 this.formatBareCurrency(amount) + 2198 this.data.shopCurrencySuffix 2199 ); 2200 } 2201 formatPoints(pts) { 2202 if (pts < 0) { 2203 return 'â' + this.formatPoints(-pts) 2204 } 2205 return new Intl.NumberFormat(this.locale).format(pts); 2206 } 2207 formatBareCurrency(amount) { 2208 let n = this.data.shopCurrencyPrecision; 2209 return new Intl.NumberFormat(this.locale, { 2210 minimumFractionDigits: n, 2211 maximumFractionDigits: n, 2212 }).format(amount); 2213 } 2214 formatCurrencyInputValue(amount) { 2215 return amount.toFixed(this.data.shopCurrencyPrecision); 2216 } 2217 convertPointsToAmount(pts) { 2218 return Math.round(pts * this.data.ptValue * 10000) / 10000; 2219 } 2220 convertAmountToPoints(amount) { 2221 return Math.round((amount / this.data.ptValue) * 10000) / 10000; 2222 } 2223 2224 resolveValue(key) { 2225 let str = this.values[key]; 2226 if (typeof str === "undefined") { 2227 throw new Error(`missing value ${key}`); 2228 } 2229 return str; 2230 } 2231 2232 resolvePixelValue(key) { 2233 let value = this.resolveValue(key); 2234 if (value == null) { 2235 return null; 2236 } 2237 let m = value.match(/^(\d+)px$/); 2238 if (m) { 2239 return parseInt(m[1], 10); 2240 } 2241 return null; 2242 } 2243 2244 customEmbed(key, stringFunc, childrenFuncs) { 2245 let string = this.resolveValue(key) 2246 if (string === '') { 2247 return; 2248 } 2249 parseStringTemplate(string, stringFunc, (fieldKey, i) => { 2250 let child = childrenFuncs[fieldKey]; 2251 if (child) { 2252 child(); 2253 } else {
2254 stringFunc(`{{!.${fieldKey}!}}`); 2255 } 2256 }); 2257 } 2258 2259 localize(key, vals) { 2260 return localize(this.values, key, vals); 2261 } 2262} 2263 2264 2265/* conditions.js */ 2266function isConditionAttributeValueMatched(conditions) { 2267 if (!conditions) { 2268 return true 2269 } 2270 return conditions.split(/\s+/).every(isConditionMatched) 2271} 2272 2273function isConditionMatched(cond) { 2274 if (cond && cond[0] == '!') { 2275 return !isConditionMatched(cond.substr(1)) 2276 } 2277 switch (cond) { 2278 case '': return true 2279 case 'never': return false 2280 case 'tester': return !!Bubblehouse.data.tester 2281 case 'logged_in': return !!Bubblehouse.data.loggedIn 2282 case 'widget1': return !!Bubblehouse.data.widget1 2283 case 'widget2': return !!Bubblehouse.data.widget2 2284 case 'widget3': return !!Bubblehouse.data.widget3 2285 default: console.error(`[Bubblehouse] invalid condition: if="... ${cond} ..."`); return false 2286 } 2287} 2288 2289function adjustPanelVisibility(element) { 2290 let conditions = element.getAttribute('if') 2291 if (!isConditionAttributeValueMatched(conditions)) { 2292 console.log(`[Bubblehouse] hiding <${element.tagName}> because conditions do not match (${conditions})`) 2293 if (element.style.display !== 'none') { 2294 element.style.display = 'none' 2295 } 2296 return false 2297 } 2298 if (element.style.display === 'none') { 2299 element.style.display = element.getAttribute('display') || '' 2300 } 2301 if (element.style.visibility === 'hidden') { 2302 element.style.visibility = 'visible' 2303 } 2304 if (Bubblehouse.verbose) console.log(`[Bubblehouse] showing <${element.tagName}>`) 2305 return true 2306} 2307 2308 2309/* call.js */ 2310Bubblehouse.call = call; 2311 2312function call(funcName, input) { 2313 let headers = { 2314 'Content-Type': 'application/json', 2315 'Accept': 'application/json', 2316 } 2317 if (Bubblehouse.data.authToken) { 2318 headers['Authorization'] = `Bearer ${Bubblehouse.data.authToken}` 2319 } 2320 return fetch(Bubblehouse.data.callUrl.replace('FUNCNAME', funcName), { 2321 method: 'POST', 2322 body: JSON.stringify(input || {}), 2323 headers: headers, 2324 }).then(wantSuccessfulJson) 2325} 2326 2327 2328/* tags/bubblehouse-redeem-panel.js */ 2329;(function() { 2330const stylesheet = ` 2331:root { display: flex; padding: 4px } 2332.hidden { display: none !important } 2333 2334.inputItem { margin-left: 4px; margin-right: 4px } 2335.inputItem:first-child { margin-left: 0 } 2336.inputItem:last-child { margin-right: 0 } 2337 2338input[type=number].nostepper {-moz-appearance: textfield } 2339input[type=number].nostepper::-webkit-inner-spin-button, 2340input[type=number].nostepper::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0 } 2341`; 2342const UTILITY_CLASSES = { hidden: true }; 2343 2344const experimentalInplaceFlow = true; 2345 2346class BubblehouseRedeemPanel extends HTMLElement { 2347 constructor() { 2348 super(); 2349 2350 this._onCartChanged = this._onCartChanged.bind(this); 2351 this._onCartLoaded = this._onCartLoaded.bind(this); 2352 this._onCartLoadingFailed = this._onCartLoadingFailed.bind(this); 2353 this._onWidgetDataLoaded = this._onWidgetDataLoaded.bind(this); 2354 this._onWidgetDataLoadingFailed = 2355 this._onWidgetDataLoadingFailed.bind(this); 2356 this._onRedemptionAmountChange = this._onRedemptionAmountChange.bind(this); 2357 this._onRedemptionPointsChange = this._onRedemptionPointsChange.bind(this); 2358 this.redeem = this.redeem.bind(this); 2359 this.initialized = false; 2360 2361 this.redemptionPending = false; 2362 this.redemptionError = null; 2363 this.cartData = null; 2364 this.redemptionData = null; 2365 this.desiredRedemptionAmountOrPoints = null; 2366 this.stylesheetReady = false; 2367 this.cartGeneration = 0; 2368 this.statusGeneration = 0; 2369 2370 this.attachShadow({ mode: "open" }); 2371 } 2372 2373 connectedCallback() { 2374 if (!adjustPanelVisibility(this)) return; 2375 Bubblehouse.cartListeners.push(this._onCartChanged); 2376 this.reload("initial"); 2377 this.render(); 2378 } 2379 2380 disconnectedCallback() { 2381 removeArrayElement(Bubblehouse.cartListeners, this._onCartChanged); 2382 } 2383 2384 cartCouponCodes() { 2385 if (!this.cartData) { 2386 return null; 2387 } 2388 return (this.cartData.cart_level_discount_applications || []) 2389 .filter((da) => da.type === "discount_code") 2390 .map((da) => da.title) 2391 } 2392 2393 _collectCheckoutContext() {
2394 let context = { 2395 site: "cart", 2396 country: Shopify.country, 2397 currency: Shopify.currency && Shopify.currency.active, 2398 locale: (Shopify.locale || "").trim(), 2399 timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, 2400 2401 discountCodesKnown: true, 2402 discountCodes: this.cartCouponCodes(), 2403 }; 2404 if (this.cartData) { 2405 context.cartSubtotal = (this.cartData.total_price / 100).toFixed(6); 2406 } 2407 return context; 2408 } 2409 2410 reload(why) { 2411 this.reloadCart(); 2412 this.reloadStatus(why); 2413 } 2414 2415 reloadCart() { 2416 let generation = ++this.cartGeneration; 2417 loadCart().then( 2418 (cartData) => this._onCartLoaded(cartData, generation), 2419 (error) => this._onCartLoadingFailed(error, generation), 2420 ); 2421 } 2422 2423 reloadStatus(why) { 2424 if (!this.cartData) { 2425 return; 2426 } 2427 let generation = ++this.statusGeneration; 2428 call("CheckoutStatus1", { 2429 ...this._collectCheckoutContext(), 2430 }).then( 2431 (redemptionData) => this._onWidgetDataLoaded(redemptionData, generation), 2432 (error) => this._onWidgetDataLoadingFailed(error, generation), 2433 ); 2434 } 2435 2436 render(origin) { 2437 // if (Bubblehouse.verbose) console.log(`<${this.tagName}> rendering:\n\ncartData = ${JSON.stringify(this.cartData, null, 2)}\n\nredemptionData = ${JSON.stringify(this.redemptionData, null, 2)}`); 2438 2439 let vm = (this.vm = new RedeemVM( 2440 this.redemptionData, 2441 this.cartData ? this.cartData.total_price / 100 : null, 2442 this.cartData ? this.cartData.item_count : null, 2443 this.cartCouponCodes(), 2444 this.desiredRedemptionAmountOrPoints, 2445 undefined, 2446 {}, 2447 { subtractProvisionalAmount: false, applyStatusGates: true } 2448 )); 2449 2450 let isContentVisible = vm.isLoaded && !vm.data?.hide; 2451 let isStatusVisible = Boolean(vm.data?.hide && vm.data?.hideMessage); 2452 let isRootVisible = isContentVisible || isStatusVisible; 2453 let isDisabled = isStatusVisible || vm.isRedemptionBlockedByCheckoutState; 2454 2455 if (!this.initialized && vm.stylesheetUrl) { 2456 this.initialized = true; 2457 if (Bubblehouse.verbose) { 2458 console.log( 2459 `[Bubblehouse] <${this.tagName}> initialized with vm: `, 2460 vm, 2461 ); 2462 } 2463 2464 let panelCells = []; 2465 if (vm.col1 === "amount") { 2466 panelCells.push(h("div#availableAmount")); 2467 } else if (vm.col1 === "points") { 2468 panelCells.push(h("div#availablePoints")); 2469 } 2470 if (vm.col2 === "points") { 2471 panelCells.push(h("div#availablePoints")); 2472 } else if (vm.col2 === "amount") { 2473 panelCells.push(h("div#availableAmount")); 2474 } 2475 if (vm.col1 === "amount") { 2476 panelCells.push(h("div#redemptionAmountCell")); 2477 } else if (vm.col1 === "points") { 2478 panelCells.push(h("div#redemptionPtsCell")); 2479 } 2480 if (vm.col2 === "points") { 2481 panelCells.push(h("div#redemptionPtsCell")); 2482 } else if (vm.col2 === "amount") { 2483 panelCells.push(h("div#redemptionAmountCell")); 2484 } 2485 2486 let logo = null; 2487 let logoUrl = vm.values[".logo.image"]; 2488 if (logoUrl) { 2489 logo = h("img#logoImg", { src: logoUrl, alt: "" }); 2490 } 2491 2492 let stylesheetEl = h("link", { rel: "stylesheet", href: vm.stylesheetUrl }); 2493 stylesheetEl.addEventListener("load", () => { 2494 this.stylesheetReady = true; 2495 this.render("stylesheet_loaded"); 2496 }); 2497 this.shadowRoot.append( 2498 h("style", stylesheet), 2499 stylesheetEl, 2500 h( 2501 "div#rootPanel", 2502 { style: { display: "none" } }, 2503 logo, 2504 h("div#amountsPanel", panelCells), 2505 h(`button#redeemButton`, { 2506 type: "button", 2507 onclick: this.redeem, 2508 }), 2509 h("div#statusPanel", h("span#statusMessageSpan")), 2510 ), 2511 ); 2512 assignElementRefs(this, this.shadowRoot); 2513 this.setRootPanelVisible(false); 2514 2515 this.applyClass(this.rootPanel, "widget"); 2516 this.applyClass(this.logoImg, "logo"); 2517 this.applyClass(this.statusPanel, "message-panel"); 2518 this.applyClass(this.amountsPanel, "amounts-panel"); 2519 this.applyClass(this.redeemButton, "redeem-button");
2520 this.applyClass(this.availableAmount, "available-amount"); 2521 this.applyClass(this.availablePoints, "available-points"); 2522 this.applyClass(this.redemptionAmountCell, "redeem-amount"); 2523 this.applyClass(this.redemptionPtsCell, "redeem-points"); 2524 2525 this.embedChildren( 2526 this.availableAmount, 2527 ".available-amount.overall.string", 2528 { 2529 value: () => h("span#availableAmountValue"), 2530 }, 2531 ); 2532 this.applyClass(this.availableAmountValue, "available-amount-value"); 2533 2534 this.embedChildren( 2535 this.availablePoints, 2536 ".available-points.overall.string", 2537 { 2538 value: () => h("span#availablePointsValue"), 2539 }, 2540 ); 2541 this.applyClass(this.availablePointsValue, "available-points-value"); 2542 2543 if (vm.col1 === "amount") { 2544 this.embedChildren( 2545 this.redemptionAmountCell, 2546 ".redeem-amount.overall.string", 2547 { 2548 value: () => 2549 h( 2550 "span#redemptionAmountValue.inputItem", 2551 {}, 2552 h( 2553 "span#redemptionAmountPrefixSpan", 2554 vm.data.shopCurrencyPrefix, 2555 ), 2556 h("input#redemptionInput.nostepper", { 2557 type: "number", 2558 min: "0", 2559 step: "" + vm.minimalCurrencyStep(), 2560 placeholder: vm.formatBareCurrency(0), 2561 onchange: this._onRedemptionAmountChange, 2562 oninput: this._onRedemptionAmountChange, 2563 }), 2564 h( 2565 "span#redemptionAmountSuffixSpan", 2566 vm.data.shopCurrencySuffix, 2567 ), 2568 ), 2569 }, 2570 ); 2571 this.embedChildren( 2572 this.redemptionPtsCell, 2573 ".redeem-points.overall.string", 2574 { 2575 value: () => h("span#redemptionPtsValueSpan"), 2576 }, 2577 ); 2578 this.detailRedemptionCell = this.redemptionPtsCell; 2579 } else if (vm.col1 === "points") { 2580 this.embedChildren( 2581 this.redemptionPtsCell, 2582 ".redeem-points.overall.string", 2583 { 2584 value: () => 2585 h( 2586 "span#redemptionPtsValueSpan.inputItem", 2587 h("input#redemptionInput.nostepper", { 2588 type: "number", 2589 min: "0", 2590 onchange: this._onRedemptionPointsChange, 2591 oninput: this._onRedemptionPointsChange, 2592 }), 2593 ), 2594 }, 2595 ); 2596 this.embedChildren( 2597 this.redemptionAmountCell, 2598 ".redeem-amount.overall.string", 2599 { 2600 value: () => h("span#redemptionAmountValue"), 2601 }, 2602 ); 2603 this.detailRedemptionCell = this.redemptionAmountCell; 2604 } 2605 this.applyClass(this.redemptionInput, "input-field"); 2606 this.applyClass(this.redemptionAmountValue, "redeem-amount-value"); 2607 this.applyClass(this.redemptionPtsValueSpan, "redeem-points-value"); 2608 } 2609 if (!this.initialized) { 2610 return; 2611 } 2612 2613 this.applyClass(this.logoImg, { 2614 "logo--enabled": !isDisabled, 2615 "logo--disabled": isDisabled, 2616 }); 2617 // this.applyClass(this.logoImg, , isDisabled); 2618 this.applyClass(this.rootPanel, { 2619 "widget--enabled": !isDisabled, 2620 "widget--disabled": isDisabled, 2621 }); 2622 this.applyClass(this.statusPanel, { 2623 "message-panel--enabled": !isDisabled, 2624 "message-panel--disabled": isDisabled, 2625 }); 2626 this.applyClass(this.amountsPanel, { hidden: !isContentVisible }); 2627 this.applyClass(this.redeemButton, { hidden: !isContentVisible }); 2628 2629 console.log( 2630 `[Bubblehouse] <${this.tagName}> render: isContentVisible = ${isContentVisible}, vm.data?.hide = ${vm.data?.hide}, this.amountsPanel.className = ${this.amountsPanel.className}, this.redeemButton.className = ${this.redeemButton.className}`, 2631 ); 2632 2633 if (vm.data?.hide) { 2634 this._renderStatusMessage(vm.data.hideMessage, vm.data.actions); 2635 this.setRootPanelVisible(isRootVisible); 2636 return; 2637 } else if (!vm.isLoaded) { 2638 this._renderStatusMessage(""); 2639 this.setRootPanelVisible(isRootVisible); 2640 return; 2641 } 2642
2643 if (this.availableAmountValue) { 2644 this.availableAmountValue.textContent = vm.formatCurrency( 2645 vm.spendableAmount, 2646 ); 2647 } 2648 if (this.availablePointsValue) { 2649 this.availablePointsValue.textContent = vm.formatPoints(vm.spendablePts); 2650 } 2651 if (this.detailRedemptionCell) { 2652 this.detailRedemptionCell.classList.toggle( 2653 "hidden", 2654 vm.visibleRedemptionPts == null, 2655 ); 2656 } 2657 if (this.redemptionPtsValueSpan && vm.col2 === "points") { 2658 this.redemptionPtsValueSpan.textContent = 2659 vm.visibleRedemptionPts != null 2660 ? vm.formatPoints(vm.visibleRedemptionPts) 2661 : ""; 2662 } 2663 if (this.redemptionAmountValue && vm.col2 === "amount") { 2664 this.redemptionAmountValue.textContent = 2665 vm.effectiveRedemptionAmount != null 2666 ? vm.formatCurrency(vm.effectiveRedemptionAmount) 2667 : ""; 2668 } 2669 if (this.shadowRoot.activeElement !== this.redemptionInput) { 2670 if (vm.col1 === "amount" && vm.effectiveRedemptionAmount != null) { 2671 this.redemptionInput.value = vm.formatBareCurrency( 2672 vm.effectiveRedemptionAmount, 2673 ); 2674 } else if (vm.col1 === "points" && vm.effectiveRedemptionPts != null) { 2675 this.redemptionInput.value = "" + vm.effectiveRedemptionPts; 2676 } 2677 } 2678 this.redeemButton.textContent = (this.redemptionPending ? vm.localize(".message.redeeming.string") : vm.redeemLabel); 2679 this.redeemButton.disabled = this.redemptionPending || !vm.isRedeemEnabled; 2680 2681 this.redemptionInput.setCustomValidity(vm.amountError || ""); 2682 2683 if (this.redemptionPending) { 2684 this._renderStatusMessage(""); 2685 } else if (vm.redeemDisabledMessage) { 2686 this._renderStatusMessage(vm.redeemDisabledMessage); 2687 } else if (this.redemptionError) { 2688 this._renderStatusMessage(this.redemptionError); 2689 } else { 2690 this._renderStatusMessage(""); 2691 } 2692 this.setRootPanelVisible(isRootVisible); 2693 } 2694 2695 redeem() { 2696 if (this.redemptionPending) { 2697 return; 2698 } 2699 this.redemptionPending = true; 2700 this.redemptionError = null; 2701 this.render(); 2702 2703 this._redeemAsync() 2704 .then(() => { 2705 if (this.redemptionData.useAdvancedIntegration) { 2706 this.redemptionPending = false; 2707 } else { 2708 // don't mark as done -- we're navigating away 2709 } 2710 this.redemptionError = null; 2711 this.render(); 2712 }) 2713 .catch((error) => { 2714 reportError(error, "redeem"); 2715 this.redemptionPending = false; 2716 this.redemptionError = error.toString(); 2717 this.render(); 2718 }); 2719 } 2720 2721 applyClass(el, ...args) { 2722 if (!el) { 2723 return; 2724 } 2725 for (let arg of args) { 2726 if (typeof arg === 'string') { 2727 this._toggleSingleClass(el, arg, true); 2728 } else if (typeof arg === 'object') { 2729 for (let [shortClassName, isOn] of Object.entries(arg)) { 2730 this._toggleSingleClass(el, shortClassName, isOn); 2731 } 2732 } 2733 } 2734 } 2735 2736 setRootPanelVisible(isVisible) { 2737 this.rootPanel.classList.toggle("hidden", !isVisible); 2738 if (isVisible) { 2739 if (this.stylesheetReady) { 2740 this.rootPanel.style.removeProperty("display"); 2741 } else { 2742 this.rootPanel.style.setProperty("display", "none"); 2743 } 2744 } else { 2745 this.rootPanel.style.setProperty("display", "none", "important"); 2746 } 2747 } 2748 2749 _toggleSingleClass(el, shortClassName, isOn) { 2750 if (UTILITY_CLASSES[shortClassName]) { 2751 el.classList.toggle(shortClassName, isOn); 2752 } else if (this.vm.data) { 2753 let className = this.vm.classes[shortClassName]; 2754 if (!className) { 2755 throw new Error(`missing theme class ${shortClassName}`); 2756 } 2757 el.classList.toggle(className, isOn); 2758 } 2759 } 2760 2761 embedChildren(el, stringKey, childrenKeys) { 2762 if (!el) { 2763 return; 2764 } 2765 parseStringTemplate( 2766 this.vm.resolveValue(stringKey), 2767 (staticStr) => { 2768 el.append(h("span", {}, staticStr)); 2769 }, 2770 (fieldKey) => { 2771 let child = childrenKeys[fieldKey]; 2772 if (typeof child === "undefined") { 2773 el.append(`{{!.${fieldKey}!}}`); 2774 } else { 2775 if (typeof child === "function") { 2776 child = child(); 2777 } 2778 appendChild(el, child); 2779 } 2780 }, 2781 ); 2782 assignElementRefs(this, el); 2783 } 2784 2785 async _redeemAsync() { 2786 if (this.vm.effectiveRedemptionAmount == null) { 2787 throw new Error("Invalid amount"); 2788 } 2789 if (this.vm.effectiveRedemptionAmount == 0) { 2790 if (!this.redemptionData.useAdvancedIntegration) { 2791 window.location.href = `/checkout`; 2792 } 2793 return; 2794 } 2795 let input = { 2796 amount: `${this.vm.effectiveRedemptionAmount.toFixed(6)}`, 2797 canRemove: true, 2798 ...this._collectCheckoutContext(), 2799 }; 2800 let output = await call("RedeemCheckoutReward1", input); 2801 if (Bubblehouse.verbose) 2802 console.log( 2803 `[Bubblehouse] <${this.tagName}> redeem input = ${JSON.stringify(input)}, output = ${JSON.stringify(output)}`, 2804 ); 2805 2806 if (output.error) { 2807 throw new Error(output.message || output.error); 2808 } 2809 2810 if (output.newStatus) { 2811 this.statusGeneration++; 2812 this.redemptionData = output.newStatus; 2813 } 2814 2815 if (output.type === "discount_code" || output.type === "gift_card") { 2816 await this._addDiscountCode({ type: output.type, code: output.code }, output.removeCodes); 2817 } else { 2818 throw new Error( 2819 `Unknown type of reward: ${output.type} (full response: ${JSON.stringify(output)})`, 2820 ); 2821 } 2822 this.desiredRedemptionAmountOrPoints = null; 2823 2824 this.reloadCart(); 2825 } 2826 2827 async _addDiscountCode(coupon, removeCodes) { 2828 if (isShopifyStorefrontApiAvailable()) { 2829 await addDiscountCode(this.cartData.token, coupon.code, removeCodes); 2830 this._sendToDestination(null); 2831 } else { 2832 this._sendToDestination(coupon); 2833 } 2834 } 2835 2836 _sendToDestination(coupon) { 2837 this.dispatchEvent( 2838 new CustomEvent("bubblehouse:cart-updated", { 2839 detail: {}, 2840 bubbles: true, 2841 }), 2842 ); 2843 if (this.redemptionData.useAdvancedIntegration) { 2844 return; 2845 } 2846 let destination = this.redemptionData.sendToCart ? "/cart" : "/checkout"; 2847 if (!coupon) { 2848 window.location.href = destination; 2849 } else if (coupon.type === "discount_code" && this.redemptionData.useShopifyDiscountPath) { 2850 window.location.href = `/discount/${encodeURIComponent(coupon.code)}?redirect=${destination}`; 2851 } else { 2852 window.location.href = `${destination}?discount=${encodeURIComponent(coupon.code)}`; 2853 } 2854 } 2855 2856 _onRedemptionAmountChange(ev) { 2857 let amount = parseFloat(this.redemptionInput.value || ""); 2858 if (isNaN(amount) || !isFinite(amount)) { 2859 amount = NaN; // null would mean default; NaN means custom but invalid 2860 } else { 2861 amount = this.vm.convertPointsToAmount( 2862 Math.ceil(this.vm.convertAmountToPoints(amount)), 2863 ); 2864 } 2865 this.desiredRedemptionAmountOrPoints = amount; 2866 this.render(); 2867 this.redemptionInput.reportValidity(); 2868 } 2869 2870 _onRedemptionPointsChange(ev) { 2871 let pts = parseInt(this.redemptionInput.value || "", 10); 2872 this.desiredRedemptionAmountOrPoints = pts; 2873 this.render(); 2874 this.redemptionInput.reportValidity(); 2875 } 2876 2877 _renderStatusMessage(message, actions = null) { 2878 if (message) { 2879 this.statusPanel.classList.remove("hidden"); 2880 if (actions) { 2881 applyContentWithActions(this.statusMessageSpan, message, actions); 2882 } else { 2883 this.statusMessageSpan.textContent = message; 2884 } 2885 } else { 2886 this.statusPanel.classList.add("hidden"); 2887 this.statusMessageSpan.textContent = ""; 2888 } 2889 } 2890 2891 _onCartChanged(cartData, cartAction) { 2892 this.cartGeneration++; 2893 this.cartData = cartData; 2894 this.reloadStatus("cart_updated"); 2895 this.render(); 2896 } 2897 2898 _onCartLoaded(cartData, generation) { 2899 if (generation !== this.cartGeneration) { 2900 return; 2901 } 2902 this.cartData = cartData; 2903 this.reloadStatus("cart_loaded"); 2904 this.render(); 2905 } 2906 _onCartLoadingFailed(error, generation) { 2907 if (generation !== this.cartGeneration) { 2908 return; 2909 } 2910 reportWarning(error, "cannot load cart"); 2911 this.render(); 2912 } 2913 2914 _onWidgetDataLoaded(redemptionData, generation) { 2915 if (generation !== this.statusGeneration) { 2916 return; 2917 } 2918 this.redemptionData = redemptionData; 2919 this.render(); 2920 } 2921 _onWidgetDataLoadingFailed(error, generation) { 2922 if (generation !== this.statusGeneration) { 2923 return; 2924 } 2925 reportWarning(error, "cannot load redemption data"); 2926 this.render(); 2927 } 2928} 2929 2930function applyContentWithActions(el, contentStr, actions) { 2931 let validTags = Object.keys(actions).map((k) => 'bhaction:' + k); 2932 let root = parseTags(contentStr, { validTags }); 2933 console.log(`[Bubblehouse] applyContentWithActions: str = ${JSON.stringify(contentStr)}, actions = ${JSON.stringify(actions)}, content = ${JSON.stringify(root, null, 2)}`) 2934 2935 while (el.firstChild) { 2936 el.removeChild(el.firstChild); 2937 } 2938 2939 appendRenderTagContent(el, root, actions); 2940} 2941
2942function appendRenderTagContent(el, content, actions) { 2943 for (let item of content) { 2944 appendRenderTagItem(el, item, actions); 2945 } 2946} 2947 2948function appendRenderTagItem(el, item, actions) { 2949 if (typeof item === 'string') { 2950 el.appendChild(document.createTextNode(item)); 2951 } else { 2952 let actionName = item.tag.replace(/^bhaction:/, '') 2953 let action = actions[actionName] 2954 if (!action) { 2955 el.appendChild(document.createTextNode('(!TAG!)')); 2956 appendRenderTagContent(el, item.body, actions); 2957 return 2958 } 2959 let actionEl = document.createElement('a'); 2960 if (action.jscall) { 2961 actionEl.addEventListener('click', (ev) => { 2962 if (runActionJSCall(item.tag, action.jscall)) { 2963 ev.preventDefault(); 2964 } 2965 }); 2966 } 2967 actionEl.setAttribute('part', 'actionlink'); 2968 if (action.nav) { 2969 actionEl.href = action.nav.url; 2970 let target = actionNavigationTarget(action.nav.target); 2971 if (target) { 2972 actionEl.target = target; 2973 } 2974 } 2975 el.appendChild(actionEl); 2976 appendRenderTagContent(actionEl, item.body, actions); 2977 } 2978} 2979 2980function runActionJSCall(tag, call) { 2981 let name = call.func; 2982 let func = window[name]; 2983 if (typeof func !== "function") { 2984 console.error(`[Bubblehouse] <${tag}>: global function ${name} not found`); 2985 return false; 2986 } 2987 try { 2988 func(); 2989 return true; 2990 } catch (error) { 2991 reportError(error, `<${tag}>`); 2992 return false; 2993 } 2994} 2995 2996function actionNavigationTarget(target) { 2997 switch (target) { 2998 case "new": 2999 case "_blank": 3000 return "_blank"; 3001 case "top": 3002 case "_top": 3003 return "_top"; 3004 case "same": 3005 case "": 3006 case null: 3007 case undefined: 3008 return ""; 3009 default: 3010 console.error(`[Bubblehouse] unsupported action navigation target ${target}`); 3011 return ""; 3012 } 3013} 3014 3015customElements.define("bubblehouse-redeem-panel", BubblehouseRedeemPanel); 3016})() || true; 3017 3018/* variantChangeEvent.js */ 3019;(function() { 3020if (isShopifyProductPage()) { 3021 window.addEventListener("load", () => { 3022 observeElements('[name="id"]', observeAndDispatchVariantChange) 3023 }) 3024} 3025 3026function observeAndDispatchVariantChange(input) { 3027 let observer = new MutationObserver((mutations) => { 3028 for (const mutation of mutations) { 3029 if (mutation.type === 'attributes' && mutation.attributeName === 'value') { 3030 window.dispatchEvent( 3031 new CustomEvent('bubblehouse:variant-change', { detail: { variantId: mutation.target.value } }) 3032 ) 3033 } 3034 } 3035 }) 3036 3037 observer.observe(input, { 3038 attributes: true, 3039 attributeFilter: ['value'], 3040 }) 3041} 3042})() || true; 3043 3044/* bigcommerce/api.js */ 3045;(function() { 3046window.Bubblehouse.BigCommerce ??= {}; 3047window.Bubblehouse.BigCommerce.getToken = getToken; 3048window.Bubblehouse.BigCommerce.loadCart = loadCart; 3049window.Bubblehouse.BigCommerce.createCart = createCart; 3050window.Bubblehouse.BigCommerce.addToCart = addToCart; 3051window.Bubblehouse.BigCommerce.updateCartItem = updateCartItem; 3052window.Bubblehouse.BigCommerce.deleteCartItem = deleteCartItem; 3053window.Bubblehouse.BigCommerce.deleteCart = deleteCart; 3054window.Bubblehouse.BigCommerce.getCheckout = getCheckout; 3055window.Bubblehouse.BigCommerce.applyCouponToCheckout = applyCouponToCheckout; 3056 3057async function getToken(appClientId) { 3058 return fetch(`/customer/current.jwt?app_client_id=${appClientId}`).then( 3059 (response) => response.text()); 3060} 3061 3062function loadCart() { 3063 return fetch("/api/storefront/carts?include=lineItems.physicalItems.options,lineItems.digitalItems.options", { 3064 method: "GET", 3065 credentials: "same-origin", 3066 }).then(wantSuccessfulJson); 3067} 3068 3069function createCart(lineItems) { 3070 let body = { lineItems }; 3071 console.log("[BH] [DEBUG] {BigCommerce} creating BigCommerce cart:", body); 3072 return fetch("/api/storefront/carts", { 3073 method: "POST", 3074 credentials: "same-origin", 3075 headers: { "Content-Type": "application/json" }, 3076 body: JSON.stringify(body), 3077 }).then(wantSuccessfulJson); 3078} 3079 3080function addToCart(cartId, lineItems, { origin } = {}) { 3081 let body = { lineItems }; 3082 console.log(`[BH] [DEBUG] {BigCommerce} adding to BigCommerce cart (${origin}):`, body); 3083 return fetch(`/api/storefront/carts/${cartId}/items`, { 3084 method: "POST", 3085 credentials: "same-origin", 3086 headers: { "Content-Type": "application/json" }, 3087 body: JSON.stringify(body), 3088 }).then(wantSuccessfulJson); 3089} 3090 3091function updateCartItem(cartId, itemId, lineItem, { origin } = {}) { 3092 let body = { lineItem }; 3093 console.log(`[BH] [DEBUG] {BigCommerce} updating BigCommerce cart item (${origin}):`, body); 3094 return fetch(`/api/storefront/carts/${cartId}/items/${itemId}`, { 3095 method: "PUT", 3096 credentials: "same-origin", 3097 headers: { "Content-Type": "application/json" }, 3098 body: JSON.stringify(body), 3099 }).then(wantSuccessfulJson); 3100} 3101 3102function deleteCartItem(cartId, itemId, { origin } = {}) { 3103 console.log(`[BH] [DEBUG] {BigCommerce} deleting BigCommerce cart item (${origin}):`, itemId); 3104 return fetch(`/api/storefront/carts/${cartId}/items/${itemId}`, { 3105 method: "DELETE", 3106 credentials: "same-origin", 3107 }).then(wantSuccessfulJson); 3108} 3109 3110function deleteCart(cartId, { origin } = {}) { 3111 console.log(`[BH] [DEBUG] {BigCommerce} deleting BigCommerce cart (${origin}):`, cartId); 3112 return fetch(`/api/storefront/carts/${cartId}`, { 3113 method: "DELETE", 3114 credentials: "same-origin", 3115 }).then(wantSuccessfulJson); 3116} 3117 3118async function getCheckout(checkoutId) { 3119 return fetch(`/api/storefront/checkouts/${checkoutId}`, { 3120 method: "GET", 3121 credentials: "same-origin", 3122 headers: { "Content-Type": "application/json" }, 3123 }).then(wantSuccessfulJson); 3124} 3125 3126async function applyCouponToCheckout(checkoutId, couponCode) { 3127 console.log(`[BH] [DEBUG] {BigCommerce}
3127 applying coupon to BigCommerce checkout: ${couponCode}`); 3128 let body = { couponCode }; 3129 return fetch(`/api/storefront/checkouts/${checkoutId}/coupons`, { 3130 method: "POST", 3131 credentials: "same-origin", 3132 headers: { "Content-Type": "application/json" }, 3133 body: JSON.stringify(body), 3134 }).then(wantSuccessfulJson); 3135} 3136})() || true; 3137 3138/* woocommerce/api.js */ 3139window.Bubblehouse ??= {}; 3140window.Bubblehouse.WooCommerce ??= {}; 3141 3142window.Bubblehouse.WooCommerce.loadCart = async function() { 3143 return await fetch(`/wp-json/wc/store/v1/cart`, { 3144 credentials: 'include', 3145 }) 3146 .then(async (response) => ({ 3147 ...await response.json(), 3148 token: response.headers.get("cart-token"), 3149 })) 3150 .catch((err) => { 3151 console.error(`[BH] {WooCommerce} Unable to load cart: ${err}`); 3152 return null; 3153 }); 3154} 3155 3156window.Bubblehouse.WooCommerce.applyCoupon = async function(_token, code) { 3157 // NOTE (@seletskiy): Detect version of checkout used (older vs newer WooCommerce). 3158 // 'bh-discounts' namespace and handler is defined in bubblehouse-woocomm PHP extension. 3159 var isBlockCheckout = ( 3160 typeof window.wc !== 'undefined' && 3161 window.wc.blocksCheckout && 3162 typeof window.wc.blocksCheckout.extensionCartUpdate === 'function' 3163 ); 3164 3165 if (isBlockCheckout) { 3166 try { 3167 await window.wc.blocksCheckout.extensionCartUpdate({ 3168 namespace: 'bh-discounts', 3169 data: { coupon_code: code }, 3170 }); 3171 3172 console.log(`[BH] [DEBUG] {applyCoupon} Block Checkout - coupon applied`); 3173 } catch (err) { 3174 console.log(`[BH] [DEBUG] {applyCoupon} Block Checkout - error: ${err}`); 3175 }; 3176 } else { 3177 jQuery.ajax({ 3178 type: 'POST', 3179 url: woocommerce_params.ajax_url, 3180 data: {
3181 action: 'bh_apply_coupon', 3182 coupon_code: code, 3183 }, 3184 success: () => { 3185 $(document.body).trigger('update_checkout'); 3186 console.log(`[BH] [DEBUG] {applyCoupon} Classic Checkout - coupon applied`) 3187 }, 3188 error: (err) => { 3189 console.log(`[BH] [DEBUG] {applyCoupon} Classic Checkout - error: ${err}`) 3190 }, 3191 }); 3192 } 3193} 3194 3195window.Bubblehouse.WooCommerce.removeCoupon = async function(token, code) { 3196 return await fetch(`/wp-json/wc/store/v1/cart/coupons/${code}`, { 3197 method: 'DELETE', 3198 credentials: 'include', 3199 headers: { 3200 'Content-Type': 'application/json', 3201 'Cart-Token': token, 3202 }, 3203 }) 3204 .then((response) => response.json()) 3205 .catch((err) => { 3206 console.error(`[BH] {WooCommerce} Unable to remove coupon: ${err}`); 3207 return null; 3208 }); 3209} 3210 3211window.Bubblehouse.WooCommerce.removeAllCoupons = async function(token) { 3212 return await fetch(`/wp-json/wc/store/v1/cart/coupons`, { 3213 method: 'DELETE', 3214 credentials: 'include', 3215 headers: { 3216 'Content-Type': 'application/json', 3217 'Cart-Token': token, 3218 }, 3219 }) 3220 .then((response) => response.json()) 3221 .catch((err) => { 3222 console.error(`[BH] {WooCommerce} Unable to remove all coupons: ${err}`); 3223 return null; 3224 }); 3225} 3226 3227window.Bubblehouse.WooCommerce.addItem = async function(token, productId, quantity) { 3228 return await fetch(`/wp-json/wc/store/v1/cart/add-item`, { 3229 method: 'POST', 3230 credentials: 'include', 3231 headers: { 3232 'Content-Type': 'application/json', 3233 'Cart-Token': token, 3234 }, 3235 body: JSON.stringify({ id: productId, quantity }), 3236 }) 3237 .then((response) => response.json()) 3238 .catch((err) => { 3239 console.error(`[BH] {WooCommerce} Unable to add item: ${err}`); 3240 return null; 3241 }); 3242} 3243 3244window.Bubblehouse.WooCommerce.updateItem = async function(token, key, quantity) { 3245 return await fetch(`/wp-json/wc/store/v1/cart/update-item`, { 3246 method: 'POST', 3247 credentials: 'include', 3248 headers: { 3249 'Content-Type': 'application/json', 3250 'Cart-Token': token, 3251 }, 3252 body: JSON.stringify({ key, quantity }), 3253 }) 3254 .then((response) => response.json()) 3255 .catch((err) => { 3256 console.error(`[BH] {WooCommerce} Unable to update item: ${err}`); 3257 return null; 3258 }); 3259} 3260 3261 3262/* cart.js */ 3263;(function() { 3264function CartApi() { 3265 const providers = { 3266 BigCommerce: { 3267 load: async () => { 3268 const carts = await Bubblehouse.BigCommerce.loadCart() 3269 if (!carts) { 3270 console.error(`[BH] {CartApi/BigCommerce} Unable to load carts.`); 3271 return null; 3272 } 3273 3274 if (carts?.length < 1) { 3275 console.warn(`[BH] {CartApi/BigCommerce} No active carts found.`); 3276 return { id: null, items: [] }; 3277 } 3278 3279 const cart = carts[0]; 3280 3281 return { 3282 id: cart.id, 3283 items: [ 3284 ...cart.lineItems.physicalItems.map((item) => ({ 3285 id: item.id, 3286 variant: { id: item.variantId }, 3287 product: { id: item.productId }, 3288 })), 3289 ] 3290 } 3291 }, 3292 3293 add: async (cartId, { products, discount }, opts) => { 3294 if (products) { 3295 const items = products.map(({ product, quantity, variant, options }) => ({ 3296 productId: product?.id, 3297 variantId: variant?.id, 3298 quantity, 3299 optionSelections: options 3300 ? Object.entries(options).map(([k, v]) => ({ optionId: k, optionValue: v })) 3301 : [], 3302 })); 3303 3304 if (cartId) { 3305 await Bubblehouse.BigCommerce.addToCart(cartId, items, opts); 3306 } else { 3307 const { id } = await Bubblehouse.BigCommerce.createCart(items); 3308 cartId = id; 3309 } 3310 } 3311 3312 if (discount) { 3313 await Bubblehouse.BigCommerce.applyCouponToCheckout(cartId, discount.code) 3314 } 3315 }, 3316 3317 update: async (cartId, { items }, opts) => { 3318 return await Promise.all(items.map(({ id, variant, product, quantity }) => 3319 Bubblehouse.BigCommerce.updateCartItem( 3320 cartId, 3321 id, 3322 { 3323 productId: product?.id, 3324 variantId: variant?.id, 3325 quantity, 3326 }, 3327 opts, 3328 ), 3329 )) 3330 }, 3331 }, 3332 3333 Shopify: { 3334 load: async () => { 3335 const cart = await Bubblehouse.loadCart(); 3336 if (!cart) { 3337 console.error(`[BH] {CartApi/Shopify} Unable to load cart.`); 3338 return null; 3339 } 3340 3341 return { 3342 id: cart.token, 3343 items: cart.items.map((item) => ({ 3344 id: item.key, 3345 variant: { id: item.variant_id }, 3346 product: { id: item.product_id }, 3347 })) 3348 } 3349 }, 3350 3351 add: async (cartId, { products, discount }, opts) => { 3352 if (products) { 3353 const items = products.map(({ variant, quantity }) => ({ 3354 id: variant?.id, 3355 quantity, 3356 })); 3357 3358 await Bubblehouse.addToCart(items, opts); 3359 } 3360 3361 if (discount) { 3362 await Bubblehouse.addDiscountCode(cartId, discount.code); 3363 } 3364 }, 3365 3366 update: async (_cartId, { items }, opts) => { 3367 const updates = {};
3368 items.forEach(({ id, quantity }) => { 3369 updates[id] = quantity; 3370 }); 3371 3372 return await Bubblehouse.updateCart(updates, opts); 3373 }, 3374 }, 3375 3376 WooCommerce: { 3377 load: async () => { 3378 const cart = await Bubblehouse.WooCommerce.loadCart(); 3379 if (!cart) { 3380 console.error(`[BH] {CartApi/WooCommerce} Unable to load cart.`); 3381 return null; 3382 } 3383 3384 return { 3385 id: cart.token, 3386 items: cart.items?.map((item) => ({ 3387 id: item.key, 3388 variant: { id: item.id?.toString() }, 3389 product: { id: item.id?.toString() }, 3390 })) ?? [] 3391 } 3392 }, 3393 3394 add: async (token, { products, discount }, _opts) => { 3395 if (products) { 3396 for (const { product, quantity } of products) { 3397 await Bubblehouse.WooCommerce.addItem(token, product?.id, quantity); 3398 } 3399 } 3400 3401 if (discount) { 3402 await Bubblehouse.WooCommerce.applyCoupon(token, discount.code); 3403 } 3404 }, 3405 3406 update: async (token, { items }, _opts) => { 3407 for (const { id, quantity } of items) { 3408 await Bubblehouse.WooCommerce.updateItem(token, id, quantity); 3409 } 3410 }, 3411 } 3412 } 3413 3414 const isBigCommerce = window.BCData !== undefined; 3415 const isShopify = window.Shopify !== undefined; 3416 const isWooCommerce = window.wc !== undefined; 3417 3418 if (isBigCommerce) { 3419 console.log("[BH] {CartApi} Using BigCommerce.") 3420 return providers.BigCommerce; 3421 } 3422 3423 if (isWooCommerce) { 3424 console.log("[BH] {CartApi} Using WooCommerce.") 3425 return providers.WooCommerce; 3426 } 3427 3428 if (isShopify) { 3429 console.log("[BH] {CartApi} Using Shopify.") 3430 return providers.Shopify; 3431 } 3432 3433 const noop = () => { 3434 console.log("[BH] {CartApi} Not supported.") 3435 } 3436 3437 return { load: noop, add: noop, update: noop }; 3438} 3439 3440window.Bubblehouse.CartApi = CartApi; 3441})() || true; 3442 3443/* outerFlash.js */ 3444function showFlashMessage(message) { 3445 if (isPopupAvailable('message')) { 3446 openPopup('message', { message }) 3447 } else { 3448 let text = message.t 3449 if (message.m === 'failure') { 3450 text = 'Error: ' + text 3451 } 3452 alert(text) 3453 } 3454} 3455 3456 3457/* outerApi.js */ 3458;(function() { 3459async function handleApplyDiscountCode1(ev) { 3460 const code = ev.detail.code; 3461 if (!code) { 3462 return; 3463 } 3464 3465 const api = window.Bubblehouse.CartApi(); 3466 try { 3467 const cart = await api.load(); 3468 if (!cart) { 3469 console.log("[BH] {ApplyDiscountCode1} No cart info available.") 3470 return; 3471 } 3472 3473 await api.add( 3474 cart.id, 3475 { 3476 products: null, 3477 discount: { code }, 3478 }, 3479 { origin: "ApplyDiscountCode1" } 3480 ); 3481 } catch (error) { 3482 cartErrorShow(error, code); 3483 } 3484} 3485 3486async function handleAddProductToCart1(ev) { 3487 const { 3488 variantId, productId, options, discountCode, quantity, 3489 successMessage, cartUrl, refreshOnAdd, ignoreIfAlreadyAdded, 3490 } = ev.detail; 3491 const api = window.Bubblehouse.CartApi(); 3492 3493 try { 3494 const cart = await api.load(); 3495 if (!cart) { 3496 console.log("[BH] {AddProductToCart1} No cart info available.") 3497 return; 3498 } 3499 3500 const alreadyAdded = ignoreIfAlreadyAdded && cart.items.some(({ variant }) => variant?.id == variantId); 3501 if (alreadyAdded && !discountCode) { 3502 console.log(`[BH] {AddProductToCart1} Already got product variant in cart.`) 3503 return; 3504 } 3505 3506 await api.add( 3507 cart.id, 3508 { 3509 products: alreadyAdded ? null : [{ 3510 variant: { id: variantId }, 3511 quantity, 3512 ...(productId ? { product: { id: productId } } : {}), 3513 options, 3514 }], 3515 discount: discountCode ? { code: discountCode } : null, 3516 }, 3517 { origin: "AddProductToCart1" } 3518 ); 3519 } catch (error) { 3520 cartErrorShow(error, discountCode); 3521 return; 3522 } 3523 3524 if (successMessage) { 3525 showFlashMessage({ t: successMessage }) 3526 if (refreshOnAdd) { 3527 setTimeout(() => { window.location.reload(); }, 1000) 3528 } 3529 } else { 3530 if (cartUrl !== null) { 3531 navigateToCartOrReloadPage(cartUrl) 3532 } 3533 } 3534} 3535 3536function cartErrorShow(error, code) { 3537 console.error("[BH] Unable to update cart:", error); 3538 const message = (code ? Bubblehouse.data?.cartCouponErrorMessage : Bubblehouse.data?.cartErrorMessage) || 3539 error?.message || String(error);
3540 showFlashMessage({ m: 'failure', t: [message, code].filter(Boolean).join('\n') }); 3541} 3542 3543document.addEventListener('bhclient:AddProductToCart1', handleAddProductToCart1); 3544document.addEventListener('bhclient:ApplyDiscountCode1', handleApplyDiscountCode1); 3545})() || true; 3546 3547/* resizer.js */ 3548document.addEventListener('bhclient:Resize1', function (ev) { 3549 ev.target.style.height = ev.detail.height + 'px'; 3550}); 3551 3552 3553/* outerMessage.js */ 3554document.addEventListener('bhclient:PostOuterMessage1', handlePostOuterMessage1); 3555 3556function handlePostOuterMessage1(ev) { 3557 window.postMessage({ event: ev.detail.event, data: ev.detail.data }, '*') 3558} 3559 3560 3561/* tags/bubblehouse-value.js */ 3562;(function() { 3563const VALUES = { 3564 'points': () => Bubblehouse.data.userPointBalanceStr, 3565 'points-value': () => Bubblehouse.data.userPointBalanceValueStr, 3566 'tier': () => Bubblehouse.data.userTierName, 3567} 3568 3569// <bubblehouse-value key="..."> displays the specified loyalty value (properly formatted for the current customer). 3570class BubblehouseValue extends HTMLElement { 3571 constructor() { 3572 super(); 3573 const shadow = this.attachShadow({ mode: 'open' }); 3574 this.valueSpan = document.createElement('span'); 3575 shadow.appendChild(this.valueSpan); 3576 } 3577 3578 connectedCallback() { 3579 this.render(); 3580 } 3581 3582 render() { 3583 let key = this.getAttribute('key') 3584 if (!key) { 3585 key = this.getAttribute('altkey') // don't ask, it's an attempt to debug a problem with Wypeuk 3586 } 3587 let func = VALUES[key] 3588 if (!func) { 3589 console.error(`[Bubblehouse] <bubblehouse-value> has invalid key=${JSON.stringify(key)}`) 3590 } 3591 let value = (func ? func() : 'invalid-value-key') 3592 let fallback = this.getAttribute('fallback') 3593 let text 3594 if (value == null || value === '') { 3595 text = fallback || '' 3596 } else if (value == 0 && fallback != null) { 3597 text = fallback || '' 3598 } else { 3599 text = '' + value 3600 } 3601 this.valueSpan.textContent = text 3602 if (Bubblehouse.verbose) console.log(`[Bubblehouse] rendered <bubblehouse-value key="${key}">`) 3603 } 3604} 3605 3606customElements.define('bubblehouse-value', BubblehouseValue); 3607})() || true; 3608 3609/* tags/bubblehouse-panel.js */ 3610;(function() { 3611// <bubblehouse-panel> unhides itself after Bubblehouse gets loaded. 3612class BubblehousePanel extends HTMLElement { 3613 connectedCallback() { 3614 adjustPanelVisibility(this) 3615 } 3616} 3617 3618customElements.define('bubblehouse-panel', BubblehousePanel); 3619})() || true; 3620 3621/* tags/bubblehouse-block.js */ 3622;(function() { 3623// <bubblehouse-block> inserts the given Bubblehouse page/block. 3624class BubblehouseBlock extends HTMLElement { 3625 constructor() { 3626 super() 3627 this.attachShadow({ mode: 'open' }) 3628 this._onMessage = this._onMessage.bind(this) 3629 } 3630 3631 connectedCallback() { 3632 this._initialize(); 3633 if (!adjustPanelVisibility(this)) return; 3634 window.addEventListener('message', this._onMessage) 3635 } 3636 3637 disconnectedCallback() { 3638 window.removeEventListener('message', this._onMessage) 3639 } 3640 3641 _initialize() { 3642 if (this._initialized) { 3643 return 3644 } 3645 this._initialized = true; 3646 3647 let blockName = this.getAttribute('block') 3648 let suffix = this.getAttribute('suffix') || '' 3649 let height = this.getAttribute('height') || '0' 3650 let shopSlug = this.getAttribute('shop') 3651 let auth = this.getAttribute('auth') 3652 let title = (this.getAttribute('title') || '').trim() 3653 if (!blockName) { 3654 throw new Error('<bubblehouse-block>: block name must be specified (e.g. block="rewards")') 3655 } 3656 let encodedBlockName = encodeBubblehouseBlockPathSegment(blockName, 'block name') 3657 let instance = `bubblehouse-embedded-${blockName}${suffix}` 3658 let url 3659 if (!shopSlug && window.Shopify) { 3660 shopSlug = window.Shopify.shop.replace(/\.myshopify\.com$/, '') 3661 if (shopSlug) { 3662 shopSlug = 'shopify-' + shopSlug 3663 } 3664 auth = auth || Bubblehouse.data.authToken || '' 3665 } 3666 if (window.Shopify && !shopSlug) { 3667 url = new URL(`/apps/bubblehouse/${encodedBlockName}/content/`, window.location.origin) 3668 } else { 3669 if (!shopSlug) {
3670 throw new Error('<bubblehouse-block>: shop slug must be specified (e.g. shop="my-shop")') 3671 } 3672 if (auth == null) { 3673 throw new Error('<bubblehouse-block>: auth must be specified (as either an empty string or a JWT token, e.g. auth="" or auth="...")') 3674 } 3675 let encodedShopSlug = encodeBubblehouseBlockPathSegment(shopSlug, 'shop slug') 3676 url = new URL(`https://app.bubblehouse.com/s/${encodedShopSlug}/blocks/${encodedBlockName}`) 3677 } 3678 if (!this._canUseBubblehouseAuth(url)) { 3679 throw new Error('<bubblehouse-block>: invalid Bubblehouse iframe URL') 3680 } 3681 let shouldFetchAuthTokenForBlock = Boolean(Bubblehouse.data.fetchAuthTokenForBlock) && !auth 3682 if (!shouldFetchAuthTokenForBlock) { 3683 url.searchParams.set('auth', auth || '') 3684 } 3685 url.searchParams.set('instance', instance) 3686 for (let [name, value] of Object.entries(collectCurrentMarketQueryParams())) { 3687 url.searchParams.set(name, value) 3688 } 3689 3690 this.iframe = h('iframe', { 3691 src: shouldFetchAuthTokenForBlock ? null : url.toString(), 3692 style: { 3693 width: '100%', 3694 border: 'none', 3695 height: height + 'px', 3696 }, 3697 title: title || null, 3698 sandbox: 'allow-top-navigation allow-scripts allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-same-origin', 3699 allow: 'clipboard-write', 3700 }) 3701 this.shadowRoot.append(this.iframe) 3702 if (shouldFetchAuthTokenForBlock) { 3703 this._shopifyProxyAuthTokenPromise = this._loadWithShopifyProxyAuthToken(url) 3704 } 3705 } 3706 3707 applyBubblehouseAuthToken(token) { 3708 if (!token || !this.iframe) { 3709 return false 3710 } 3711 let url 3712 try { 3713 url = new URL(this.iframe.src, window.location.href) 3714 } catch { 3715 return false 3716 } 3717 if (url.searchParams.get('auth')) { 3718 return false 3719 } 3720 if (!this._canUseBubblehouseAuth(url)) { 3721 return false 3722 } 3723 url.searchParams.set('auth', token) 3724 this.iframe.src = url.toString() 3725 return true 3726 } 3727 3728 async _loadWithShopifyProxyAuthToken(url) { 3729 let token = await fetchShopifyProxyAuthToken() 3730 if (token) { 3731 Bubblehouse.data.authToken = token 3732 url.searchParams.set('auth', token) 3733 } else { 3734 url.searchParams.set('auth', '') 3735 } 3736 if (this.iframe) { 3737 this.iframe.src = url.toString() 3738 } 3739 } 3740 3741 _canUseBubblehouseAuth(url) { 3742 return isBubblehouseAuthFrameURL(url, 'https://app.bubblehouse.com', window.location.origin) 3743 } 3744 3745 _onMessage(ev) { 3746 let data = ev.data 3747 if (!data || ev.source !== this.iframe.contentWindow) { 3748 return 3749 } 3750 if (data.bubblehouseBlockClientCall === 'Scroll1') { 3751 this.iframe.dispatchEvent(new CustomEvent('bhclient:Scroll1', { 3752 detail: Object.assign({}, data, { sourceWindow: ev.source, sourceOrigin: ev.origin }), 3753 bubbles: true, 3754 composed: true, 3755 })) 3756 return 3757 } 3758 if (data.bubblehouseBlockClientCall !== 'Resize1') { 3759 return 3760 } 3761 if (data.height) { 3762 this.iframe.style.height = data.height + 'px' 3763 } 3764 if (!this.iframe.title && typeof data.title === 'string') { 3765 let title = data.title.trim() 3766 if (title) { 3767 this.iframe.title = title 3768 } 3769 } 3770 } 3771} 3772 3773function encodeBubblehouseBlockPathSegment(value, name) { 3774 if (!/^[A-Za-z0-9_-]+$/.test(value)) { 3775 throw new Error(`<bubblehouse-block>: ${name} must contain only letters, numbers, underscores, or dashes`) 3776 } 3777 return encodeURIComponent(value) 3778} 3779 3780async function fetchShopifyProxyAuthToken() { 3781 try { 3782 let response = await fetch('/apps/bubblehouse/customer-auth-token', { 3783 credentials: 'include', 3784 headers: { 3785 Accept: 'application/json', 3786 }, 3787 }) 3788 if (!response.ok) { 3789 return '' 3790 } 3791 let data = await response.json() 3792 return typeof data.token === 'string' ? data.token : '' 3793 } catch { 3794 return '' 3795 } 3796} 3797
3798customElements.define('bubblehouse-block', BubblehouseBlock); 3799})() || true; 3800 3801/* toc-scroll.js */ 3802async function handleScroll1(ev) { 3803 let scrollEl = null; 3804 let gap = 16; 3805 3806 let ionContent = ev.target.closest('ion-content') || document.querySelector('ion-content'); 3807 if (ionContent) { 3808 scrollEl = await ionContent.getScrollElement(); 3809 } 3810 3811 if (scrollEl) { 3812 let containerRect = scrollEl.getBoundingClientRect(); 3813 let baseOffset = ev.target.getBoundingClientRect().top - containerRect.top + scrollEl.scrollTop; 3814 let offset = Math.max(0, baseOffset - gap + ev.detail.offset); 3815 let scrollElDesc = scrollEl.id ? `${scrollEl.tagName.toLowerCase()}#${scrollEl.id}` : scrollEl.className ? `${scrollEl.tagName.toLowerCase()}.${scrollEl.className.split(' ')[0]}` : scrollEl.tagName.toLowerCase(); 3816 console.log(`[Bubblehouse] scrolling ${scrollElDesc} to ${Math.round(offset)}px (currently at ${Math.round(scrollEl.scrollTop)}px, target ${ev.target.id} at ${Math.round(baseOffset)}px + ${Math.round(ev.detail.offset)}px offset - ${gap}px gap)`); 3817 scrollEl.scrollTo({ top: Math.round(offset), behavior: 'smooth' }); 3818 } else { 3819 let baseOffset = ev.target.getBoundingClientRect().top + window.scrollY; 3820 let offset = Math.max(0, baseOffset - gap + ev.detail.offset); 3821 console.log(`[Bubblehouse] scrolling to ${Math.round(offset)}px (${Math.round(ev.detail.offset)}px within ${ev.target.id} + ${Math.round(baseOffset)}px iframe offset - ${gap}px gap)`); 3822 window.scroll({ top: Math.round(offset), behavior: 'smooth' }); 3823 } 3824} 3825 3826document.addEventListener('bhclient:Scroll1', handleScroll1); 3827 3828 3829/* anchor-navigation.js */ 3830const BUBBLEHOUSE_ANCHOR_PREFIX = "#bh-"; 3831const BUBBLEHOUSE_ANCHOR_RETRY_DELAY_MS = 100; 3832const BUBBLEHOUSE_ANCHOR_RETRY_COUNT = 50; 3833const BUBBLEHOUSE_ANCHOR_FINAL_WAIT_MS = 1500; 3834const BUBBLEHOUSE_ANCHOR_SETTLE_DELAY_MS = 2000; 3835const BUBBLEHOUSE_ANCHOR_SETTLE_THRESHOLD_PX = 50; 3836 3837let bubblehouseAnchorNavigation = null; 3838let bubblehouseAnchorSettleTimerID = 0; 3839 3840navigateToCurrentBubblehouseAnchor(); 3841window.addEventListener("hashchange", navigateToCurrentBubblehouseAnchor); 3842window.addEventListener("message", handleBubblehouseAnchorResizeMessage); 3843document.addEventListener("bhclient:Scroll1", handleBubblehouseAnchorScroll1); 3844 3845function navigateToCurrentBubblehouseAnchor() { 3846 let anchor = currentBubblehouseAnchor(); 3847 if (!anchor) { 3848 cancelBubblehouseAnchorNavigation(); 3849 return; 3850 } 3851 startBubblehouseAnchorNavigation(anchor); 3852} 3853 3854function startBubblehouseAnchorNavigation(anchor) { 3855 cancelBubblehouseAnchorNavigation(); 3856 bubblehouseAnchorNavigation = { 3857 anchor, 3858 resizeRetryUsed: false, 3859 retriesRemaining: BUBBLEHOUSE_ANCHOR_RETRY_COUNT, 3860 timerID: 0, 3861 }; 3862 attemptBubblehouseAnchorNavigation(false); 3863} 3864 3865function attemptBubblehouseAnchorNavigation(finalAttempt) { 3866 let state = bubblehouseAnchorNavigation; 3867 if (!state) { 3868 return; 3869 } 3870 state.timerID = 0; 3871 if (scrollToSameOriginBubblehouseAnchor(state.anchor)) { 3872 completeBubblehouseAnchorNavigation(); 3873 return; 3874 } 3875 let posted = postNavigateToAnchor1(state.anchor); 3876 if (finalAttempt) { 3877 scheduleBubblehouseAnchorGiveUp(state.anchor, posted); 3878 } else if (state.retriesRemaining > 0) { 3879 state.retriesRemaining--; 3880 state.timerID = setTimeout(() => attemptBubblehouseAnchorNavigation(false), BUBBLEHOUSE_ANCHOR_RETRY_DELAY_MS); 3881 } else { 3882 warnBubblehouseAnchorNavigationFailed(state.anchor); 3883 } 3884} 3885 3886function currentBubblehouseAnchor() { 3887 let hash = location.hash || ""; 3888 if (!hash.startsWith(BUBBLEHOUSE_ANCHOR_PREFIX)) { 3889 return ""; 3890 } 3891 let anchor = hash.slice(BUBBLEHOUSE_ANCHOR_PREFIX.length); 3892 if (!anchor || anchor.includes("=")) { 3893 return ""; 3894 } 3895 return anchor; 3896} 3897 3898function scrollToSameOriginBubblehouseAnchor(anchor) { 3899 for (let frame of bubblehouseAnchorFrames()) { 3900 let section; 3901 try { 3902 section = frame.contentDocument?.getElementById(anchor); 3903 } catch { 3904 section = null; 3905 } 3906 if (!section) { 3907 continue; 3908 } 3909 let inTabgroup = false; 3910 try { 3911 inTabgroup = !!(section.closest && section.closest('[data-controller~="tabgroup"]')); 3912 } catch { 3913 } 3914 let reveal; 3915 try { 3916 reveal = frame.contentWindow?.Bubblehouse?.revealTabgroupContaining; 3917 } catch { 3918 reveal = null; 3919 } 3920 if (inTabgroup && typeof reveal !== 'function') { 3921 continue; 3922 } 3923 if (typeof reveal === 'function') {
3924 try { 3925 section = reveal(section) || section; 3926 } catch { 3927 } 3928 } 3929 let offset = bubblehouseAnchorOffset(section); 3930 dispatchSameOriginBubblehouseAnchorScroll(frame, offset); 3931 scheduleSameOriginBubblehouseAnchorSettle(frame, section, offset); 3932 return true; 3933 } 3934 return false; 3935} 3936 3937function scheduleSameOriginBubblehouseAnchorSettle(frame, section, firstOffset) { 3938 clearBubblehouseAnchorSettleTimer(); 3939 bubblehouseAnchorSettleTimerID = setTimeout(() => { 3940 bubblehouseAnchorSettleTimerID = 0; 3941 let settledOffset = bubblehouseAnchorOffset(section); 3942 if (Math.abs(settledOffset - firstOffset) > BUBBLEHOUSE_ANCHOR_SETTLE_THRESHOLD_PX) { 3943 dispatchSameOriginBubblehouseAnchorScroll(frame, settledOffset); 3944 } 3945 }, BUBBLEHOUSE_ANCHOR_SETTLE_DELAY_MS); 3946} 3947 3948function bubblehouseAnchorOffset(section) { 3949 return section.getBoundingClientRect ? section.getBoundingClientRect().top : section.offsetTop; 3950} 3951 3952function dispatchSameOriginBubblehouseAnchorScroll(frame, offset) { 3953 frame.dispatchEvent(new CustomEvent("bhclient:Scroll1", { 3954 detail: { 3955 bubblehouseBlockClientCall: "Scroll1", 3956 instance: frame.id, 3957 offset, 3958 }, 3959 bubbles: true, 3960 composed: true, 3961 })); 3962} 3963 3964function postNavigateToAnchor1(anchor) { 3965 let posted = 0; 3966 for (let frame of bubblehouseAnchorFrames()) {
3967 let contentWindow = frame.contentWindow; 3968 if (!contentWindow?.postMessage) { 3969 continue; 3970 } 3971 contentWindow.postMessage({ 3972 bubblehouseBlockClientCall: "NavigateToAnchor1", 3973 instance: "#self", 3974 anchor, 3975 }, bubblehouseAnchorTargetOrigin(frame)); 3976 posted++; 3977 } 3978 return posted; 3979} 3980 3981function handleBubblehouseAnchorScroll1(ev) { 3982 if (!bubblehouseAnchorNavigation || !isTrustedBubblehouseAnchorSource(ev.detail?.sourceWindow)) { 3983 return; 3984 } 3985 completeBubblehouseAnchorNavigation(); 3986} 3987 3988function handleBubblehouseAnchorResizeMessage(ev) { 3989 let state = bubblehouseAnchorNavigation; 3990 if (!state || state.resizeRetryUsed || !isResize1Message(ev.data) || !isTrustedBubblehouseAnchorSource(ev.source)) { 3991 return; 3992 } 3993 state.resizeRetryUsed = true; 3994 clearBubblehouseAnchorTimer(); 3995 attemptBubblehouseAnchorNavigation(true); 3996} 3997 3998function isResize1Message(data) { 3999 return data && data.bubblehouseBlockClientCall === "Resize1"; 4000} 4001 4002function isTrustedBubblehouseAnchorSource(source) { 4003 return !!source && bubblehouseAnchorFrames().some((frame) => frame.contentWindow === source); 4004} 4005 4006function bubblehouseAnchorFrames() { 4007 return collectBubblehouseFrames(document).filter((frame) => !!bubblehouseAnchorFrameURL(frame)); 4008} 4009 4010function bubblehouseAnchorFrameURL(frame) { 4011 try { 4012 let url = new URL(frame.getAttribute("src") || frame.src, location.href); 4013 return isBubblehouseAuthFrameURL(url, bubblehouseAppOrigin(), location.origin) ? url : null; 4014 } catch { 4015 return null; 4016 } 4017} 4018 4019function bubblehouseAnchorTargetOrigin(frame) { 4020 return bubblehouseAnchorFrameURL(frame)?.origin || "*"; 4021} 4022 4023function scheduleBubblehouseAnchorGiveUp(anchor, posted) { 4024 if (!bubblehouseAnchorNavigation) { 4025 return; 4026 } 4027 if (posted) { 4028 bubblehouseAnchorNavigation.timerID = setTimeout(() => warnBubblehouseAnchorNavigationFailed(anchor), BUBBLEHOUSE_ANCHOR_FINAL_WAIT_MS); 4029 } else { 4030 warnBubblehouseAnchorNavigationFailed(anchor); 4031 } 4032} 4033 4034function warnBubblehouseAnchorNavigationFailed(anchor) { 4035 if (!bubblehouseAnchorNavigation) { 4036 return; 4037 } 4038 console.warn("[Bubblehouse] giving up on navigation to anchor " + anchor); 4039 cancelBubblehouseAnchorNavigation(); 4040} 4041 4042function completeBubblehouseAnchorNavigation() { 4043 clearBubblehouseAnchorTimer(); 4044 bubblehouseAnchorNavigation = null; 4045} 4046 4047function cancelBubblehouseAnchorNavigation() { 4048 completeBubblehouseAnchorNavigation(); 4049 clearBubblehouseAnchorSettleTimer(); 4050} 4051 4052function clearBubblehouseAnchorTimer() { 4053 if (bubblehouseAnchorNavigation?.timerID) { 4054 clearTimeout(bubblehouseAnchorNavigation.timerID); 4055 bubblehouseAnchorNavigation.timerID = 0; 4056 } 4057} 4058 4059function clearBubblehouseAnchorSettleTimer() { 4060 if (bubblehouseAnchorSettleTimerID) { 4061 clearTimeout(bubblehouseAnchorSettleTimerID); 4062 bubblehouseAnchorSettleTimerID = 0; 4063 } 4064} 4065 4066 4067/* public.js */ 4068Bubblehouse.centsToAccruedPoints = centsToAccruedPoints 4069Bubblehouse.pointsBalance = pointsBalance 4070Bubblehouse.pointsBalanceStr = pointsBalanceStr 4071Bubblehouse.pointsBalanceMonetaryValue = pointsBalanceMonetaryValue 4072Bubblehouse.pointsBalanceMonetaryValueStr = pointsBalanceMonetaryValueStr 4073 4074Bubblehouse.ONE_TIME_PURCHASE = 'onetime' 4075Bubblehouse.SUBSCRIPTION = 'sub' 4076 4077function centsToAccruedPoints(cents, scenario, options = {}) { 4078 if (scenario === 'onetime') { 4079 return Math.round(cents * Bubblehouse.data.ppc + 0.001) 4080 } else if (scenario === 'sub') { 4081 return Math.round(cents * Bubblehouse.data.ppcs + 0.001) 4082 } else { 4083 throw new Error('unsupported scenario') 4084 } 4085} 4086 4087Bubblehouse.PTS_TOTAL = 'total' 4088Bubblehouse.PTS_AVAILABLE = 'avail' 4089 4090function pointsBalance(kind) { 4091 if (kind === 'total') { 4092 return Bubblehouse.data.userPointBalance 4093 } else if (kind === 'avail') { 4094 return Bubblehouse.data.userPointBalanceAvail 4095 } else { 4096 throw new Error('unsupported kind') 4097 } 4098} 4099 4100function pointsBalanceStr(kind) { 4101 if (kind === 'total') { 4102 return Bubblehouse.data.userPointBalanceStr 4103 } else if (kind === 'avail') { 4104 return Bubblehouse.data.userPointBalanceAvailStr 4105 } else { 4106 throw new Error('unsupported kind') 4107 } 4108} 4109 4110function pointsBalanceMonetaryValue(kind) { 4111 if (kind === 'total') { 4112 return Bubblehouse.data.userPointBalanceValue 4113 } else if (kind === 'avail') { 4114 return Bubblehouse.data.userPointBalanceAvailValue 4115 } else { 4116 throw new Error('unsupported kind') 4117 } 4118} 4119 4120function pointsBalanceMonetaryValueStr(kind) { 4121 if (kind === 'total') { 4122 return Bubblehouse.data.userPointBalanceValueStr 4123 } else if (kind === 'avail') { 4124 return Bubblehouse.data.userPointBalanceAvailValueStr 4125 } else { 4126 throw new Error('unsupported kind') 4127 } 4128} 4129 4130 4131/* integrations.js */ 4132;(function() { 4133if (Bubblehouse.isInner) { 4134 function invokeIntegration(key, method, ...args) { 4135 let message = {
4136 bubblehouseBlockClientCall: 'InvokeIntegration1', 4137 instance: '#self', 4138 key, method, args 4139 }; 4140 window.parent.postMessage(message, '*'); 4141 } 4142 4143 Bubblehouse.invokeIntegration = invokeIntegration; 4144} else { 4145 Bubblehouse.integrations = {}; 4146 4147 function handleInvokeIntegration1(ev) { 4148 let { key, method, args } = ev.detail; 4149 if (Bubblehouse.integrations[key] === undefined) 4150 throw new Error(`[Bubblehouse] Integration with key "${key}" is not enabled`); 4151 let ingr = Bubblehouse.integrations[key]; 4152 if (ingr[method] === undefined) 4153 throw new Error(`[Bubblehouse] Integration "${key}" does not provide method "${method}"`); 4154 4155 console.log(`[Bubblehouse] Calling integration "${key}" method "${method}" with args "${JSON.stringify(args)}"`); 4156 ingr[method].call(ingr, ...args); 4157 } 4158 4159 document.addEventListener('bhclient:InvokeIntegration1', handleInvokeIntegration1); 4160} 4161})() || true; 4162 4163/* integrations/postscript.js */ 4164;(function() { 4165class Postscript { 4166 constructor() {} 4167 4168 async OpenPopup1(popups, opts) { 4169 let id = popups.default; 4170 if (popups.auto) { 4171 id = await window.postscript.popups.find(); 4172 } else { 4173 if (popups.mobile && this.isMobile()) { 4174 id = popups.mobile; 4175 } 4176 } 4177 4178 // Postscript requires Number for legacy popup ID, while new popups use UUID. 4179 if (!isNaN(+id)) { 4180 id = +id; 4181 } 4182 4183 window.postscript.popups.open(id, opts); 4184 } 4185 4186 isMobile() { 4187 return window.matchMedia("(min-device-width : 300px)").matches && 4188 window.matchMedia("(max-device-width : 480px)").matches; 4189 } 4190} 4191 4192Bubblehouse.integrations.postscript = new Postscript(); 4193})() || true; 4194 4195/* load.js */ 4196document.dispatchEvent(new CustomEvent('bubblehouse:load')); 4197 4198 4199/* elements/embed-iframe.js */ 4200class EmbedIframe extends HTMLElement { 4201 constructor() { 4202 super(); 4203 } 4204 4205 connectedCallback() { 4206 const iframe = document.createElement("iframe"); 4207 4208 this.getAttributeNames().map((an) => { 4209 iframe.setAttribute(an, this.getAttribute(an)); 4210 }); 4211 4212 const src = iframe.getAttribute("src"); 4213 iframe.setAttribute("src", src.replace("$AUTH_TOKEN", Bubblehouse.data.authToken)); 4214 4215 this.replaceWith(iframe); 4216 } 4217} 4218 4219customElements.define("bh-embed-iframe", EmbedIframe); 4220 4221 4222})();
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.