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