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-plant-people-new", 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; margin: auto; 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; margin: auto; 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.plantpeople.co/apps/bubblehouse/blocks/POPUPNAME","callUrl":"https://app.bubblehouse.com/api/v2023061/shopify-plant-people-new/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:","widget1":true,"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-plant-people-new"],"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/* bh-magic.js */ 1069(() => { 1070 const url = new URL(location); 1071 const query = url.searchParams; 1072 const token = query.get("bh-magic"); 1073 if (token) { 1074 query.delete("bh-magic"); 1075 const target = new URL(Bubblehouse.root + "/sso/auth/" + token); 1076 target.searchParams.set("return_url", url.toString()); 1077 location.href = target.toString() 1078 } 1079})(); 1080 1081 1082/* actionTokenRelay.js */ 1083const BUBBLEHOUSE_ACTION_QUERY_PARAM = Bubblehouse.actionQueryParam; 1084 1085let bubblehouseActionToken = readBubblehouseActionToken(); 1086let bubblehouseRejectedActionToken = ""; 1087let bubblehouseActionTokenConflict = false;
1088let bubblehouseActionTokenObserver = null; 1089 1090processBubblehouseActionToken(); 1091onDOMContentLoaded(processBubblehouseActionToken); 1092window.addEventListener("message", handleBubblehouseActionTokenStatus); 1093Bubblehouse.prepareFrameURLForAuthReload = prepareBubblehouseActionFrameURLForAuthReload; 1094 1095function readBubblehouseActionToken() { 1096 try { 1097 let values = new URL(location.href).searchParams.getAll(BUBBLEHOUSE_ACTION_QUERY_PARAM); 1098 return values.length === 1 ? values[0] : ""; 1099 } catch { 1100 return ""; 1101 } 1102} 1103 1104function processBubblehouseActionToken() { 1105 if (!bubblehouseActionToken) { 1106 return 1107 } 1108 applyBubblehouseActionTokenToNode(document); 1109 observeLateBubblehouseActionFrames(); 1110} 1111 1112function applyBubblehouseActionTokenToNode(node) { 1113 let appOrigin = bubblehouseActionAppOrigin(); 1114 let shopSlugs = bubblehouseActionShopSlugs(); 1115 let proxyPaths = bubblehouseActionProxyPaths(); 1116 let candidates = []; 1117 for (let frame of collectBubblehouseActionFramesIncludingRoot(node)) { 1118 let frameURL; 1119 try { 1120 frameURL = new URL(frame.src, location.href); 1121 } catch { 1122 continue 1123 } 1124 if (!isBubblehouseActionFrameURL(frameURL, appOrigin, location.origin, shopSlugs, proxyPaths)) { 1125 continue 1126 } 1127 candidates.push({ frame, frameURL }); 1128 } 1129 for (let { frame, frameURL } of candidates) { 1130 if (bubblehouseRejectedActionToken && 1131 frameURL.searchParams.get(BUBBLEHOUSE_ACTION_QUERY_PARAM) === bubblehouseRejectedActionToken) { 1132 try { 1133 frameURL.searchParams.delete(BUBBLEHOUSE_ACTION_QUERY_PARAM); 1134 frame.src = frameURL.toString(); 1135 } catch { 1136 } 1137 } 1138 } 1139 if (!bubblehouseActionToken || bubblehouseActionTokenConflict) { 1140 return 1141 } 1142 let conflictingCandidates = candidates.filter(({ frameURL }) => { 1143 let values = frameURL.searchParams.getAll(BUBBLEHOUSE_ACTION_QUERY_PARAM); 1144 return values.length > 0 && (values.length !== 1 || values[0] !== bubblehouseActionToken) 1145 }); 1146 if (conflictingCandidates.length > 0) { 1147 bubblehouseActionTokenConflict = true; 1148 if (Bubblehouse.verbose) { 1149 console.log("[Bubblehouse] Action Token relay conflict; no outer token selected"); 1150 } 1151 return 1152 } 1153 for (let { frame, frameURL } of candidates) { 1154 let values = frameURL.searchParams.getAll(BUBBLEHOUSE_ACTION_QUERY_PARAM); 1155 if (values.length > 0) { 1156 continue 1157 } 1158 try { 1159 frameURL.searchParams.set(BUBBLEHOUSE_ACTION_QUERY_PARAM, bubblehouseActionToken); 1160 frame.src = frameURL.toString(); 1161 } catch { 1162 } 1163 } 1164} 1165 1166function observeLateBubblehouseActionFrames() { 1167 if (bubblehouseActionTokenObserver || typeof MutationObserver !== "function") { 1168 return 1169 } 1170 let root = document.body || document.documentElement; 1171 if (!root) { 1172 return 1173 } 1174 try { 1175 bubblehouseActionTokenObserver = new MutationObserver((mutations) => { 1176 for (let mutation of mutations) { 1177 for (let node of mutation.addedNodes || []) { 1178 applyBubblehouseActionTokenToNode(node); 1179 } 1180 } 1181 }); 1182 bubblehouseActionTokenObserver.observe(root, { childList: true, subtree: true }); 1183 } catch { 1184 } 1185} 1186 1187function collectBubblehouseActionFramesIncludingRoot(root) { 1188 let frames = collectBubblehouseFrames(root); 1189 if (root && root.tagName && root.tagName.toLowerCase() === "iframe") { 1190 frames.unshift(root); 1191 } 1192 return frames 1193} 1194 1195function bubblehouseActionAppOrigin() { 1196 for (let value of [Bubblehouse.data.callUrl, Bubblehouse.root]) { 1197 if (!value) { 1198 continue 1199 } 1200 try { 1201 return new URL(value, location.href).origin 1202 } catch { 1203 } 1204 } 1205 return "" 1206} 1207 1208function handleBubblehouseActionTokenStatus(ev) { 1209 let status = ev.data && ev.data.bubblehouseActionTokenStatus; 1210 let acknowledgedToken = ev.data && ev.data.bubblehouseActionToken; 1211 let trustedTokenStatus = acknowledgedToken === bubblehouseActionToken || 1212 (status === "rejected" && acknowledgedToken === ""); 1213 if (!bubblehouseActionToken || 1214 !ev.data || !trustedTokenStatus || 1215 (bubblehouseActionTokenConflict && status !== "rejected") || 1216 (status !== "captured" && status !== "rejected")) { 1217 return 1218 } 1219 if (!isTrustedBubblehouseActionTokenStatus(ev)) { 1220 return 1221 } 1222 let token = bubblehouseActionToken; 1223 if (status === "rejected") { 1224 bubblehouseRejectedActionToken = token; 1225 bubblehouseActionToken = ""; 1226 } 1227 clearOuterBubblehouseActionToken(token); 1228} 1229 1230function isTrustedBubblehouseActionTokenStatus(ev) { 1231 let appOrigin = bubblehouseActionAppOrigin(); 1232 let shopSlugs = bubblehouseActionShopSlugs(); 1233 let proxyPaths = bubblehouseActionProxyPaths(); 1234 for (let frame of collectBubblehouseFrames(document)) { 1235 let frameURL; 1236 try { 1237 frameURL = new URL(frame.src, location.href); 1238 } catch { 1239 continue 1240 } 1241 let actionValues = frameURL.searchParams.getAll(BUBBLEHOUSE_ACTION_QUERY_PARAM); 1242 if (frame.contentWindow === ev.source && 1243 frameURL.origin === ev.origin && 1244 actionValues.length === 1 && 1245 actionValues[0] === bubblehouseActionToken && 1246 isBubblehouseActionFrameURL(frameURL, appOrigin, location.origin, shopSlugs, proxyPaths)) { 1247 return true 1248 } 1249 } 1250 return false 1251} 1252 1253function bubblehouseActionShopSlugs() { 1254 let configured = Bubblehouse.extra && Bubblehouse.extra.actionShopSlugs; 1255 let slugs = Array.isArray(configured) ? configured.filter((slug) => typeof slug === "string" && slug) : []; 1256 let rootSlug = bubblehouseRootShopSlug(Bubblehouse.root, location.href); 1257 if (rootSlug && !slugs.includes(rootSlug)) { 1258 slugs.push(rootSlug); 1259 } 1260 return slugs 1261} 1262 1263function bubblehouseActionProxyPaths() { 1264 let configured = Bubblehouse.extra && Bubblehouse.extra.actionProxyPaths; 1265 return Array.isArray(configured) ? configured.filter((path) => typeof path === "string" && path) : [] 1266} 1267 1268function prepareBubblehouseActionFrameURLForAuthReload(url) { 1269 if (bubblehouseRejectedActionToken && 1270 url.searchParams.get(BUBBLEHOUSE_ACTION_QUERY_PARAM) === bubblehouseRejectedActionToken) { 1271 url.searchParams.delete(BUBBLEHOUSE_ACTION_QUERY_PARAM); 1272 } 1273} 1274 1275function clearOuterBubblehouseActionToken(token) { 1276 try { 1277 let url = new URL(location.href); 1278 if (url.searchParams.get(BUBBLEHOUSE_ACTION_QUERY_PARAM) !== token) { 1279 return 1280 } 1281 url.searchParams.delete(BUBBLEHOUSE_ACTION_QUERY_PARAM); 1282 history.replaceState(history.state, "", url.toString()); 1283 } catch { 1284 } 1285} 1286 1287 1288/* util/wantSuccessfulJson.js */ 1289function wantSuccessfulJson(response) { 1290 if (response.ok) { 1291 return response.json() 1292 } else { 1293 throw new Error(`${response.url} returned HTTP ${response.status} ${response.statusText}`) 1294 } 1295} 1296 1297 1298/* util/waitAll.js */ 1299function waitAll(results) { 1300 return Promise.all(results.map((v) => Promise.resolve(v))) 1301} 1302 1303 1304/* util/reportError.js */ 1305function reportError(error, context = '') { 1306 console.error(`[Bubblehouse] error: ${context ? context + ': ' : ''}${error.message || error}`) 1307 if (error.stack) { 1308 console.error(error.stack) 1309 } 1310} 1311 1312function reportWarning(error, context = '') {
1313 console.warn(`[Bubblehouse] warning: ${context ? context + ': ' : ''}${error.message || error}`) 1314} 1315 1316 1317/* util/debounceAsyncWithRepeat.js */ 1318function debounceAsyncWithRepeat(debugName, debounceInterval, f) { 1319 const IDLE = 0, PENDING = 1, RUNNING = 2, RUNNING_AND_PENDING = 3; 1320 let state = -1; setState(IDLE); 1321 let pendingArgs = []; 1322 return request; 1323 1324 function request(...args) { 1325 pendingArgs = args; 1326 if (state === IDLE) { 1327 schedule() 1328 } else if (state === RUNNING) { 1329 setState(RUNNING_AND_PENDING) 1330 } 1331 } 1332 1333 function schedule() { 1334 setState(PENDING); 1335 setTimeout(start, debounceInterval); 1336 } 1337 1338 function start() { 1339 let args = pendingArgs; 1340 pendingArgs = []; 1341 setState(RUNNING); 1342 let promise = f.apply(null, args); 1343 if (promise && promise.then) { 1344 promise.then(finish, finish) 1345 } else { 1346 finish() 1347 } 1348 } 1349 1350 function finish(result) { 1351 if (result instanceof Error) { 1352 console.error(`[Bubblehouse] ${debugName} failed:`, result) 1353 } else { 1354 if (Bubblehouse.verbose) console.log(`[Bubblehouse] ${debugName} finished:`, result) 1355 } 1356 if (state === RUNNING_AND_PENDING) { 1357 start(); 1358 } else { 1359 setState(IDLE); 1360 } 1361 } 1362 1363 function setState(newState) { 1364 // if (Bubblehouse.verbose) console.log(`[Bubblehouse] ${debugName} state transition: ${state} => ${newState}`); 1365 state = newState; 1366 } 1367} 1368 1369 1370/* shopify/cartApi.js */ 1371// updateCart = debounceAsyncWithRepeat('updateCart', 1, updateCart); 1372 1373Bubblehouse.loadCart = loadCart; 1374Bubblehouse.addToCart = addToCart; 1375Bubblehouse.updateCart = updateCart; 1376Bubblehouse.callStorefront = callStorefront; 1377Bubblehouse.addDiscountCode = addDiscountCode; 1378 1379function loadCart() { 1380 return fetch(Shopify.routes.root + "cart.js").then(wantSuccessfulJson); 1381} 1382 1383// items = [{ id: giftVariantId, quantity: 1 }] 1384function addToCart(items, { origin } = {}) { 1385 let body = { items }; 1386 console.log(`[Bubblehouse] adding to cart (${origin}):`, body); 1387 return fetch(Shopify.routes.root + "cart/add.js?bubblehouse-cart-ignore=1", { 1388 method: "POST", 1389 body: JSON.stringify(body), 1390 headers: { "Content-Type": "application/json" }, 1391 }).then(wantSuccessfulJson); 1392} 1393 1394// updates = { key1: quantity1, ... } 1395function updateCart(updates, { origin } = {}) { 1396 let body = { updates }; 1397 console.log(`[Bubblehouse] updating cart (${origin}):`, body); 1398 return fetch( 1399 `${Shopify.routes.root}cart/update.js?bubblehouse-cart-ignore=1`, 1400 { 1401 method: "POST", 1402 body: JSON.stringify(body), 1403 headers: { "Content-Type": "application/json" }, 1404 }, 1405 ); 1406} 1407 1408async function addDiscountCode(_cartToken, discountCode, codesToRemove = []) { 1409 const cart = await loadCart(); 1410 const existingDiscountCodes = (cart.discount_codes || []).map(({ code }) => code); 1411 const discountCodes = [...new Set([ 1412 ...existingDiscountCodes.filter((code) => !codesToRemove.includes(code)), 1413 discountCode, 1414 ])]; 1415 if (Bubblehouse.verbose) { 1416 console.log(`[BH Debug] new discountCodes = ${JSON.stringify(discountCodes)}`) 1417 } 1418 const updated = await setDiscountCodes(discountCodes); 1419 if (!updated.discount_codes?.some(({ code, applicable }) => 1420 code.toLowerCase() === discountCode.toLowerCase() && applicable 1421 )) { 1422 throw new Error("Shopify did not apply the discount code"); 1423 } 1424} 1425 1426function setDiscountCodes(discountCodes) { 1427 const body = { discount: discountCodes.join(",") }; 1428 return fetch( 1429 `${Shopify.routes.root}cart/update.js?bubblehouse-cart-ignore=1`, 1430 { 1431 method: "POST", 1432 body: JSON.stringify(body), 1433 headers: { "Content-Type": "application/json" }, 1434 }, 1435 ).then(wantSuccessfulJson); 1436} 1437 1438function isShopifyStorefrontApiAvailable() { 1439 return !!Bubblehouse.data.storefrontToken; 1440} 1441 1442async function callStorefront(query, variables) { 1443 let token = Bubblehouse.data.storefrontToken; 1444 if (!token) { 1445 throw new Error("misconfigured: missing storefront token"); 1446 } 1447 1448 let m = query.match(/result: (\w+)/); 1449 if (!m) { 1450 throw new Error("invalid GQL query"); 1451 } 1452 let callName = m[1]; 1453 1454 let response = await fetch("/api/2025-01/graphql.json", { 1455 method: "POST", 1456 headers: { 1457 "Content-Type": "application/json", 1458 "X-Shopify-Storefront-Access-Token": token, 1459 }, 1460 body: JSON.stringify({ query, variables }), 1461 }); 1462 let data = await response.json(); 1463 if (Bubblehouse.verbose) { 1464 console.log(`[Bubblehouse] callStorefront ${callName}(${JSON.stringify(variables)}) => ${JSON.stringify(data)}`) 1465} 1466 if (data?.errors) { 1467 // {"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"}}]} 1468 1469 console.error(`[Bubblehouse] ${callName} GQL errors: ${JSON.stringify(data.errors)}`); 1470 throw new Error(`${callName}: GQL error: ${data.errors[0].message} (${data.errors.length})`); 1471 } 1472 let result = data.data?.result; 1473 if (result?.userErrors?.length) { 1474 console.error(`[Bubblehouse] ${callName} user errors:`, result.userErrors); 1475 throw new Error(`${callName}: GQL userErrors: ${result.userErrors}`); 1476 } 1477 return result; 1478} 1479 1480function navigateToCartOrReloadPage(cartUrl) { 1481 window.location.href = cartUrl ? cartUrl : "/cart"; 1482} 1483 1484 1485/* xhrMonitor.js */ 1486;(function() { 1487let origXHROpen = XMLHttpRequest.prototype.open; 1488let origFetch = window.fetch; 1489XMLHttpRequest.prototype.open = xhrOpenWrapperUnlikelyToBeRelatedToYourError; 1490window.fetch = fetchWrapperUnlikelyToBeRelatedToYourError; 1491if (Bubblehouse.verbose) console.log('[Bubblehouse] xhr monitoring enabled'); 1492 1493function xhrOpenWrapperUnlikelyToBeRelatedToYourError(method, url) { 1494 if (this.bubblehouseListener) 1495 this.removeEventListener('loadend', this.bubblehouseListener);
1496 this.bubblehouseListener = handleXMLHttpRequestLoaded.bind(this, method.toUpperCase(), url) 1497 this.addEventListener('loadend', this.bubblehouseListener); 1498 origXHROpen.apply(this, arguments); 1499} 1500 1501function handleXMLHttpRequestLoaded(method, url) { 1502 // if (Bubblehouse.verbose) console.log(`[Bubblehouse] xhr finished via XMLHttpRequest: ${method} ${url}`); 1503 document.dispatchEvent(new CustomEvent('bubblehouse:xhrend', { detail: { method: method, url: url, ok: true } })); 1504} 1505 1506function fetchWrapperUnlikelyToBeRelatedToYourError(resource, options) { 1507 let method = 'GET'; 1508 if (options && typeof options === 'object' && options.method && typeof options.method === 'string') { 1509 method = options.method.toUpperCase(); 1510 } 1511 let promise = origFetch.apply(this, arguments); 1512 promise.then(fetchWrapperSucceeded.bind(null, method)); 1513 return promise; 1514} 1515 1516function fetchWrapperSucceeded(method, response) { 1517 // if (Bubblehouse.verbose) console.log(`[Bubblehouse] xhr finished via fetch: ${method} ${response.url}`); 1518 document.dispatchEvent(new CustomEvent('bubblehouse:xhrend', { detail: { method: method, url: response.url, ok: response.ok } })); 1519} 1520})() || true; 1521 1522/* cartMonitor.js */ 1523;(function() { 1524Bubblehouse.cartUpdaters = [] 1525Bubblehouse.cartListeners = [] 1526document.addEventListener('bubblehouse:xhrend', considerUpdatingCart); 1527document.addEventListener('bubblehouse:external-cart-update', handleExternalCartUpdate); 1528setTimeout(() => { handleCartUpdate('pageLoad', false) }, 1) 1529 1530function considerUpdatingCart(ev) { 1531 let { method, url } = ev.detail; 1532 let m 1533 if ((method === 'POST') && (m = /\/cart\/(add|update|change)/.exec(url))) { 1534 let isBubblehouseOrigin = /bubblehouse-cart-ignore/.test(url) 1535 if (Bubblehouse.verbose) console.log(`[Bubblehouse] cart update detected (from ${isBubblehouseOrigin ? 'bubblehouse' : 'outside'})`); 1536 handleCartUpdate(m[1], isBubblehouseOrigin).then(null, reportError) 1537 // } else if ((method === 'POST') && (m = /\/api\/([0-9-]+)\/graphql\.json/.exec(url))) { 1538 } 1539} 1540 1541function handleExternalCartUpdate() { 1542 handleCartUpdate('external_update', false).then(null, reportError) 1543} 1544 1545// document.dispatchEvent(new Event('bubblehouse_cart_update', { detail: { origin: url } })) 1546 1547async function handleCartUpdate(cartAction, isBubblehouseOrigin) { 1548 if (Bubblehouse.cartListeners.length === 0 && Bubblehouse.cartUpdaters.length === 0) return; 1549 let cartBody 1550 try { 1551 cartBody = await loadCart() 1552 } catch (error) { 1553 reportWarning(error, `cart fetch failed after ${cartAction}`) 1554 return 1555 } 1556 if (Bubblehouse.verbose) console.log(`[Bubblehouse] cart after ${cartAction}:`, cartBody); 1557 if (!isBubblehouseOrigin) { 1558 await waitAll(Bubblehouse.cartUpdaters.map((f) => f(cartBody, cartAction))) 1559 } 1560 await waitAll(Bubblehouse.cartListeners.map((f) => f(cartBody, cartAction))) 1561 //await rerenderIfModified(cartBody) 1562} 1563 1564function rerenderIfModified(originalCart) { 1565 return loadCart().then((newCart) => { 1566 if (JSON.stringify(originalCart.items) != JSON.stringify(newCart.items)) { 1567 if (Bubblehouse.verbose) console.log(`[Bubblehouse] cart modified after running cart updaters`); 1568 return rerenderCart(); 1569 } else { 1570 if (Bubblehouse.verbose) console.log(`[Bubblehouse] cart unmodified after running cart updaters`); 1571 } 1572 }) 1573} 1574})() || true; 1575 1576/* shopify/isOnCartPage.js */ 1577Bubblehouse.isOnCartPage = isOnCartPage; 1578Bubblehouse.isCartVisible = isCartVisible; 1579 1580function isOnCartPage() { 1581 return /\/cart$/.test(location.pathname) 1582} 1583 1584function isCartVisible() { 1585 return isOnCartPage() || !!document.querySelector('#cart-drawer') 1586} 1587 1588 1589/* util/ignoreErr.js */ 1590function ignoreErr(debugPrefix, f) { 1591 try { 1592 let result = f() 1593 if (result && result.then) { 1594 return result['catch'](function (error) { 1595 console.error(`[Bubblehouse] ${debugPrefix} failed:`, error) 1596 }) 1597 } else { 1598 return result 1599 } 1600 } catch (error) { 1601 console.error(`[Bubblehouse] ${debugPrefix} failed:`, error) 1602 } 1603} 1604 1605 1606/* rerenderCart.js */ 1607Bubblehouse.rerenderCart = rerenderCart; 1608 1609function rerenderCart(force) { 1610 if (!isOnCartPage() && !force) { 1611 return 1612 } 1613 location.reload(); 1614 // return waitAll([ 1615 // ignoreErr('rerenderCart.shopify', () => { 1616 // let el = document.querySelector('cart-notification'); 1617 // if (el && 'function' === typeof el.renderContents) { 1618 // return fetch(Shopify.routes.root + 'cart?sections=cart-drawer,cart-icon-bubble', { cache: 'no-cache', credentials: 'same-origin' }).then(wantSuccessfulJson).then((body) => { 1619 // el.renderContents({ sections: body }) 1620 // }) 1621 // } 1622 // }), 1623 // ]) 1624} 1625 1626 1627/* freeSample.js */ 1628;(function() { 1629if (!Bubblehouse.data.freeSample) { 1630 return 1631} 1632let freeSampleVariantId = Bubblehouse.data.freeSample.variantId; 1633Bubblehouse.cartUpdaters.push(adjustGiftsInCart); 1634if (Bubblehouse.verbose) console.log(`[Bubblehouse] [free-sample] enabled with variant ID ${freeSampleVariantId}`) 1635 1636let minPurchaseAmountInCents = ~~(parseFloat(Bubblehouse.data.freeSample.minPurchaseAmount, 10) * 100 + 0.5) 1637 1638function adjustGiftsInCart(cart, cartAction) { 1639 let sampleItems = [], regularItems = [] 1640 for (let item of cart.items) { 1641 if (item.variant_id === freeSampleVariantId) { 1642 sampleItems.push(item) 1643 } else { 1644 regularItems.push(item) 1645 } 1646 } 1647 1648 let isFreeSampleOwed = (regularItems.length > 0) && (cart.total_price >= minPurchaseAmountInCents); 1649 1650 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.`) 1651 1652 if (isFreeSampleOwed && sampleItems.length === 0) { 1653 if (cartAction === 'add' || (cartAction === 'update' && Bubblehouse.data.freeSample.autoAdd) || (cartAction === 'pageLoad' && isCartVisible())) { 1654 if (Bubblehouse.data.freeSample.autoAdd) { 1655 addGiftToCart().then(() => rerenderCart(false)) 1656 } else { 1657 console.log(`[Bubblehouse] [free-sample] offering via popup`) 1658 1659 let popup 1660 popup = openPopupHere('free-sample', {}, { 1661 handlers: { 1662 'AddGiftToCart1': function (message) { doAddSampleToCart1(popup, message) }, 1663 } 1664 }) 1665 } 1666 } else { 1667 console.log(`[Bubblehouse] [free-sample] eligible, but not offered on ${cartAction} action`) 1668 } 1669 } else if (sampleItems.length > 0) { 1670 let firstItem = sampleItems.pop(); 1671 let firstItemQuantity = (isFreeSampleOwed ? 1 : 0); 1672 1673 let updates = {}; 1674 if (firstItem.quantity !== firstItemQuantity) { 1675 updates[firstItem.key] = firstItemQuantity; 1676 } 1677 for (let item of sampleItems) { 1678 updates[item.key] = 0; 1679 } 1680 1681 if (Object.keys(updates).length > 0) { 1682 if (Bubblehouse.verbose) console.log(`[Bubblehouse] [free-sample] removing from cart.`) 1683 1684 let u = updateCart(updates, { origin: 'free-sample' }) 1685 console.log('u = ', u) 1686 return u.then(() => rerenderCart(false)) 1687 } else { 1688 if (Bubblehouse.verbose) console.log(`[Bubblehouse] [free-sample] is up to date`) 1689 } 1690 } 1691} 1692 1693function doAddSampleToCart1(popup, message) { 1694 if (Bubblehouse.verbose) console.log(`[Bubblehouse] [free-sample] adding to cart`); 1695 addGiftToCart().then(() => { 1696 popup.close(); 1697 return rerenderCart(true); 1698 }) 1699} 1700 1701function addGiftToCart() { 1702 return addToCart([{ id: freeSampleVariantId, quantity: 1 }], { origin: 'free-sample' }) 1703} 1704})() || true; 1705 1706/* util/observeElements.js */ 1707function observeElements(selector, func) { 1708 let observer = new MutationObserver(function (mutations) { 1709 mutations.forEach(function (mutation) { 1710 for (let node of mutation.addedNodes) { 1711 if (node.nodeType === Node.ELEMENT_NODE && node.matches(selector)) { 1712 func(node) 1713 } 1714 } 1715 }) 1716 }) 1717 observer.observe(document, { subtree: true, childList: true }) 1718 1719 for (let node of document.querySelectorAll(selector)) { 1720 func(node) 1721 } 1722} 1723 1724 1725/* shopify/productPage.js */ 1726function isShopifyProductPage() { 1727 return ( 1728 /\/products\/[^/]+/.test(window.location.pathname) || 1729 window.ShopifyAnalytics?.meta?.page?.pageType === 'product' || 1730 window.meta?.product != null 1731 ) 1732} 1733 1734 1735/* inlinePanels.js */ 1736;(function() {
1737observeElements('.bubblehouse-product-points-panel', populateProductPointsPanel) 1738 1739function populateProductPointsPanel(panel) { 1740 if (!panel) { 1741 return 1742 } 1743 if (!Bubblehouse.data.ppc) { 1744 return 1745 } 1746 let valueSpan = panel.querySelector('.bubblehouse-product-points-value') 1747 let amount = panel.dataset.priceCents 1748 if (Bubblehouse.verbose) console.log(`[Bubblehouse] Found inline panel, ppc = ${Bubblehouse.data.ppc}, amount = ${amount}`) 1749 if (valueSpan && amount) { 1750 let points = Math.floor(Bubblehouse.data.ppc * +amount) 1751 valueSpan.textContent = points.toFixed(0) 1752 1753 if (Bubblehouse.data.prelaunch) { 1754 return 1755 } 1756 panel.style.visibility = '' 1757 panel.style.display = '' 1758 updatePointsOnVariantChange(valueSpan) 1759 } 1760} 1761 1762async function updatePointsOnVariantChange(valueSpan) { 1763 if (!isShopifyProductPage()) { 1764 return 1765 } 1766 1767 let url = new URL(window.location.href) 1768 url.pathname += '.js' 1769 1770 let data 1771 try { 1772 let response = await fetch(url.toString()) 1773 if (!response.ok) { 1774 console.error(`[Bubblehouse] error fetching product json`) 1775 return 1776 } 1777 data = await response.json() 1778 } catch { 1779 console.error(`[Bubblehouse] error fetching product json`) 1780 return 1781 } 1782 1783 if (!data || !Array.isArray(data.variants)) { 1784 console.error(`[Bubblehouse] product variants array is undefined`) 1785 return 1786 } 1787 1788 window.addEventListener('bubblehouse:variant-change', e => { 1789 let variant = data.variants.find(o => o.id == e.detail.variantId) 1790 if (!variant) { 1791 console.log(`[Bubblehouse] variant ${e.detail.variantId} not found`) 1792 return 1793 } 1794 let points = Math.floor(Bubblehouse.data.ppc * +variant.price) 1795 valueSpan.textContent = points.toFixed(0) 1796 }) 1797} 1798})() || true; 1799 1800/* util/h.js */ 1801function h(tagSpec, ...children) { 1802 let [tagName, ...classesAndIds] = tagSpec.match(/[#.]?[^#.]+/g) 1803 let element = document.createElement(tagName) 1804 for (let identifier of classesAndIds) { 1805 if (identifier.startsWith('#')) { 1806 element.id = identifier.slice(1) 1807 element.classList.add(identifier.slice(1)) 1808 } else if (identifier.startsWith('.')) { 1809 element.classList.add(identifier.slice(1)) 1810 } else { 1811 throw new Error(`invalid tag spec: "${tagSpec}" (invalid part is "${identifier}")`) 1812 } 1813 } 1814 while (children.length > 0 && children[0] && children[0].constructor === Object) { 1815 setAttrs(element, children.shift()) 1816 } 1817 if (children.length === 1 && typeof children[0] === 'string') { 1818 element.textContent = children[0] 1819 } else { 1820 for (let child of children) { 1821 appendChild(element, child) 1822 } 1823 } 1824 return element; 1825} 1826 1827function setAttrs(element, attrs) { 1828 for (const [attr, value] of Object.entries(attrs)) { 1829 if (value == null || value === false) { 1830 continue 1831 } else if (attr === 'class') { 1832 adjustClasses(element, value) 1833 } else if (attr.startsWith('on')) { 1834 let event = attr.substr(2) 1835 element.addEventListener(event, value) 1836 } else if (value === true) { 1837 element.setAttribute(attr, '') 1838 } else if (value && value.constructor === Object) { 1839 if (attr === 'style') { 1840 Object.assign(element.style, value) 1841 } else { 1842 throw new TypeError(`Invalid object value for attribute "${attr}"`) 1843 } 1844 } else { 1845 element.setAttribute(attr, value.toString()) 1846 } 1847 } 1848} 1849 1850// classes -> classItem | array of classes 1851// classItem -> falsy | classSpecString | {classSpecString: booleanish} 1852// classSpecString -> whitespace-separated list of classes 1853function adjustClasses(element, classes) { 1854 if (!classes) return; 1855 if (typeof classes === 'string') { 1856 element.classList.add(...classes.trim().split(/\s+/)) 1857 } else if (Array.isArray(classes)) { 1858 for (let item of classes) { 1859 adjustClasses(element, item) 1860 } 1861 } else if (classes && classes.constructor === Object) { 1862 for (let [name, included] of Object.entries(classes)) { 1863 element.classList.toggle(name, !!included) 1864 } 1865 } else { 1866 throw new TypeError('Classes must be strings, objects, or false/null/undefined'); 1867 } 1868} 1869 1870function appendChild(element, child) { 1871 if (child == null || child === false) return; 1872 if (child instanceof Node || typeof child === 'string') { 1873 element.append(child) 1874 } else if (typeof child === 'number') { 1875 element.append(child.toString()) 1876 } else if (Array.isArray(child)) { 1877 for (let nestedChild of child) { 1878 appendChild(element, nestedChild) 1879 } 1880 } else {
1881 throw new TypeError('Children must be elements, strings, numbers, arrays, or false/null/undefined'); 1882 } 1883 return child 1884} 1885 1886function assignElementRefs(host, root) { 1887 for (let child of root.children) { 1888 if (child.id) { 1889 host[child.id] = child 1890 } 1891 assignElementRefs(host, child) 1892 } 1893 return root 1894} 1895 1896 1897/* util/removeArrayElement.js */ 1898function removeArrayElement(array, element) { 1899 let index = array.indexOf(element) 1900 if (index >= 0) { 1901 array.splice(index, 1) 1902 } 1903} 1904 1905 1906/* util/parseStringTemplate.js */ 1907function parseStringTemplate(str, fs, fk, prefix = '{{.', suffix = '}}') { 1908 // Given "This is {{.foo}} sample {{.bar}} value", calls 1909 // - fs('This is ') 1910 // - fk('foo') 1911 // - fs(' sample ') 1912 // - fk('bar') 1913 // - fs(' value.') 1914 let remaining = str; 1915 let startIndex = 0, 1916 endIndex = 0; 1917 while ( 1918 (startIndex = remaining.indexOf(prefix)) >= 0 && 1919 (endIndex = remaining.indexOf(suffix, startIndex + prefix.length)) >= 0 1920 ) { 1921 if (startIndex > 0) { 1922 fs(remaining.substring(0, startIndex)); 1923 } 1924 fk(remaining.substring(startIndex + prefix.length, endIndex)); 1925 remaining = remaining.substring(endIndex + suffix.length); 1926 } 1927 if (remaining) { 1928 fs(remaining); 1929 } 1930} 1931 1932function mapStringTemplate(str, fs, fk) { 1933 let result = []; 1934 parseStringTemplate( 1935 str, 1936 (s) => { 1937 result.push(fs(s, result.length)); 1938 }, 1939 (s) => { 1940 result.push(fk(s, result.length)); 1941 }, 1942 ); 1943 return result; 1944} 1945 1946function parseTags(str, { validTags }) { 1947 // Given "This is <foo>sample</foo> value", returns 1948 // - ['This is ', {tag: 'foo', body: ['sample']}, ' value'] 1949 // Does not support nested tags so far. 1950 1951 var result = []; 1952 var body = result; 1953 var element = null; 1954 parseStringTemplate(str, (str) => { 1955 body.push(str); 1956 }, (rawStr) => { 1957 let innerStr = rawStr.trim(); 1958 if (innerStr === '') { 1959 body.push('(!EMPTY_TAG!)'); 1960 return; 1961 } 1962 1963 let isClosing = innerStr.startsWith('/'); 1964 let tag = isClosing ? innerStr.substring(1).trim() : innerStr; 1965 1966 if (!validTags.includes(tag)) { 1967 body.push('<' + rawStr + '>'); 1968 } else if (element != null) { 1969 if (!isClosing) { 1970 body.push('(!NESTED_TAG!)'); 1971 } else if (tag === element.tag) { 1972 body = result; 1973 element = null; 1974 } else { 1975 body.push('(!WRONG_CLOSING_TAG!)'); 1976 } 1977 } else if (isClosing) { 1978 body.push('(!UNOPENED_TAG!)'); 1979 } else { 1980 element = { tag: tag, body: [] }; 1981 body.push(element); 1982 body = element.body; 1983 } 1984 }, '<', '>'); 1985 return result; 1986} 1987 1988 1989/* util/localize.js */ 1990function localize(strings, key, vals = {}) { 1991 if (!strings.hasOwnProperty(key)) { 1992 throw new Error(`missing string "${key}"`); 1993 } 1994 let str = strings[key]; 1995 for (let k in vals) 1996 if (vals.hasOwnProperty(k)) { 1997 str = str.replaceAll("{{." + k + "}}", "" + vals[k]); 1998 } 1999 return str; 2000} 2001 2002 2003/* vm/RedeemVM.js */ 2004class RedeemVM { 2005 constructor( 2006 data, 2007 cartCost, 2008 itemCount, 2009 cartCouponCodes, 2010 desiredRedemptionAmountOrPoints, 2011 locale, 2012 overrides = {}, 2013 options = { subtractProvisionalAmount: false}, 2014 ) { 2015 data || (data = {}); 2016 let isFreeCart = 2017 data.enableWhenFree && 2018 cartCost != null && 2019 itemCount != null && 2020 cartCost == 0 && 2021 itemCount > 0; 2022 this.data = data; 2023 this.cartCost = cartCost; 2024 this.locale = locale; 2025 this.isLoaded = data && cartCost != null; // && (cartCost > 0 || isFreeCart); 2026 this.offers = (data && data.offers) || []; 2027 this.enablePointRedemption = data && data.enablePointRedemption; 2028 if (options.verbose !== false) { 2029 console.log(`[Bubblehouse] data =`, data); 2030 console.log( 2031 `[Bubblehouse] RedeemVM: cartCost = ${cartCost}, itemCount = ${itemCount}, isLoaded = ${this.isLoaded}, isFreeCart = ${isFreeCart}`, 2032 ); 2033 } 2034 if (!this.isLoaded || !data.styles) { 2035 return; 2036 } 2037 2038 this.values = data.styles.v || {}; 2039 this.classes = data.styles.c || {}; 2040 this.stylesheetUrl = data.styles.css; 2041 this.col1 = 2042 overrides.col1 || this.resolveValue(".widget.presentation.col1.derived"); 2043 this.col2 = 2044 overrides.col2 || this.resolveValue(".widget.presentation.col2.derived"); 2045 if (!["amount", "points"].includes(this.col1)) { 2046 throw new Error(
2047 `<bubblehouse-redeem-panel>: internal error: col1 = ${this.col1}`, 2048 ); 2049 } 2050 if ( 2051 !["amount", "points", "none"].includes(this.col2) || 2052 this.col2 === this.col1 2053 ) { 2054 throw new Error( 2055 `<bubblehouse-redeem-panel>: internal error: col2 = ${this.col2}`, 2056 ); 2057 } 2058 2059 this.spendablePts = data.ptsSpendable; 2060 this.spendableAmount = this.convertPointsToAmount(this.spendablePts); 2061 2062 let maxRedeemPts = data.redeemPtsMax; 2063 if (cartCost != null) { 2064 let maxOrderAmount = cartCost; 2065 if (options.subtractProvisionalAmount && data.provisionalAmount != null) { 2066 maxOrderAmount = Math.max(0, cartCost - data.provisionalAmount); 2067 // if (data.provisionalCouponCode == null || cartCouponCodes == null || !cartCouponCodes.includes(data.provisionalCouponCode)) { 2068 // } 2069 } 2070 let maxOrderPts = Math.ceil(this.convertAmountToPoints(maxOrderAmount)); 2071 maxRedeemPts = Math.min(maxRedeemPts, maxOrderPts); 2072 } 2073 let defaultRedeemPts = 2074 Number.isFinite(data.redeemPtsDefault) ? data.redeemPtsDefault : maxRedeemPts; 2075 defaultRedeemPts = Math.min(Math.max(0, defaultRedeemPts), maxRedeemPts); 2076 if (defaultRedeemPts != 0 && defaultRedeemPts < data.redeemPtsMin) { 2077 defaultRedeemPts = 0; 2078 } 2079 2080 if ( 2081 desiredRedemptionAmountOrPoints == null || 2082 !isNaN(desiredRedemptionAmountOrPoints) 2083 ) { 2084 if (this.col1 === "amount") { 2085 if (desiredRedemptionAmountOrPoints == null) { 2086 this.effectiveRedemptionPts = defaultRedeemPts; 2087 } else { 2088 this.effectiveRedemptionPts = Math.ceil( 2089 this.convertAmountToPoints(desiredRedemptionAmountOrPoints), 2090 ); 2091 } 2092 this.effectiveRedemptionAmount = this.convertPointsToAmount( 2093 this.effectiveRedemptionPts, 2094 ); 2095 } else if (this.col1 === "points") { 2096 this.effectiveRedemptionPts = 2097 desiredRedemptionAmountOrPoints == null 2098 ? defaultRedeemPts 2099 : desiredRedemptionAmountOrPoints; 2100 this.effectiveRedemptionAmount = this.convertPointsToAmount( 2101 this.effectiveRedemptionPts, 2102 ); 2103 } 2104 } else { 2105 this.effectiveRedemptionPts = null; 2106 this.effectiveRedemptionAmount = null; 2107 } 2108 2109 this.amountError = null; 2110 let applyStatusGates = 2111 options.applyStatusGates === true || 2112 options.applyCheckoutStatusGates === true; 2113 let applyDiscountCodeGate = 2114 options.applyDiscountCodeGate === true || 2115 options.applyCheckoutStatusGates === true; 2116 this.redeemDisabledMessage = 2117 applyStatusGates && data.redeemDisabledMessage != null 2118 ? data.redeemDisabledMessage 2119 : ""; 2120 this.isRedemptionBlockedByStatusGate = 2121 applyStatusGates && 2122 ( 2123 this.redeemDisabledMessage !== "" || 2124 data.enabled === false || 2125 data.enablePointRedemption === false 2126 ); 2127 this.isRedemptionBlockedByDiscountCodeGate = 2128 applyDiscountCodeGate && 2129 data.disableIfDiscountApplied === true && 2130 (cartCouponCodes || []).length > 0; 2131 this.isRedemptionBlockedByCheckoutState = 2132 this.isRedemptionBlockedByStatusGate || 2133 this.isRedemptionBlockedByDiscountCodeGate; 2134 if (this.effectiveRedemptionPts != null) { 2135 if (this.effectiveRedemptionPts < 0) { 2136 this.amountError = this.localize(".error.negative.string"); 2137 } else if ( 2138 this.effectiveRedemptionPts != 0 && 2139 this.effectiveRedemptionPts < data.redeemPtsMin 2140 ) { 2141 let valueStr; 2142 if (this.col1 === "amount") { 2143 valueStr = this.formatBareCurrency( 2144 this.convertPointsToAmount(data.redeemPtsMin), 2145 ); 2146 } else if (this.col1 === "points") { 2147 valueStr = this.formatPoints(data.redeemPtsMin); 2148 } 2149 this.amountError = this.localize(".error.below-minimum.string", { 2150 value: valueStr, 2151 }); 2152 } else if ( 2153 this.effectiveRedemptionPts != 0 && 2154 this.effectiveRedemptionPts > maxRedeemPts 2155 ) { 2156 let pts; 2157 let valueStr; 2158 2159 if (maxRedeemPts === 0 && data.redeemPtsPeriodLimit > 0) { 2160 pts = data.redeemPtsPeriodLimit 2161 } else { 2162 pts = maxRedeemPts 2163 } 2164 2165 if (this.col1 === "amount") { 2166 valueStr = this.formatBareCurrency( 2167 this.convertPointsToAmount(pts), 2168 ); 2169 } else if (this.col1 === "points") { 2170 valueStr = this.formatPoints(pts); 2171 } 2172 this.amountError = this.localize(".error.above-balance.string", { 2173 value: valueStr, 2174 }); 2175 } 2176 2177 this.visibleRedemptionPts = null; 2178 if (this.effectiveRedemptionPts != null) { 2179 this.visibleRedemptionPts = this.effectiveRedemptionPts; 2180 if (this.resolveValue('.redeem-points.presentation.enum') === 'negative') { 2181 this.visibleRedemptionPts = -this.visibleRedemptionPts; 2182 } 2183 } 2184 } 2185 2186 this.isRedeemEnabled = 2187 this.effectiveRedemptionAmount != null && 2188 (this.effectiveRedemptionAmount != 0 || isFreeCart) && 2189 this.amountError == null && 2190 !this.isRedemptionBlockedByCheckoutState; 2191 if (this.effectiveRedemptionAmount != null) { 2192 if (this.effectiveRedemptionAmount == 0 && isFreeCart) { 2193 this.redeemLabel = this.localize(".redeem-button.free.string"); 2194 } else { 2195 this.redeemLabel = this.localize(".redeem-button.active.string", { 2196 amount: this.formatCurrency(this.effectiveRedemptionAmount), 2197 pts: this.formatPoints(this.effectiveRedemptionPts), 2198 }); 2199 } 2200 } else { 2201 this.redeemLabel = this.localize(".redeem-button.disabled.string"); 2202 } 2203 2204 // if (shopDetails.isVerbose) { 2205 // console.log(`[Bubblehouse] spendableAmount = ${spendableAmount}, cartCost = ${cartCost}, defaultRedemptionAmount = ${defaultRedemptionAmount}, unroundedEffectiveRedemptionAmount = ${unroundedEffectiveRedemptionAmount}, effectiveRedemptionPts = ${effectiveRedemptionPts}, effectiveRedemptionAmount = ${effectiveRedemptionAmount}`) 2206 // } 2207 } 2208 2209 minimalCurrencyStep() { 2210 return Math.pow(10, -this.data.shopCurrencyPrecision); 2211 } 2212 formatCurrency(amount) { 2213 return ( 2214 this.data.shopCurrencyPrefix + 2215 this.formatBareCurrency(amount) + 2216 this.data.shopCurrencySuffix 2217 ); 2218 } 2219 formatPoints(pts) { 2220 if (pts < 0) { 2221 return 'â' + this.formatPoints(-pts) 2222 } 2223 return new Intl.NumberFormat(this.locale).format(pts); 2224 } 2225 formatBareCurrency(amount) { 2226 let n = this.data.shopCurrencyPrecision; 2227 return new Intl.NumberFormat(this.locale, { 2228 minimumFractionDigits: n, 2229 maximumFractionDigits: n, 2230 }).format(amount); 2231 } 2232 formatCurrencyInputValue(amount) { 2233 return amount.toFixed(this.data.shopCurrencyPrecision); 2234 } 2235 convertPointsToAmount(pts) { 2236 return Math.round(pts * this.data.ptValue * 10000) / 10000; 2237 } 2238 convertAmountToPoints(amount) { 2239 return Math.round((amount / this.data.ptValue) * 10000) / 10000; 2240 } 2241 2242 resolveValue(key) { 2243 let str = this.values[key]; 2244 if (typeof str === "undefined") { 2245 throw new Error(`missing value ${key}`); 2246 } 2247 return str; 2248 } 2249 2250 resolvePixelValue(key) { 2251 let value = this.resolveValue(key); 2252 if (value == null) { 2253 return null; 2254 } 2255 let m = value.match(/^(\d+)px$/); 2256 if (m) { 2257 return parseInt(m[1], 10); 2258 } 2259 return null; 2260 } 2261 2262 customEmbed(key, stringFunc, childrenFuncs) { 2263 let string = this.resolveValue(key) 2264 if (string === '') { 2265 return; 2266 } 2267 parseStringTemplate(string, stringFunc, (fieldKey, i) => { 2268 let child = childrenFuncs[fieldKey]; 2269 if (child) { 2270 child(); 2271 } else {
2272 stringFunc(`{{!.${fieldKey}!}}`); 2273 } 2274 }); 2275 } 2276 2277 localize(key, vals) { 2278 return localize(this.values, key, vals); 2279 } 2280} 2281 2282 2283/* conditions.js */ 2284function isConditionAttributeValueMatched(conditions) { 2285 if (!conditions) { 2286 return true 2287 } 2288 return conditions.split(/\s+/).every(isConditionMatched) 2289} 2290 2291function isConditionMatched(cond) { 2292 if (cond && cond[0] == '!') { 2293 return !isConditionMatched(cond.substr(1)) 2294 } 2295 switch (cond) { 2296 case '': return true 2297 case 'never': return false 2298 case 'tester': return !!Bubblehouse.data.tester 2299 case 'logged_in': return !!Bubblehouse.data.loggedIn 2300 case 'widget1': return !!Bubblehouse.data.widget1 2301 case 'widget2': return !!Bubblehouse.data.widget2 2302 case 'widget3': return !!Bubblehouse.data.widget3 2303 default: console.error(`[Bubblehouse] invalid condition: if="... ${cond} ..."`); return false 2304 } 2305} 2306 2307function adjustPanelVisibility(element) { 2308 let conditions = element.getAttribute('if') 2309 if (!isConditionAttributeValueMatched(conditions)) { 2310 console.log(`[Bubblehouse] hiding <${element.tagName}> because conditions do not match (${conditions})`) 2311 if (element.style.display !== 'none') { 2312 element.style.display = 'none' 2313 } 2314 return false 2315 } 2316 if (element.style.display === 'none') { 2317 element.style.display = element.getAttribute('display') || '' 2318 } 2319 if (element.style.visibility === 'hidden') { 2320 element.style.visibility = 'visible' 2321 } 2322 if (Bubblehouse.verbose) console.log(`[Bubblehouse] showing <${element.tagName}>`) 2323 return true 2324} 2325 2326 2327/* call.js */ 2328Bubblehouse.call = call; 2329 2330function call(funcName, input) { 2331 let headers = { 2332 'Content-Type': 'application/json', 2333 'Accept': 'application/json', 2334 } 2335 if (Bubblehouse.data.authToken) { 2336 headers['Authorization'] = `Bearer ${Bubblehouse.data.authToken}` 2337 } 2338 return fetch(Bubblehouse.data.callUrl.replace('FUNCNAME', funcName), { 2339 method: 'POST', 2340 body: JSON.stringify(input || {}), 2341 headers: headers, 2342 }).then(wantSuccessfulJson) 2343} 2344 2345 2346/* tags/bubblehouse-redeem-panel.js */ 2347;(function() { 2348const stylesheet = ` 2349:root { display: flex; padding: 4px } 2350.hidden { display: none !important } 2351 2352.inputItem { margin-left: 4px; margin-right: 4px } 2353.inputItem:first-child { margin-left: 0 } 2354.inputItem:last-child { margin-right: 0 } 2355 2356input[type=number].nostepper {-moz-appearance: textfield } 2357input[type=number].nostepper::-webkit-inner-spin-button, 2358input[type=number].nostepper::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0 } 2359`; 2360const UTILITY_CLASSES = { hidden: true }; 2361 2362const experimentalInplaceFlow = true; 2363 2364class BubblehouseRedeemPanel extends HTMLElement { 2365 constructor() { 2366 super(); 2367 2368 this._onCartChanged = this._onCartChanged.bind(this); 2369 this._onCartLoaded = this._onCartLoaded.bind(this); 2370 this._onCartLoadingFailed = this._onCartLoadingFailed.bind(this); 2371 this._onWidgetDataLoaded = this._onWidgetDataLoaded.bind(this); 2372 this._onWidgetDataLoadingFailed = 2373 this._onWidgetDataLoadingFailed.bind(this); 2374 this._onRedemptionAmountChange = this._onRedemptionAmountChange.bind(this); 2375 this._onRedemptionPointsChange = this._onRedemptionPointsChange.bind(this); 2376 this.redeem = this.redeem.bind(this); 2377 this.initialized = false; 2378 2379 this.redemptionPending = false; 2380 this.redemptionError = null; 2381 this.cartData = null; 2382 this.redemptionData = null; 2383 this.desiredRedemptionAmountOrPoints = null; 2384 this.stylesheetReady = false; 2385 this.cartGeneration = 0; 2386 this.statusGeneration = 0; 2387 2388 this.attachShadow({ mode: "open" }); 2389 } 2390 2391 connectedCallback() { 2392 if (!adjustPanelVisibility(this)) return; 2393 Bubblehouse.cartListeners.push(this._onCartChanged); 2394 this.reload("initial"); 2395 this.render(); 2396 } 2397 2398 disconnectedCallback() { 2399 removeArrayElement(Bubblehouse.cartListeners, this._onCartChanged); 2400 } 2401 2402 cartCouponCodes() { 2403 if (!this.cartData) { 2404 return null; 2405 } 2406 return (this.cartData.cart_level_discount_applications || []) 2407 .filter((da) => da.type === "discount_code") 2408 .map((da) => da.title) 2409 } 2410 2411 _collectCheckoutContext() {
2412 let context = { 2413 site: "cart", 2414 country: Shopify.country, 2415 currency: Shopify.currency && Shopify.currency.active, 2416 locale: (Shopify.locale || "").trim(), 2417 timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, 2418 2419 discountCodesKnown: true, 2420 discountCodes: this.cartCouponCodes(), 2421 }; 2422 if (this.cartData) { 2423 context.cartSubtotal = (this.cartData.total_price / 100).toFixed(6); 2424 } 2425 return context; 2426 } 2427 2428 reload(why) { 2429 this.reloadCart(); 2430 this.reloadStatus(why); 2431 } 2432 2433 reloadCart() { 2434 let generation = ++this.cartGeneration; 2435 loadCart().then( 2436 (cartData) => this._onCartLoaded(cartData, generation), 2437 (error) => this._onCartLoadingFailed(error, generation), 2438 ); 2439 } 2440 2441 reloadStatus(why) { 2442 if (!this.cartData) { 2443 return; 2444 } 2445 let generation = ++this.statusGeneration; 2446 call("CheckoutStatus1", { 2447 ...this._collectCheckoutContext(), 2448 }).then( 2449 (redemptionData) => this._onWidgetDataLoaded(redemptionData, generation), 2450 (error) => this._onWidgetDataLoadingFailed(error, generation), 2451 ); 2452 } 2453 2454 render(origin) { 2455 // if (Bubblehouse.verbose) console.log(`<${this.tagName}> rendering:\n\ncartData = ${JSON.stringify(this.cartData, null, 2)}\n\nredemptionData = ${JSON.stringify(this.redemptionData, null, 2)}`); 2456 2457 let vm = (this.vm = new RedeemVM( 2458 this.redemptionData, 2459 this.cartData ? this.cartData.total_price / 100 : null, 2460 this.cartData ? this.cartData.item_count : null, 2461 this.cartCouponCodes(), 2462 this.desiredRedemptionAmountOrPoints, 2463 undefined, 2464 {}, 2465 { subtractProvisionalAmount: false, applyStatusGates: true } 2466 )); 2467 2468 let isContentVisible = vm.isLoaded && !vm.data?.hide; 2469 let isStatusVisible = Boolean(vm.data?.hide && vm.data?.hideMessage); 2470 let isRootVisible = isContentVisible || isStatusVisible; 2471 let isDisabled = isStatusVisible || vm.isRedemptionBlockedByCheckoutState; 2472 2473 if (!this.initialized && vm.stylesheetUrl) { 2474 this.initialized = true; 2475 if (Bubblehouse.verbose) { 2476 console.log( 2477 `[Bubblehouse] <${this.tagName}> initialized with vm: `, 2478 vm, 2479 ); 2480 } 2481 2482 let panelCells = []; 2483 if (vm.col1 === "amount") { 2484 panelCells.push(h("div#availableAmount")); 2485 } else if (vm.col1 === "points") { 2486 panelCells.push(h("div#availablePoints")); 2487 } 2488 if (vm.col2 === "points") { 2489 panelCells.push(h("div#availablePoints")); 2490 } else if (vm.col2 === "amount") { 2491 panelCells.push(h("div#availableAmount")); 2492 } 2493 if (vm.col1 === "amount") { 2494 panelCells.push(h("div#redemptionAmountCell")); 2495 } else if (vm.col1 === "points") { 2496 panelCells.push(h("div#redemptionPtsCell")); 2497 } 2498 if (vm.col2 === "points") { 2499 panelCells.push(h("div#redemptionPtsCell")); 2500 } else if (vm.col2 === "amount") { 2501 panelCells.push(h("div#redemptionAmountCell")); 2502 } 2503 2504 let logo = null; 2505 let logoUrl = vm.values[".logo.image"]; 2506 if (logoUrl) { 2507 logo = h("img#logoImg", { src: logoUrl, alt: "" }); 2508 } 2509 2510 let stylesheetEl = h("link", { rel: "stylesheet", href: vm.stylesheetUrl }); 2511 stylesheetEl.addEventListener("load", () => { 2512 this.stylesheetReady = true; 2513 this.render("stylesheet_loaded"); 2514 }); 2515 this.shadowRoot.append( 2516 h("style", stylesheet), 2517 stylesheetEl, 2518 h( 2519 "div#rootPanel", 2520 { style: { display: "none" } }, 2521 logo, 2522 h("div#amountsPanel", panelCells), 2523 h(`button#redeemButton`, { 2524 type: "button", 2525 onclick: this.redeem, 2526 }), 2527 h("div#statusPanel", h("span#statusMessageSpan")), 2528 ), 2529 ); 2530 assignElementRefs(this, this.shadowRoot); 2531 this.setRootPanelVisible(false); 2532 2533 this.applyClass(this.rootPanel, "widget"); 2534 this.applyClass(this.logoImg, "logo"); 2535 this.applyClass(this.statusPanel, "message-panel"); 2536 this.applyClass(this.amountsPanel, "amounts-panel"); 2537 this.applyClass(this.redeemButton, "redeem-button");
2538 this.applyClass(this.availableAmount, "available-amount"); 2539 this.applyClass(this.availablePoints, "available-points"); 2540 this.applyClass(this.redemptionAmountCell, "redeem-amount"); 2541 this.applyClass(this.redemptionPtsCell, "redeem-points"); 2542 2543 this.embedChildren( 2544 this.availableAmount, 2545 ".available-amount.overall.string", 2546 { 2547 value: () => h("span#availableAmountValue"), 2548 }, 2549 ); 2550 this.applyClass(this.availableAmountValue, "available-amount-value"); 2551 2552 this.embedChildren( 2553 this.availablePoints, 2554 ".available-points.overall.string", 2555 { 2556 value: () => h("span#availablePointsValue"), 2557 }, 2558 ); 2559 this.applyClass(this.availablePointsValue, "available-points-value"); 2560 2561 if (vm.col1 === "amount") { 2562 this.embedChildren( 2563 this.redemptionAmountCell, 2564 ".redeem-amount.overall.string", 2565 { 2566 value: () => 2567 h( 2568 "span#redemptionAmountValue.inputItem", 2569 {}, 2570 h( 2571 "span#redemptionAmountPrefixSpan", 2572 vm.data.shopCurrencyPrefix, 2573 ), 2574 h("input#redemptionInput.nostepper", { 2575 type: "number", 2576 min: "0", 2577 step: "" + vm.minimalCurrencyStep(), 2578 placeholder: vm.formatBareCurrency(0), 2579 onchange: this._onRedemptionAmountChange, 2580 oninput: this._onRedemptionAmountChange, 2581 }), 2582 h( 2583 "span#redemptionAmountSuffixSpan", 2584 vm.data.shopCurrencySuffix, 2585 ), 2586 ), 2587 }, 2588 ); 2589 this.embedChildren( 2590 this.redemptionPtsCell, 2591 ".redeem-points.overall.string", 2592 { 2593 value: () => h("span#redemptionPtsValueSpan"), 2594 }, 2595 ); 2596 this.detailRedemptionCell = this.redemptionPtsCell; 2597 } else if (vm.col1 === "points") { 2598 this.embedChildren( 2599 this.redemptionPtsCell, 2600 ".redeem-points.overall.string", 2601 { 2602 value: () => 2603 h( 2604 "span#redemptionPtsValueSpan.inputItem", 2605 h("input#redemptionInput.nostepper", { 2606 type: "number", 2607 min: "0", 2608 onchange: this._onRedemptionPointsChange, 2609 oninput: this._onRedemptionPointsChange, 2610 }), 2611 ), 2612 }, 2613 ); 2614 this.embedChildren( 2615 this.redemptionAmountCell, 2616 ".redeem-amount.overall.string", 2617 { 2618 value: () => h("span#redemptionAmountValue"), 2619 }, 2620 ); 2621 this.detailRedemptionCell = this.redemptionAmountCell; 2622 } 2623 this.applyClass(this.redemptionInput, "input-field"); 2624 this.applyClass(this.redemptionAmountValue, "redeem-amount-value"); 2625 this.applyClass(this.redemptionPtsValueSpan, "redeem-points-value"); 2626 } 2627 if (!this.initialized) { 2628 return; 2629 } 2630 2631 this.applyClass(this.logoImg, { 2632 "logo--enabled": !isDisabled, 2633 "logo--disabled": isDisabled, 2634 }); 2635 // this.applyClass(this.logoImg, , isDisabled); 2636 this.applyClass(this.rootPanel, { 2637 "widget--enabled": !isDisabled, 2638 "widget--disabled": isDisabled, 2639 }); 2640 this.applyClass(this.statusPanel, { 2641 "message-panel--enabled": !isDisabled, 2642 "message-panel--disabled": isDisabled, 2643 }); 2644 this.applyClass(this.amountsPanel, { hidden: !isContentVisible }); 2645 this.applyClass(this.redeemButton, { hidden: !isContentVisible }); 2646 2647 console.log( 2648 `[Bubblehouse] <${this.tagName}> render: isContentVisible = ${isContentVisible}, vm.data?.hide = ${vm.data?.hide}, this.amountsPanel.className = ${this.amountsPanel.className}, this.redeemButton.className = ${this.redeemButton.className}`, 2649 ); 2650 2651 if (vm.data?.hide) { 2652 this._renderStatusMessage(vm.data.hideMessage, vm.data.actions); 2653 this.setRootPanelVisible(isRootVisible); 2654 return; 2655 } else if (!vm.isLoaded) { 2656 this._renderStatusMessage(""); 2657 this.setRootPanelVisible(isRootVisible); 2658 return; 2659 } 2660
2661 if (this.availableAmountValue) { 2662 this.availableAmountValue.textContent = vm.formatCurrency( 2663 vm.spendableAmount, 2664 ); 2665 } 2666 if (this.availablePointsValue) { 2667 this.availablePointsValue.textContent = vm.formatPoints(vm.spendablePts); 2668 } 2669 if (this.detailRedemptionCell) { 2670 this.detailRedemptionCell.classList.toggle( 2671 "hidden", 2672 vm.visibleRedemptionPts == null, 2673 ); 2674 } 2675 if (this.redemptionPtsValueSpan && vm.col2 === "points") { 2676 this.redemptionPtsValueSpan.textContent = 2677 vm.visibleRedemptionPts != null 2678 ? vm.formatPoints(vm.visibleRedemptionPts) 2679 : ""; 2680 } 2681 if (this.redemptionAmountValue && vm.col2 === "amount") { 2682 this.redemptionAmountValue.textContent = 2683 vm.effectiveRedemptionAmount != null 2684 ? vm.formatCurrency(vm.effectiveRedemptionAmount) 2685 : ""; 2686 } 2687 if (this.shadowRoot.activeElement !== this.redemptionInput) { 2688 if (vm.col1 === "amount" && vm.effectiveRedemptionAmount != null) { 2689 this.redemptionInput.value = vm.formatBareCurrency( 2690 vm.effectiveRedemptionAmount, 2691 ); 2692 } else if (vm.col1 === "points" && vm.effectiveRedemptionPts != null) { 2693 this.redemptionInput.value = "" + vm.effectiveRedemptionPts; 2694 } 2695 } 2696 this.redeemButton.textContent = (this.redemptionPending ? vm.localize(".message.redeeming.string") : vm.redeemLabel); 2697 this.redeemButton.disabled = this.redemptionPending || !vm.isRedeemEnabled; 2698 2699 this.redemptionInput.setCustomValidity(vm.amountError || ""); 2700 2701 if (this.redemptionPending) { 2702 this._renderStatusMessage(""); 2703 } else if (vm.redeemDisabledMessage) { 2704 this._renderStatusMessage(vm.redeemDisabledMessage); 2705 } else if (this.redemptionError) { 2706 this._renderStatusMessage(this.redemptionError); 2707 } else { 2708 this._renderStatusMessage(""); 2709 } 2710 this.setRootPanelVisible(isRootVisible); 2711 } 2712 2713 redeem() { 2714 if (this.redemptionPending) { 2715 return; 2716 } 2717 this.redemptionPending = true; 2718 this.redemptionError = null; 2719 this.render(); 2720 2721 this._redeemAsync() 2722 .then(() => { 2723 if (this.redemptionData.useAdvancedIntegration) { 2724 this.redemptionPending = false; 2725 } else { 2726 // don't mark as done -- we're navigating away 2727 } 2728 this.redemptionError = null; 2729 this.render(); 2730 }) 2731 .catch((error) => { 2732 reportError(error, "redeem"); 2733 this.redemptionPending = false; 2734 this.redemptionError = error.toString(); 2735 this.render(); 2736 }); 2737 } 2738 2739 applyClass(el, ...args) { 2740 if (!el) { 2741 return; 2742 } 2743 for (let arg of args) { 2744 if (typeof arg === 'string') { 2745 this._toggleSingleClass(el, arg, true); 2746 } else if (typeof arg === 'object') { 2747 for (let [shortClassName, isOn] of Object.entries(arg)) { 2748 this._toggleSingleClass(el, shortClassName, isOn); 2749 } 2750 } 2751 } 2752 } 2753 2754 setRootPanelVisible(isVisible) { 2755 this.rootPanel.classList.toggle("hidden", !isVisible); 2756 if (isVisible) { 2757 if (this.stylesheetReady) { 2758 this.rootPanel.style.removeProperty("display"); 2759 } else { 2760 this.rootPanel.style.setProperty("display", "none"); 2761 } 2762 } else { 2763 this.rootPanel.style.setProperty("display", "none", "important"); 2764 } 2765 } 2766 2767 _toggleSingleClass(el, shortClassName, isOn) { 2768 if (UTILITY_CLASSES[shortClassName]) { 2769 el.classList.toggle(shortClassName, isOn); 2770 } else if (this.vm.data) { 2771 let className = this.vm.classes[shortClassName]; 2772 if (!className) { 2773 throw new Error(`missing theme class ${shortClassName}`); 2774 } 2775 el.classList.toggle(className, isOn); 2776 } 2777 } 2778 2779 embedChildren(el, stringKey, childrenKeys) { 2780 if (!el) { 2781 return; 2782 } 2783 parseStringTemplate( 2784 this.vm.resolveValue(stringKey), 2785 (staticStr) => { 2786 el.append(h("span", {}, staticStr)); 2787 }, 2788 (fieldKey) => { 2789 let child = childrenKeys[fieldKey]; 2790 if (typeof child === "undefined") { 2791 el.append(`{{!.${fieldKey}!}}`); 2792 } else { 2793 if (typeof child === "function") { 2794 child = child(); 2795 } 2796 appendChild(el, child); 2797 } 2798 }, 2799 ); 2800 assignElementRefs(this, el); 2801 } 2802 2803 async _redeemAsync() { 2804 if (this.vm.effectiveRedemptionAmount == null) { 2805 throw new Error("Invalid amount"); 2806 } 2807 if (this.vm.effectiveRedemptionAmount == 0) { 2808 if (!this.redemptionData.useAdvancedIntegration) { 2809 window.location.href = `/checkout`; 2810 } 2811 return; 2812 } 2813 let input = { 2814 amount: `${this.vm.effectiveRedemptionAmount.toFixed(6)}`, 2815 canRemove: true, 2816 ...this._collectCheckoutContext(), 2817 }; 2818 let output = await call("RedeemCheckoutReward1", input); 2819 if (Bubblehouse.verbose) 2820 console.log( 2821 `[Bubblehouse] <${this.tagName}> redeem input = ${JSON.stringify(input)}, output = ${JSON.stringify(output)}`, 2822 ); 2823 2824 if (output.error) { 2825 throw new Error(output.message || output.error); 2826 } 2827 2828 if (output.newStatus) { 2829 this.statusGeneration++; 2830 this.redemptionData = output.newStatus; 2831 } 2832 2833 if (output.type === "discount_code" || output.type === "gift_card") { 2834 await this._addDiscountCode({ type: output.type, code: output.code }, output.removeCodes); 2835 } else { 2836 throw new Error( 2837 `Unknown type of reward: ${output.type} (full response: ${JSON.stringify(output)})`, 2838 ); 2839 } 2840 this.desiredRedemptionAmountOrPoints = null; 2841 2842 this.reloadCart(); 2843 } 2844 2845 async _addDiscountCode(coupon, removeCodes) { 2846 if (isShopifyStorefrontApiAvailable()) { 2847 await addDiscountCode(this.cartData.token, coupon.code, removeCodes); 2848 this._sendToDestination(null); 2849 } else { 2850 this._sendToDestination(coupon); 2851 } 2852 } 2853 2854 _sendToDestination(coupon) { 2855 this.dispatchEvent( 2856 new CustomEvent("bubblehouse:cart-updated", { 2857 detail: {}, 2858 bubbles: true, 2859 }), 2860 ); 2861 if (this.redemptionData.useAdvancedIntegration) { 2862 return; 2863 } 2864 let destination = this.redemptionData.sendToCart ? "/cart" : "/checkout"; 2865 if (!coupon) { 2866 window.location.href = destination; 2867 } else if (coupon.type === "discount_code" && this.redemptionData.useShopifyDiscountPath) { 2868 window.location.href = `/discount/${encodeURIComponent(coupon.code)}?redirect=${destination}`; 2869 } else { 2870 window.location.href = `${destination}?discount=${encodeURIComponent(coupon.code)}`; 2871 } 2872 } 2873 2874 _onRedemptionAmountChange(ev) { 2875 let amount = parseFloat(this.redemptionInput.value || ""); 2876 if (isNaN(amount) || !isFinite(amount)) { 2877 amount = NaN; // null would mean default; NaN means custom but invalid 2878 } else { 2879 amount = this.vm.convertPointsToAmount( 2880 Math.ceil(this.vm.convertAmountToPoints(amount)), 2881 ); 2882 } 2883 this.desiredRedemptionAmountOrPoints = amount; 2884 this.render(); 2885 this.redemptionInput.reportValidity(); 2886 } 2887 2888 _onRedemptionPointsChange(ev) { 2889 let pts = parseInt(this.redemptionInput.value || "", 10); 2890 this.desiredRedemptionAmountOrPoints = pts; 2891 this.render(); 2892 this.redemptionInput.reportValidity(); 2893 } 2894 2895 _renderStatusMessage(message, actions = null) { 2896 if (message) { 2897 this.statusPanel.classList.remove("hidden"); 2898 if (actions) { 2899 applyContentWithActions(this.statusMessageSpan, message, actions); 2900 } else { 2901 this.statusMessageSpan.textContent = message; 2902 } 2903 } else { 2904 this.statusPanel.classList.add("hidden"); 2905 this.statusMessageSpan.textContent = ""; 2906 } 2907 } 2908 2909 _onCartChanged(cartData, cartAction) { 2910 this.cartGeneration++; 2911 this.cartData = cartData; 2912 this.reloadStatus("cart_updated"); 2913 this.render(); 2914 } 2915 2916 _onCartLoaded(cartData, generation) { 2917 if (generation !== this.cartGeneration) { 2918 return; 2919 } 2920 this.cartData = cartData; 2921 this.reloadStatus("cart_loaded"); 2922 this.render(); 2923 } 2924 _onCartLoadingFailed(error, generation) { 2925 if (generation !== this.cartGeneration) { 2926 return; 2927 } 2928 reportWarning(error, "cannot load cart"); 2929 this.render(); 2930 } 2931 2932 _onWidgetDataLoaded(redemptionData, generation) { 2933 if (generation !== this.statusGeneration) { 2934 return; 2935 } 2936 this.redemptionData = redemptionData; 2937 this.render(); 2938 } 2939 _onWidgetDataLoadingFailed(error, generation) { 2940 if (generation !== this.statusGeneration) { 2941 return; 2942 } 2943 reportWarning(error, "cannot load redemption data"); 2944 this.render(); 2945 } 2946} 2947 2948function applyContentWithActions(el, contentStr, actions) { 2949 let validTags = Object.keys(actions).map((k) => 'bhaction:' + k); 2950 let root = parseTags(contentStr, { validTags }); 2951 console.log(`[Bubblehouse] applyContentWithActions: str = ${JSON.stringify(contentStr)}, actions = ${JSON.stringify(actions)}, content = ${JSON.stringify(root, null, 2)}`) 2952 2953 while (el.firstChild) { 2954 el.removeChild(el.firstChild); 2955 } 2956 2957 appendRenderTagContent(el, root, actions); 2958} 2959
2960function appendRenderTagContent(el, content, actions) { 2961 for (let item of content) { 2962 appendRenderTagItem(el, item, actions); 2963 } 2964} 2965 2966function appendRenderTagItem(el, item, actions) { 2967 if (typeof item === 'string') { 2968 el.appendChild(document.createTextNode(item)); 2969 } else { 2970 let actionName = item.tag.replace(/^bhaction:/, '') 2971 let action = actions[actionName] 2972 if (!action) { 2973 el.appendChild(document.createTextNode('(!TAG!)')); 2974 appendRenderTagContent(el, item.body, actions); 2975 return 2976 } 2977 let actionEl = document.createElement('a'); 2978 if (action.jscall) { 2979 actionEl.addEventListener('click', (ev) => { 2980 if (runActionJSCall(item.tag, action.jscall)) { 2981 ev.preventDefault(); 2982 } 2983 }); 2984 } 2985 actionEl.setAttribute('part', 'actionlink'); 2986 if (action.nav) { 2987 actionEl.href = action.nav.url; 2988 let target = actionNavigationTarget(action.nav.target); 2989 if (target) { 2990 actionEl.target = target; 2991 } 2992 } 2993 el.appendChild(actionEl); 2994 appendRenderTagContent(actionEl, item.body, actions); 2995 } 2996} 2997 2998function runActionJSCall(tag, call) { 2999 let name = call.func; 3000 let func = window[name]; 3001 if (typeof func !== "function") { 3002 console.error(`[Bubblehouse] <${tag}>: global function ${name} not found`); 3003 return false; 3004 } 3005 try { 3006 func(); 3007 return true; 3008 } catch (error) { 3009 reportError(error, `<${tag}>`); 3010 return false; 3011 } 3012} 3013 3014function actionNavigationTarget(target) { 3015 switch (target) { 3016 case "new": 3017 case "_blank": 3018 return "_blank"; 3019 case "top": 3020 case "_top": 3021 return "_top"; 3022 case "same": 3023 case "": 3024 case null: 3025 case undefined: 3026 return ""; 3027 default: 3028 console.error(`[Bubblehouse] unsupported action navigation target ${target}`); 3029 return ""; 3030 } 3031} 3032 3033customElements.define("bubblehouse-redeem-panel", BubblehouseRedeemPanel); 3034})() || true; 3035 3036/* variantChangeEvent.js */ 3037;(function() { 3038if (isShopifyProductPage()) { 3039 window.addEventListener("load", () => { 3040 observeElements('[name="id"]', observeAndDispatchVariantChange) 3041 }) 3042} 3043 3044function observeAndDispatchVariantChange(input) { 3045 let observer = new MutationObserver((mutations) => { 3046 for (const mutation of mutations) { 3047 if (mutation.type === 'attributes' && mutation.attributeName === 'value') { 3048 window.dispatchEvent( 3049 new CustomEvent('bubblehouse:variant-change', { detail: { variantId: mutation.target.value } }) 3050 ) 3051 } 3052 } 3053 }) 3054 3055 observer.observe(input, { 3056 attributes: true, 3057 attributeFilter: ['value'], 3058 }) 3059} 3060})() || true; 3061 3062/* bigcommerce/api.js */ 3063;(function() { 3064window.Bubblehouse.BigCommerce ??= {}; 3065window.Bubblehouse.BigCommerce.getToken = getToken; 3066window.Bubblehouse.BigCommerce.loadCart = loadCart; 3067window.Bubblehouse.BigCommerce.createCart = createCart; 3068window.Bubblehouse.BigCommerce.addToCart = addToCart; 3069window.Bubblehouse.BigCommerce.updateCartItem = updateCartItem; 3070window.Bubblehouse.BigCommerce.deleteCartItem = deleteCartItem; 3071window.Bubblehouse.BigCommerce.deleteCart = deleteCart; 3072window.Bubblehouse.BigCommerce.getCheckout = getCheckout; 3073window.Bubblehouse.BigCommerce.applyCouponToCheckout = applyCouponToCheckout; 3074 3075async function getToken(appClientId) { 3076 return fetch(`/customer/current.jwt?app_client_id=${appClientId}`).then( 3077 (response) => response.text()); 3078} 3079 3080function loadCart() { 3081 return fetch("/api/storefront/carts?include=lineItems.physicalItems.options,lineItems.digitalItems.options", { 3082 method: "GET", 3083 credentials: "same-origin", 3084 }).then(wantSuccessfulJson); 3085} 3086 3087function createCart(lineItems) { 3088 let body = { lineItems }; 3089 console.log("[BH] [DEBUG] {BigCommerce} creating BigCommerce cart:", body); 3090 return fetch("/api/storefront/carts", { 3091 method: "POST", 3092 credentials: "same-origin", 3093 headers: { "Content-Type": "application/json" }, 3094 body: JSON.stringify(body), 3095 }).then(wantSuccessfulJson); 3096} 3097 3098function addToCart(cartId, lineItems, { origin } = {}) { 3099 let body = { lineItems }; 3100 console.log(`[BH] [DEBUG] {BigCommerce} adding to BigCommerce cart (${origin}):`, body); 3101 return fetch(`/api/storefront/carts/${cartId}/items`, { 3102 method: "POST", 3103 credentials: "same-origin", 3104 headers: { "Content-Type": "application/json" }, 3105 body: JSON.stringify(body), 3106 }).then(wantSuccessfulJson); 3107} 3108 3109function updateCartItem(cartId, itemId, lineItem, { origin } = {}) { 3110 let body = { lineItem }; 3111 console.log(`[BH] [DEBUG] {BigCommerce} updating BigCommerce cart item (${origin}):`, body); 3112 return fetch(`/api/storefront/carts/${cartId}/items/${itemId}`, { 3113 method: "PUT", 3114 credentials: "same-origin", 3115 headers: { "Content-Type": "application/json" }, 3116 body: JSON.stringify(body), 3117 }).then(wantSuccessfulJson); 3118} 3119 3120function deleteCartItem(cartId, itemId, { origin } = {}) { 3121 console.log(`[BH] [DEBUG] {BigCommerce} deleting BigCommerce cart item (${origin}):`, itemId); 3122 return fetch(`/api/storefront/carts/${cartId}/items/${itemId}`, { 3123 method: "DELETE", 3124 credentials: "same-origin", 3125 }).then(wantSuccessfulJson); 3126} 3127 3128function deleteCart(cartId, { origin } = {}) { 3129 console.log(`[BH] [DEBUG] {BigCommerce} deleting BigCommerce cart (${origin}):`, cartId); 3130 return fetch(`/api/storefront/carts/${cartId}`, { 3131 method: "DELETE", 3132 credentials: "same-origin", 3133 }).then(wantSuccessfulJson); 3134} 3135 3136async function getCheckout(checkoutId) { 3137 return fetch(`/api/storefront/checkouts/${checkoutId}`, { 3138 method: "GET", 3139 credentials: "same-origin", 3140 headers: { "Content-Type": "application/json" }, 3141 }).then(wantSuccessfulJson); 3142} 3143 3144async function applyCouponToCheckout(checkoutId, couponCode) { 3145 console.log(`[BH] [DEBUG] {BigCommerce}
3145 applying coupon to BigCommerce checkout: ${couponCode}`); 3146 let body = { couponCode }; 3147 return fetch(`/api/storefront/checkouts/${checkoutId}/coupons`, { 3148 method: "POST", 3149 credentials: "same-origin", 3150 headers: { "Content-Type": "application/json" }, 3151 body: JSON.stringify(body), 3152 }).then(wantSuccessfulJson); 3153} 3154})() || true; 3155 3156/* woocommerce/api.js */ 3157window.Bubblehouse ??= {}; 3158window.Bubblehouse.WooCommerce ??= {}; 3159 3160window.Bubblehouse.WooCommerce.loadCart = async function() { 3161 return await fetch(`/wp-json/wc/store/v1/cart`, { 3162 credentials: 'include', 3163 }) 3164 .then(async (response) => ({ 3165 ...await response.json(), 3166 token: response.headers.get("cart-token"), 3167 })) 3168 .catch((err) => { 3169 console.error(`[BH] {WooCommerce} Unable to load cart: ${err}`); 3170 return null; 3171 }); 3172} 3173 3174window.Bubblehouse.WooCommerce.applyCoupon = async function(_token, code) { 3175 // NOTE (@seletskiy): Detect version of checkout used (older vs newer WooCommerce). 3176 // 'bh-discounts' namespace and handler is defined in bubblehouse-woocomm PHP extension. 3177 var isBlockCheckout = ( 3178 typeof window.wc !== 'undefined' && 3179 window.wc.blocksCheckout && 3180 typeof window.wc.blocksCheckout.extensionCartUpdate === 'function' 3181 ); 3182 3183 if (isBlockCheckout) { 3184 try { 3185 await window.wc.blocksCheckout.extensionCartUpdate({ 3186 namespace: 'bh-discounts', 3187 data: { coupon_code: code }, 3188 }); 3189 3190 console.log(`[BH] [DEBUG] {applyCoupon} Block Checkout - coupon applied`); 3191 } catch (err) { 3192 console.log(`[BH] [DEBUG] {applyCoupon} Block Checkout - error: ${err}`); 3193 }; 3194 } else { 3195 jQuery.ajax({ 3196 type: 'POST', 3197 url: woocommerce_params.ajax_url, 3198 data: {
3199 action: 'bh_apply_coupon', 3200 coupon_code: code, 3201 }, 3202 success: () => { 3203 $(document.body).trigger('update_checkout'); 3204 console.log(`[BH] [DEBUG] {applyCoupon} Classic Checkout - coupon applied`) 3205 }, 3206 error: (err) => { 3207 console.log(`[BH] [DEBUG] {applyCoupon} Classic Checkout - error: ${err}`) 3208 }, 3209 }); 3210 } 3211} 3212 3213window.Bubblehouse.WooCommerce.removeCoupon = async function(token, code) { 3214 return await fetch(`/wp-json/wc/store/v1/cart/coupons/${code}`, { 3215 method: 'DELETE', 3216 credentials: 'include', 3217 headers: { 3218 'Content-Type': 'application/json', 3219 'Cart-Token': token, 3220 }, 3221 }) 3222 .then((response) => response.json()) 3223 .catch((err) => { 3224 console.error(`[BH] {WooCommerce} Unable to remove coupon: ${err}`); 3225 return null; 3226 }); 3227} 3228 3229window.Bubblehouse.WooCommerce.removeAllCoupons = async function(token) { 3230 return await fetch(`/wp-json/wc/store/v1/cart/coupons`, { 3231 method: 'DELETE', 3232 credentials: 'include', 3233 headers: { 3234 'Content-Type': 'application/json', 3235 'Cart-Token': token, 3236 }, 3237 }) 3238 .then((response) => response.json()) 3239 .catch((err) => { 3240 console.error(`[BH] {WooCommerce} Unable to remove all coupons: ${err}`); 3241 return null; 3242 }); 3243} 3244 3245window.Bubblehouse.WooCommerce.addItem = async function(token, productId, quantity) { 3246 return await fetch(`/wp-json/wc/store/v1/cart/add-item`, { 3247 method: 'POST', 3248 credentials: 'include', 3249 headers: { 3250 'Content-Type': 'application/json', 3251 'Cart-Token': token, 3252 }, 3253 body: JSON.stringify({ id: productId, quantity }), 3254 }) 3255 .then((response) => response.json()) 3256 .catch((err) => { 3257 console.error(`[BH] {WooCommerce} Unable to add item: ${err}`); 3258 return null; 3259 }); 3260} 3261 3262window.Bubblehouse.WooCommerce.updateItem = async function(token, key, quantity) { 3263 return await fetch(`/wp-json/wc/store/v1/cart/update-item`, { 3264 method: 'POST', 3265 credentials: 'include', 3266 headers: { 3267 'Content-Type': 'application/json', 3268 'Cart-Token': token, 3269 }, 3270 body: JSON.stringify({ key, quantity }), 3271 }) 3272 .then((response) => response.json()) 3273 .catch((err) => { 3274 console.error(`[BH] {WooCommerce} Unable to update item: ${err}`); 3275 return null; 3276 }); 3277} 3278 3279 3280/* cart.js */ 3281;(function() { 3282function CartApi() { 3283 const providers = { 3284 BigCommerce: { 3285 load: async () => { 3286 const carts = await Bubblehouse.BigCommerce.loadCart() 3287 if (!carts) { 3288 console.error(`[BH] {CartApi/BigCommerce} Unable to load carts.`); 3289 return null; 3290 } 3291 3292 if (carts?.length < 1) { 3293 console.warn(`[BH] {CartApi/BigCommerce} No active carts found.`); 3294 return { id: null, items: [] }; 3295 } 3296 3297 const cart = carts[0]; 3298 3299 return { 3300 id: cart.id, 3301 items: [ 3302 ...cart.lineItems.physicalItems.map((item) => ({ 3303 id: item.id, 3304 variant: { id: item.variantId }, 3305 product: { id: item.productId }, 3306 })), 3307 ] 3308 } 3309 }, 3310 3311 add: async (cartId, { products, discount }, opts) => { 3312 if (products) { 3313 const items = products.map(({ product, quantity, variant, options }) => ({ 3314 productId: product?.id, 3315 variantId: variant?.id, 3316 quantity, 3317 optionSelections: options 3318 ? Object.entries(options).map(([k, v]) => ({ optionId: k, optionValue: v })) 3319 : [], 3320 })); 3321 3322 if (cartId) { 3323 await Bubblehouse.BigCommerce.addToCart(cartId, items, opts); 3324 } else { 3325 const { id } = await Bubblehouse.BigCommerce.createCart(items); 3326 cartId = id; 3327 } 3328 } 3329 3330 if (discount) { 3331 await Bubblehouse.BigCommerce.applyCouponToCheckout(cartId, discount.code) 3332 } 3333 }, 3334 3335 update: async (cartId, { items }, opts) => { 3336 return await Promise.all(items.map(({ id, variant, product, quantity }) => 3337 Bubblehouse.BigCommerce.updateCartItem( 3338 cartId, 3339 id, 3340 { 3341 productId: product?.id, 3342 variantId: variant?.id, 3343 quantity, 3344 }, 3345 opts, 3346 ), 3347 )) 3348 }, 3349 }, 3350 3351 Shopify: { 3352 load: async () => { 3353 const cart = await Bubblehouse.loadCart(); 3354 if (!cart) { 3355 console.error(`[BH] {CartApi/Shopify} Unable to load cart.`); 3356 return null; 3357 } 3358 3359 return { 3360 id: cart.token, 3361 items: cart.items.map((item) => ({ 3362 id: item.key, 3363 variant: { id: item.variant_id }, 3364 product: { id: item.product_id }, 3365 })) 3366 } 3367 }, 3368 3369 add: async (cartId, { products, discount }, opts) => { 3370 if (products) { 3371 const items = products.map(({ variant, quantity }) => ({ 3372 id: variant?.id, 3373 quantity, 3374 })); 3375 3376 await Bubblehouse.addToCart(items, opts); 3377 } 3378 3379 if (discount) { 3380 await Bubblehouse.addDiscountCode(cartId, discount.code); 3381 } 3382 }, 3383 3384 update: async (_cartId, { items }, opts) => { 3385 const updates = {};
3386 items.forEach(({ id, quantity }) => { 3387 updates[id] = quantity; 3388 }); 3389 3390 return await Bubblehouse.updateCart(updates, opts); 3391 }, 3392 }, 3393 3394 WooCommerce: { 3395 load: async () => { 3396 const cart = await Bubblehouse.WooCommerce.loadCart(); 3397 if (!cart) { 3398 console.error(`[BH] {CartApi/WooCommerce} Unable to load cart.`); 3399 return null; 3400 } 3401 3402 return { 3403 id: cart.token, 3404 items: cart.items?.map((item) => ({ 3405 id: item.key, 3406 variant: { id: item.id?.toString() }, 3407 product: { id: item.id?.toString() }, 3408 })) ?? [] 3409 } 3410 }, 3411 3412 add: async (token, { products, discount }, _opts) => { 3413 if (products) { 3414 for (const { product, quantity } of products) { 3415 await Bubblehouse.WooCommerce.addItem(token, product?.id, quantity); 3416 } 3417 } 3418 3419 if (discount) { 3420 await Bubblehouse.WooCommerce.applyCoupon(token, discount.code); 3421 } 3422 }, 3423 3424 update: async (token, { items }, _opts) => { 3425 for (const { id, quantity } of items) { 3426 await Bubblehouse.WooCommerce.updateItem(token, id, quantity); 3427 } 3428 }, 3429 } 3430 } 3431 3432 const isBigCommerce = window.BCData !== undefined; 3433 const isShopify = window.Shopify !== undefined; 3434 const isWooCommerce = window.wc !== undefined; 3435 3436 if (isBigCommerce) { 3437 console.log("[BH] {CartApi} Using BigCommerce.") 3438 return providers.BigCommerce; 3439 } 3440 3441 if (isWooCommerce) { 3442 console.log("[BH] {CartApi} Using WooCommerce.") 3443 return providers.WooCommerce; 3444 } 3445 3446 if (isShopify) { 3447 console.log("[BH] {CartApi} Using Shopify.") 3448 return providers.Shopify; 3449 } 3450 3451 const noop = () => { 3452 console.log("[BH] {CartApi} Not supported.") 3453 } 3454 3455 return { load: noop, add: noop, update: noop }; 3456} 3457 3458window.Bubblehouse.CartApi = CartApi; 3459})() || true; 3460 3461/* outerFlash.js */ 3462function showFlashMessage(message) { 3463 if (isPopupAvailable('message')) { 3464 openPopup('message', { message }) 3465 } else { 3466 let text = message.t 3467 if (message.m === 'failure') { 3468 text = 'Error: ' + text 3469 } 3470 alert(text) 3471 } 3472} 3473 3474 3475/* outerApi.js */ 3476;(function() { 3477async function handleApplyDiscountCode1(ev) { 3478 const code = ev.detail.code; 3479 if (!code) { 3480 return; 3481 } 3482 3483 const api = window.Bubblehouse.CartApi(); 3484 try { 3485 const cart = await api.load(); 3486 if (!cart) { 3487 console.log("[BH] {ApplyDiscountCode1} No cart info available.") 3488 return; 3489 } 3490 3491 await api.add( 3492 cart.id, 3493 { 3494 products: null, 3495 discount: { code }, 3496 }, 3497 { origin: "ApplyDiscountCode1" } 3498 ); 3499 } catch (error) { 3500 cartErrorShow(error, code); 3501 } 3502} 3503 3504async function handleAddProductToCart1(ev) { 3505 const { 3506 variantId, productId, options, discountCode, quantity, 3507 successMessage, cartUrl, refreshOnAdd, ignoreIfAlreadyAdded, 3508 } = ev.detail; 3509 const api = window.Bubblehouse.CartApi(); 3510 3511 try { 3512 const cart = await api.load(); 3513 if (!cart) { 3514 console.log("[BH] {AddProductToCart1} No cart info available.") 3515 return; 3516 } 3517 3518 const alreadyAdded = ignoreIfAlreadyAdded && cart.items.some(({ variant }) => variant?.id == variantId); 3519 if (alreadyAdded && !discountCode) { 3520 console.log(`[BH] {AddProductToCart1} Already got product variant in cart.`) 3521 return; 3522 } 3523 3524 await api.add( 3525 cart.id, 3526 { 3527 products: alreadyAdded ? null : [{ 3528 variant: { id: variantId }, 3529 quantity, 3530 ...(productId ? { product: { id: productId } } : {}), 3531 options, 3532 }], 3533 discount: discountCode ? { code: discountCode } : null, 3534 }, 3535 { origin: "AddProductToCart1" } 3536 ); 3537 } catch (error) { 3538 cartErrorShow(error, discountCode); 3539 return; 3540 } 3541 3542 if (successMessage) { 3543 showFlashMessage({ t: successMessage }) 3544 if (refreshOnAdd) { 3545 setTimeout(() => { window.location.reload(); }, 1000) 3546 } 3547 } else { 3548 if (cartUrl !== null) { 3549 navigateToCartOrReloadPage(cartUrl) 3550 } 3551 } 3552} 3553 3554function cartErrorShow(error, code) { 3555 console.error("[BH] Unable to update cart:", error); 3556 const message = (code ? Bubblehouse.data?.cartCouponErrorMessage : Bubblehouse.data?.cartErrorMessage) || 3557 error?.message || String(error);
3558 showFlashMessage({ m: 'failure', t: [message, code].filter(Boolean).join('\n') }); 3559} 3560 3561document.addEventListener('bhclient:AddProductToCart1', handleAddProductToCart1); 3562document.addEventListener('bhclient:ApplyDiscountCode1', handleApplyDiscountCode1); 3563})() || true; 3564 3565/* resizer.js */ 3566document.addEventListener('bhclient:Resize1', function (ev) { 3567 ev.target.style.height = ev.detail.height + 'px'; 3568}); 3569 3570 3571/* outerMessage.js */ 3572document.addEventListener('bhclient:PostOuterMessage1', handlePostOuterMessage1); 3573 3574function handlePostOuterMessage1(ev) { 3575 window.postMessage({ event: ev.detail.event, data: ev.detail.data }, '*') 3576} 3577 3578 3579/* tags/bubblehouse-value.js */ 3580;(function() { 3581const VALUES = { 3582 'points': () => Bubblehouse.data.userPointBalanceStr, 3583 'points-value': () => Bubblehouse.data.userPointBalanceValueStr, 3584 'tier': () => Bubblehouse.data.userTierName, 3585} 3586 3587// <bubblehouse-value key="..."> displays the specified loyalty value (properly formatted for the current customer). 3588class BubblehouseValue extends HTMLElement { 3589 constructor() { 3590 super(); 3591 const shadow = this.attachShadow({ mode: 'open' }); 3592 this.valueSpan = document.createElement('span'); 3593 shadow.appendChild(this.valueSpan); 3594 } 3595 3596 connectedCallback() { 3597 this.render(); 3598 } 3599 3600 render() { 3601 let key = this.getAttribute('key') 3602 if (!key) { 3603 key = this.getAttribute('altkey') // don't ask, it's an attempt to debug a problem with Wypeuk 3604 } 3605 let func = VALUES[key] 3606 if (!func) { 3607 console.error(`[Bubblehouse] <bubblehouse-value> has invalid key=${JSON.stringify(key)}`) 3608 } 3609 let value = (func ? func() : 'invalid-value-key') 3610 let fallback = this.getAttribute('fallback') 3611 let text 3612 if (value == null || value === '') { 3613 text = fallback || '' 3614 } else if (value == 0 && fallback != null) { 3615 text = fallback || '' 3616 } else { 3617 text = '' + value 3618 } 3619 this.valueSpan.textContent = text 3620 if (Bubblehouse.verbose) console.log(`[Bubblehouse] rendered <bubblehouse-value key="${key}">`) 3621 } 3622} 3623 3624customElements.define('bubblehouse-value', BubblehouseValue); 3625})() || true; 3626 3627/* tags/bubblehouse-panel.js */ 3628;(function() { 3629// <bubblehouse-panel> unhides itself after Bubblehouse gets loaded. 3630class BubblehousePanel extends HTMLElement { 3631 connectedCallback() { 3632 adjustPanelVisibility(this) 3633 } 3634} 3635 3636customElements.define('bubblehouse-panel', BubblehousePanel); 3637})() || true; 3638 3639/* tags/bubblehouse-block.js */ 3640;(function() { 3641// <bubblehouse-block> inserts the given Bubblehouse page/block. 3642class BubblehouseBlock extends HTMLElement { 3643 constructor() { 3644 super() 3645 this.attachShadow({ mode: 'open' }) 3646 this._onMessage = this._onMessage.bind(this) 3647 } 3648 3649 connectedCallback() { 3650 this._initialize(); 3651 if (!adjustPanelVisibility(this)) return; 3652 window.addEventListener('message', this._onMessage) 3653 } 3654 3655 disconnectedCallback() { 3656 window.removeEventListener('message', this._onMessage) 3657 } 3658 3659 _initialize() { 3660 if (this._initialized) { 3661 return 3662 } 3663 this._initialized = true; 3664 3665 let blockName = this.getAttribute('block') 3666 let suffix = this.getAttribute('suffix') || '' 3667 let height = this.getAttribute('height') || '0' 3668 let shopSlug = this.getAttribute('shop') 3669 let auth = this.getAttribute('auth') 3670 let title = (this.getAttribute('title') || '').trim() 3671 if (!blockName) { 3672 throw new Error('<bubblehouse-block>: block name must be specified (e.g. block="rewards")') 3673 } 3674 let encodedBlockName = encodeBubblehouseBlockPathSegment(blockName, 'block name') 3675 let instance = `bubblehouse-embedded-${blockName}${suffix}` 3676 let url 3677 if (!shopSlug && window.Shopify) { 3678 shopSlug = window.Shopify.shop.replace(/\.myshopify\.com$/, '') 3679 if (shopSlug) { 3680 shopSlug = 'shopify-' + shopSlug 3681 } 3682 auth = auth || Bubblehouse.data.authToken || '' 3683 } 3684 if (window.Shopify && !shopSlug) { 3685 url = new URL(`/apps/bubblehouse/${encodedBlockName}/content/`, window.location.origin) 3686 } else { 3687 if (!shopSlug) {
3688 throw new Error('<bubblehouse-block>: shop slug must be specified (e.g. shop="my-shop")') 3689 } 3690 if (auth == null) { 3691 throw new Error('<bubblehouse-block>: auth must be specified (as either an empty string or a JWT token, e.g. auth="" or auth="...")') 3692 } 3693 let encodedShopSlug = encodeBubblehouseBlockPathSegment(shopSlug, 'shop slug') 3694 url = new URL(`https://app.bubblehouse.com/s/${encodedShopSlug}/blocks/${encodedBlockName}`) 3695 } 3696 if (!this._canUseBubblehouseAuth(url)) { 3697 throw new Error('<bubblehouse-block>: invalid Bubblehouse iframe URL') 3698 } 3699 let shouldFetchAuthTokenForBlock = Boolean(Bubblehouse.data.fetchAuthTokenForBlock) && !auth 3700 if (!shouldFetchAuthTokenForBlock) { 3701 url.searchParams.set('auth', auth || '') 3702 } 3703 url.searchParams.set('instance', instance) 3704 for (let [name, value] of Object.entries(collectCurrentMarketQueryParams())) { 3705 url.searchParams.set(name, value) 3706 } 3707 3708 this.iframe = h('iframe', { 3709 src: shouldFetchAuthTokenForBlock ? null : url.toString(), 3710 style: { 3711 width: '100%', 3712 border: 'none', 3713 height: height + 'px', 3714 }, 3715 title: title || null, 3716 sandbox: 'allow-top-navigation allow-scripts allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-same-origin', 3717 allow: 'clipboard-write', 3718 }) 3719 this.shadowRoot.append(this.iframe) 3720 if (shouldFetchAuthTokenForBlock) { 3721 this._shopifyProxyAuthTokenPromise = this._loadWithShopifyProxyAuthToken(url) 3722 } 3723 } 3724 3725 applyBubblehouseAuthToken(token) { 3726 if (!token || !this.iframe) { 3727 return false 3728 } 3729 let url 3730 try { 3731 url = new URL(this.iframe.src, window.location.href) 3732 } catch { 3733 return false 3734 } 3735 if (url.searchParams.get('auth')) { 3736 return false 3737 } 3738 if (!this._canUseBubblehouseAuth(url)) { 3739 return false 3740 } 3741 url.searchParams.set('auth', token) 3742 this.iframe.src = url.toString() 3743 return true 3744 } 3745 3746 async _loadWithShopifyProxyAuthToken(url) { 3747 let token = await fetchShopifyProxyAuthToken() 3748 if (token) { 3749 Bubblehouse.data.authToken = token 3750 url.searchParams.set('auth', token) 3751 } else { 3752 url.searchParams.set('auth', '') 3753 } 3754 if (this.iframe) { 3755 this.iframe.src = url.toString() 3756 } 3757 } 3758 3759 _canUseBubblehouseAuth(url) { 3760 return isBubblehouseAuthFrameURL(url, 'https://app.bubblehouse.com', window.location.origin) 3761 } 3762 3763 _onMessage(ev) { 3764 let data = ev.data 3765 if (!data || ev.source !== this.iframe.contentWindow) { 3766 return 3767 } 3768 if (data.bubblehouseBlockClientCall === 'Scroll1') { 3769 this.iframe.dispatchEvent(new CustomEvent('bhclient:Scroll1', { 3770 detail: Object.assign({}, data, { sourceWindow: ev.source, sourceOrigin: ev.origin }), 3771 bubbles: true, 3772 composed: true, 3773 })) 3774 return 3775 } 3776 if (data.bubblehouseBlockClientCall !== 'Resize1') { 3777 return 3778 } 3779 if (data.height) { 3780 this.iframe.style.height = data.height + 'px' 3781 } 3782 if (!this.iframe.title && typeof data.title === 'string') { 3783 let title = data.title.trim() 3784 if (title) { 3785 this.iframe.title = title 3786 } 3787 } 3788 } 3789} 3790 3791function encodeBubblehouseBlockPathSegment(value, name) { 3792 if (!/^[A-Za-z0-9_-]+$/.test(value)) { 3793 throw new Error(`<bubblehouse-block>: ${name} must contain only letters, numbers, underscores, or dashes`) 3794 } 3795 return encodeURIComponent(value) 3796} 3797 3798async function fetchShopifyProxyAuthToken() { 3799 try { 3800 let response = await fetch('/apps/bubblehouse/customer-auth-token', { 3801 credentials: 'include', 3802 headers: { 3803 Accept: 'application/json', 3804 }, 3805 }) 3806 if (!response.ok) { 3807 return '' 3808 } 3809 let data = await response.json() 3810 return typeof data.token === 'string' ? data.token : '' 3811 } catch { 3812 return '' 3813 } 3814} 3815
3816customElements.define('bubblehouse-block', BubblehouseBlock); 3817})() || true; 3818 3819/* toc-scroll.js */ 3820async function handleScroll1(ev) { 3821 let scrollEl = null; 3822 let gap = 16; 3823 3824 let ionContent = ev.target.closest('ion-content') || document.querySelector('ion-content'); 3825 if (ionContent) { 3826 scrollEl = await ionContent.getScrollElement(); 3827 } 3828 3829 if (scrollEl) { 3830 let containerRect = scrollEl.getBoundingClientRect(); 3831 let baseOffset = ev.target.getBoundingClientRect().top - containerRect.top + scrollEl.scrollTop; 3832 let offset = Math.max(0, baseOffset - gap + ev.detail.offset); 3833 let scrollElDesc = scrollEl.id ? `${scrollEl.tagName.toLowerCase()}#${scrollEl.id}` : scrollEl.className ? `${scrollEl.tagName.toLowerCase()}.${scrollEl.className.split(' ')[0]}` : scrollEl.tagName.toLowerCase(); 3834 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)`); 3835 scrollEl.scrollTo({ top: Math.round(offset), behavior: 'smooth' }); 3836 } else { 3837 let baseOffset = ev.target.getBoundingClientRect().top + window.scrollY; 3838 let offset = Math.max(0, baseOffset - gap + ev.detail.offset); 3839 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)`); 3840 window.scroll({ top: Math.round(offset), behavior: 'smooth' }); 3841 } 3842} 3843 3844document.addEventListener('bhclient:Scroll1', handleScroll1); 3845 3846 3847/* anchor-navigation.js */ 3848const BUBBLEHOUSE_ANCHOR_PREFIX = "#bh-"; 3849const BUBBLEHOUSE_ANCHOR_RETRY_DELAY_MS = 100; 3850const BUBBLEHOUSE_ANCHOR_RETRY_COUNT = 50; 3851const BUBBLEHOUSE_ANCHOR_FINAL_WAIT_MS = 1500; 3852const BUBBLEHOUSE_ANCHOR_SETTLE_DELAY_MS = 2000; 3853const BUBBLEHOUSE_ANCHOR_SETTLE_THRESHOLD_PX = 50; 3854 3855let bubblehouseAnchorNavigation = null; 3856let bubblehouseAnchorSettleTimerID = 0; 3857 3858navigateToCurrentBubblehouseAnchor(); 3859window.addEventListener("hashchange", navigateToCurrentBubblehouseAnchor); 3860window.addEventListener("message", handleBubblehouseAnchorResizeMessage); 3861document.addEventListener("bhclient:Scroll1", handleBubblehouseAnchorScroll1); 3862 3863function navigateToCurrentBubblehouseAnchor() { 3864 let anchor = currentBubblehouseAnchor(); 3865 if (!anchor) { 3866 cancelBubblehouseAnchorNavigation(); 3867 return; 3868 } 3869 startBubblehouseAnchorNavigation(anchor); 3870} 3871 3872function startBubblehouseAnchorNavigation(anchor) { 3873 cancelBubblehouseAnchorNavigation(); 3874 bubblehouseAnchorNavigation = { 3875 anchor, 3876 resizeRetryUsed: false, 3877 retriesRemaining: BUBBLEHOUSE_ANCHOR_RETRY_COUNT, 3878 timerID: 0, 3879 }; 3880 attemptBubblehouseAnchorNavigation(false); 3881} 3882 3883function attemptBubblehouseAnchorNavigation(finalAttempt) { 3884 let state = bubblehouseAnchorNavigation; 3885 if (!state) { 3886 return; 3887 } 3888 state.timerID = 0; 3889 if (scrollToSameOriginBubblehouseAnchor(state.anchor)) { 3890 completeBubblehouseAnchorNavigation(); 3891 return; 3892 } 3893 let posted = postNavigateToAnchor1(state.anchor); 3894 if (finalAttempt) { 3895 scheduleBubblehouseAnchorGiveUp(state.anchor, posted); 3896 } else if (state.retriesRemaining > 0) { 3897 state.retriesRemaining--; 3898 state.timerID = setTimeout(() => attemptBubblehouseAnchorNavigation(false), BUBBLEHOUSE_ANCHOR_RETRY_DELAY_MS); 3899 } else { 3900 warnBubblehouseAnchorNavigationFailed(state.anchor); 3901 } 3902} 3903 3904function currentBubblehouseAnchor() { 3905 let hash = location.hash || ""; 3906 if (!hash.startsWith(BUBBLEHOUSE_ANCHOR_PREFIX)) { 3907 return ""; 3908 } 3909 let anchor = hash.slice(BUBBLEHOUSE_ANCHOR_PREFIX.length); 3910 if (!anchor || anchor.includes("=")) { 3911 return ""; 3912 } 3913 return anchor; 3914} 3915 3916function scrollToSameOriginBubblehouseAnchor(anchor) { 3917 for (let frame of bubblehouseAnchorFrames()) { 3918 let section; 3919 try { 3920 section = frame.contentDocument?.getElementById(anchor); 3921 } catch { 3922 section = null; 3923 } 3924 if (!section) { 3925 continue; 3926 } 3927 let inTabgroup = false; 3928 try { 3929 inTabgroup = !!(section.closest && section.closest('[data-controller~="tabgroup"]')); 3930 } catch { 3931 } 3932 let reveal; 3933 try { 3934 reveal = frame.contentWindow?.Bubblehouse?.revealTabgroupContaining; 3935 } catch { 3936 reveal = null; 3937 } 3938 if (inTabgroup && typeof reveal !== 'function') { 3939 continue; 3940 } 3941 if (typeof reveal === 'function') {
3942 try { 3943 section = reveal(section) || section; 3944 } catch { 3945 } 3946 } 3947 let offset = bubblehouseAnchorOffset(section); 3948 dispatchSameOriginBubblehouseAnchorScroll(frame, offset); 3949 scheduleSameOriginBubblehouseAnchorSettle(frame, section, offset); 3950 return true; 3951 } 3952 return false; 3953} 3954 3955function scheduleSameOriginBubblehouseAnchorSettle(frame, section, firstOffset) { 3956 clearBubblehouseAnchorSettleTimer(); 3957 bubblehouseAnchorSettleTimerID = setTimeout(() => { 3958 bubblehouseAnchorSettleTimerID = 0; 3959 let settledOffset = bubblehouseAnchorOffset(section); 3960 if (Math.abs(settledOffset - firstOffset) > BUBBLEHOUSE_ANCHOR_SETTLE_THRESHOLD_PX) { 3961 dispatchSameOriginBubblehouseAnchorScroll(frame, settledOffset); 3962 } 3963 }, BUBBLEHOUSE_ANCHOR_SETTLE_DELAY_MS); 3964} 3965 3966function bubblehouseAnchorOffset(section) { 3967 return section.getBoundingClientRect ? section.getBoundingClientRect().top : section.offsetTop; 3968} 3969 3970function dispatchSameOriginBubblehouseAnchorScroll(frame, offset) { 3971 frame.dispatchEvent(new CustomEvent("bhclient:Scroll1", { 3972 detail: { 3973 bubblehouseBlockClientCall: "Scroll1", 3974 instance: frame.id, 3975 offset, 3976 }, 3977 bubbles: true, 3978 composed: true, 3979 })); 3980} 3981 3982function postNavigateToAnchor1(anchor) { 3983 let posted = 0; 3984 for (let frame of bubblehouseAnchorFrames()) {
3985 let contentWindow = frame.contentWindow; 3986 if (!contentWindow?.postMessage) { 3987 continue; 3988 } 3989 contentWindow.postMessage({ 3990 bubblehouseBlockClientCall: "NavigateToAnchor1", 3991 instance: "#self", 3992 anchor, 3993 }, bubblehouseAnchorTargetOrigin(frame)); 3994 posted++; 3995 } 3996 return posted; 3997} 3998 3999function handleBubblehouseAnchorScroll1(ev) { 4000 if (!bubblehouseAnchorNavigation || !isTrustedBubblehouseAnchorSource(ev.detail?.sourceWindow)) { 4001 return; 4002 } 4003 completeBubblehouseAnchorNavigation(); 4004} 4005 4006function handleBubblehouseAnchorResizeMessage(ev) { 4007 let state = bubblehouseAnchorNavigation; 4008 if (!state || state.resizeRetryUsed || !isResize1Message(ev.data) || !isTrustedBubblehouseAnchorSource(ev.source)) { 4009 return; 4010 } 4011 state.resizeRetryUsed = true; 4012 clearBubblehouseAnchorTimer(); 4013 attemptBubblehouseAnchorNavigation(true); 4014} 4015 4016function isResize1Message(data) { 4017 return data && data.bubblehouseBlockClientCall === "Resize1"; 4018} 4019 4020function isTrustedBubblehouseAnchorSource(source) { 4021 return !!source && bubblehouseAnchorFrames().some((frame) => frame.contentWindow === source); 4022} 4023 4024function bubblehouseAnchorFrames() { 4025 return collectBubblehouseFrames(document).filter((frame) => !!bubblehouseAnchorFrameURL(frame)); 4026} 4027 4028function bubblehouseAnchorFrameURL(frame) { 4029 try { 4030 let url = new URL(frame.getAttribute("src") || frame.src, location.href); 4031 return isBubblehouseAuthFrameURL(url, bubblehouseAppOrigin(), location.origin) ? url : null; 4032 } catch { 4033 return null; 4034 } 4035} 4036 4037function bubblehouseAnchorTargetOrigin(frame) { 4038 return bubblehouseAnchorFrameURL(frame)?.origin || "*"; 4039} 4040 4041function scheduleBubblehouseAnchorGiveUp(anchor, posted) { 4042 if (!bubblehouseAnchorNavigation) { 4043 return; 4044 } 4045 if (posted) { 4046 bubblehouseAnchorNavigation.timerID = setTimeout(() => warnBubblehouseAnchorNavigationFailed(anchor), BUBBLEHOUSE_ANCHOR_FINAL_WAIT_MS); 4047 } else { 4048 warnBubblehouseAnchorNavigationFailed(anchor); 4049 } 4050} 4051 4052function warnBubblehouseAnchorNavigationFailed(anchor) { 4053 if (!bubblehouseAnchorNavigation) { 4054 return; 4055 } 4056 console.warn("[Bubblehouse] giving up on navigation to anchor " + anchor); 4057 cancelBubblehouseAnchorNavigation(); 4058} 4059 4060function completeBubblehouseAnchorNavigation() { 4061 clearBubblehouseAnchorTimer(); 4062 bubblehouseAnchorNavigation = null; 4063} 4064 4065function cancelBubblehouseAnchorNavigation() { 4066 completeBubblehouseAnchorNavigation(); 4067 clearBubblehouseAnchorSettleTimer(); 4068} 4069 4070function clearBubblehouseAnchorTimer() { 4071 if (bubblehouseAnchorNavigation?.timerID) { 4072 clearTimeout(bubblehouseAnchorNavigation.timerID); 4073 bubblehouseAnchorNavigation.timerID = 0; 4074 } 4075} 4076 4077function clearBubblehouseAnchorSettleTimer() { 4078 if (bubblehouseAnchorSettleTimerID) { 4079 clearTimeout(bubblehouseAnchorSettleTimerID); 4080 bubblehouseAnchorSettleTimerID = 0; 4081 } 4082} 4083 4084 4085/* public.js */ 4086Bubblehouse.centsToAccruedPoints = centsToAccruedPoints 4087Bubblehouse.pointsBalance = pointsBalance 4088Bubblehouse.pointsBalanceStr = pointsBalanceStr 4089Bubblehouse.pointsBalanceMonetaryValue = pointsBalanceMonetaryValue 4090Bubblehouse.pointsBalanceMonetaryValueStr = pointsBalanceMonetaryValueStr 4091 4092Bubblehouse.ONE_TIME_PURCHASE = 'onetime' 4093Bubblehouse.SUBSCRIPTION = 'sub' 4094 4095function centsToAccruedPoints(cents, scenario, options = {}) { 4096 if (scenario === 'onetime') { 4097 return Math.round(cents * Bubblehouse.data.ppc + 0.001) 4098 } else if (scenario === 'sub') { 4099 return Math.round(cents * Bubblehouse.data.ppcs + 0.001) 4100 } else { 4101 throw new Error('unsupported scenario') 4102 } 4103} 4104 4105Bubblehouse.PTS_TOTAL = 'total' 4106Bubblehouse.PTS_AVAILABLE = 'avail' 4107 4108function pointsBalance(kind) { 4109 if (kind === 'total') { 4110 return Bubblehouse.data.userPointBalance 4111 } else if (kind === 'avail') { 4112 return Bubblehouse.data.userPointBalanceAvail 4113 } else { 4114 throw new Error('unsupported kind') 4115 } 4116} 4117 4118function pointsBalanceStr(kind) { 4119 if (kind === 'total') { 4120 return Bubblehouse.data.userPointBalanceStr 4121 } else if (kind === 'avail') { 4122 return Bubblehouse.data.userPointBalanceAvailStr 4123 } else { 4124 throw new Error('unsupported kind') 4125 } 4126} 4127 4128function pointsBalanceMonetaryValue(kind) { 4129 if (kind === 'total') { 4130 return Bubblehouse.data.userPointBalanceValue 4131 } else if (kind === 'avail') { 4132 return Bubblehouse.data.userPointBalanceAvailValue 4133 } else { 4134 throw new Error('unsupported kind') 4135 } 4136} 4137 4138function pointsBalanceMonetaryValueStr(kind) { 4139 if (kind === 'total') { 4140 return Bubblehouse.data.userPointBalanceValueStr 4141 } else if (kind === 'avail') { 4142 return Bubblehouse.data.userPointBalanceAvailValueStr 4143 } else { 4144 throw new Error('unsupported kind') 4145 } 4146} 4147 4148 4149/* load.js */ 4150document.dispatchEvent(new CustomEvent('bubblehouse:load')); 4151 4152 4153/* elements/embed-iframe.js */ 4154class EmbedIframe extends HTMLElement { 4155 constructor() { 4156 super(); 4157 } 4158 4159 connectedCallback() { 4160 const iframe = document.createElement("iframe"); 4161 4162 this.getAttributeNames().map((an) => { 4163 iframe.setAttribute(an, this.getAttribute(an)); 4164 }); 4165 4166 const src = iframe.getAttribute("src"); 4167 iframe.setAttribute("src", src.replace("$AUTH_TOKEN", Bubblehouse.data.authToken)); 4168 4169 this.replaceWith(iframe); 4170 } 4171} 4172 4173customElements.define("bh-embed-iframe", EmbedIframe); 4174 4175 4176})();
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.