{"version":3,"file":"product-recommendations-section.min.js","mappings":"wZAiCO,SAASA,EAAcC,EAAYC,EAAaC,GACrD,IACEF,EAAMC,GAAOC,CACf,CAAE,MAAOC,GACP,IAAIC,EAEFA,EADED,aAA2BE,MACpBF,EAAgBG,QAEhB,GAAGH,IAGdI,QAAQC,IAAI,kCAAmC,CAC7CP,MACAQ,KAAW,MAALT,OAAK,EAALA,EAAOS,KACbC,IAAKC,OAAOC,UAAUC,SAASC,KAAKd,GACpCI,UAEJ,CACF,CCnCA,MAAMW,+BAA+BC,YAkC3BC,YAAcC,KAAKC,OAAOC,KAAKF,MAC/BG,mBAAqBH,KAAKI,cAAcF,KAAKF,MAE9C,iBAAAK,GACLL,KAAKM,uBAILN,KAAKO,SAAW,IAAIC,qBAAqBR,KAAKG,mBAAoB,CAChEM,WAAY,sBAEdT,KAAKO,SAASG,QAAQV,KACxB,CAEQ,oBAAAM,GACDN,KAAKW,aACRX,KAAKY,aAAa,CAAEC,KAAM,SAC1Bb,KAAKW,WAAWG,UAAY,i/BAEhC,CAEQ,aAAAV,CAAcW,GACNA,EAAQ,GACXC,iBAIXhB,KAAKO,SAASU,aACdjB,KAAKkB,qBAAqBC,KAAKnB,KAAKD,aAAaqB,MAAM/B,QAAQgC,MACjE,CAEA,wBAAcH,GACZlB,KAAKsB,cCpBFC,eAA2CC,GAChD,IAAK,QAAQC,KAAKD,EAAME,YAAa,CACnC,MAAM5C,EAAa,IAAIK,MAAM,+CAE7B,MADAL,EAAM0C,MAAQA,EACR1C,CACR,CAOA,MAAM6C,EAAM,IAAIC,IAAIJ,EAAMG,IAAKE,SAASC,QAWxC,IAAIC,EAmBAC,EA7BJL,EAAIM,aAAaC,IAAI,aAAcV,EAAME,YAErCF,EAAMW,YACRR,EAAIM,aAAaC,IAAI,aAAcV,EAAMW,YAGvCX,EAAMY,QACRT,EAAIM,aAAaC,IAAI,SAAUV,EAAMY,QAIvC,IACEL,QAAiBM,MAAMV,EAAIW,KAC7B,CAAE,MAAOxD,GAGP,MAFAD,EAAcC,EAAO,QAAS0C,GAC9B3C,EAAcC,EAAO,MAAO6C,EAAIW,MAC1BxD,CACR,CAEA,IAAKiD,EAASQ,GAAI,CAGhB,MAAMzD,EAAa,IAAIK,MAAM,mBAAmB4C,EAASS,mCAIzD,MAHA1D,EAAM2D,KAAOV,EAASS,OACtB1D,EAAM0C,MAAQA,EACd1C,EAAM6C,IAAMI,EAASJ,IACf7C,CACR,CAGA,IACEkD,QAAqBD,EAASW,MAChC,CAAE,MAAO5D,GAIP,MAHAD,EAAcC,EAAO,QAAS0C,GAC9B3C,EAAcC,EAAO,SAAUiD,EAASS,QACxC3D,EAAcC,EAAO,MAAOiD,EAASJ,KAC/B7C,CACR,CAEA,OAAOkD,CACT,CDjCyBW,CAA4B,CAC/CP,OAAQpC,KAAK4C,QAAQR,OACrBV,WAAY1B,KAAK4C,QAAQC,UACzBV,WAAYnC,KAAK4C,QAAQE,UACzBnB,IAAK3B,KAAK4C,QAAQjB,KAEtB,CAEQ,MAAA1B,GAEN,IAAKD,KAAK+C,YACR,OAIF,GAAI/C,KAAKW,WAAWqC,cAAc,oBAChC,OAGF,MAAMC,EAAQjD,KAAKW,WAAWqC,cAAc,MACxChD,KAAK4C,QAAQK,MACfA,EAAMC,YAAclD,KAAK4C,QAAQK,MACA,YAAxBjD,KAAK4C,QAAQR,OACtBa,EAAMC,YAAc,oBAEpBD,EAAMC,YAAc,eAGtB,MAAMC,EAAWnD,KAAKW,WAAWqC,cAAc,aAC/CG,EAASrC,UAAYd,KAAKsB,QAI1B,MAAM8B,EAAkBD,EAASH,cAAc,oBAC/C,GAAII,EAAiB,CACnB,MAAMC,EAAkBrD,KAAKW,WAAWqC,cAA2B,oBACnEK,EAAgBC,QAAS,EAU/B/B,eAAsB8B,EAA8BF,GAClD,GAsDF,WACE,GAA0B,mBAAfI,WACT,OAAO,EAGT,OAAOA,WAAW,oCAAoCC,OACxD,CA5DMC,GACF,OAMF,MAAMC,EAAQC,iBAAiBN,GACzBO,EAAeF,EAAME,aACrBC,EAAYH,EAAMG,UAClBC,EAAS,CAAEC,OAAQ,MAAOH,aAAc,MAAOC,UAAW,OAEhER,EAAgBK,MAAMM,SAAW,SACjC,MAAMC,EAAOZ,EAAgBa,QAAQ,CAACJ,EAAQA,GAAS,CAAEK,KAAM,mBA0BjE,SAAsBhB,GACpB,OAAO,IAAIiB,QAAeC,IACxBlB,EAASmB,iBAAiB,2BAA4B,SAASC,EAAkBC,GAC3EA,EAAMC,SAAWtB,IACnBA,EAASuB,oBAAoB,2BAA4BH,GACzDF,IAEJ,IAEJ,CAjCQM,CAAaxB,SAObyB,UACAA,IAEN,MAAMb,EAASV,EAAgBwB,aAC/BZ,EAAKa,SAEL,MAAMC,EAAY,CAACjB,EAAQ,CAAEC,OAAQ,GAAGA,MAAYH,eAAcC,cAC5DmB,EAAY3B,EAAgBa,QAAQa,EAAW,CAAEE,SAAU,IAAKC,OAAQ,sBACxEF,EAAUG,SAEhB9B,EAAgBK,MAAMM,SAAW,EACnC,CA3CMoB,CAAO/B,EAAiBD,GAAiBhC,MAAM/B,QAAQgC,KACzD,CACF,EAyEF,SAASuD,IACP,OAAO,IAAIR,QAAQiB,sBACrB,CAQKC,eAAeC,IAAI,4BACtBD,eAAeE,OAAO,0BAA2B3F","sources":["webpack://lange/./lib/decorate-error.ts","webpack://lange/./sections/product-recommendations/product-recommendations/index.ts","webpack://lange/./lib/shopify/product-recommendations.ts"],"sourcesContent":["/**\n * Copies a single piece of supplementary context onto an error before it is thrown or reported.\n *\n * Use this when decorating a value the calling code did not construct, such as a caught value or an\n * error handed in by another module. An error the caller just built with new is always writable, so\n * plain assignment is fine there. A value from elsewhere may be a foreign object this code does not\n * own. Such a value can carry a non-writable property, such as a DOMException whose code is a read-\n * only accessor, or be frozen outright, and assigning to it then throws. Attaching context must\n * never itself fail, so a rejected assignment is swallowed and the remaining context is still\n * attempted. Guarding each property in isolation means a single read-only slot, the common case,\n * costs only that one property rather than all of them.\n *\n * In practice the value that triggers this is almost always a DOMException that reached the caller\n * from a fetch the browser cut short, such as an abort when an in-flight request is interrupted.\n * The platform defines a DOMException's code, name, and message as read-only attributes, so\n * assigning a code onto one throws under the strict mode that all bundled module code runs in. It\n * surfaces from Safari far more than elsewhere for two reasons. The reported wording, attempted to\n * assign to readonly property, is JavaScriptCore's phrasing for this failure, so the same bug in\n * another engine is worded differently and groups as a separate report, which makes this one look\n * Safari specific even though the read-only rule is not. And iOS Safari abandons in-flight fetches\n * aggressively when a tab is backgrounded or a connection drops, which is when such a DOMException\n * tends to be produced and handed to code that assumed an ordinary writable error.\n *\n * A swallowed failure is logged at a level low enough that it is recorded as a breadcrumb for\n * monitoring rather than escalated into its own captured event. That keeps the lost context visible\n * alongside the eventual report without adding fresh noise of its own. The breadcrumb names the\n * property that could not be attached, the kind of value that rejected it, and why, which is enough\n * to recognize a read-only or frozen caught value after the fact.\n *\n * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Read-only\n * @see https://developer.mozilla.org/en-US/docs/Web/API/DOMException\n * @see https://webidl.spec.whatwg.org/#dfn-read-only\n */\nexport function decorateError(error: any, key: string, value: unknown) {\n  try {\n    error[key] = value;\n  } catch (assignmentError) {\n    let reason: string;\n    if (assignmentError instanceof Error) {\n      reason = assignmentError.message;\n    } else {\n      reason = `${assignmentError}`;\n    }\n\n    console.log('Failed to assign error property', {\n      key,\n      name: error?.name,\n      tag: Object.prototype.toString.call(error),\n      reason\n    });\n  }\n}\n","import type { CustomElement } from '@integrabeauty/custom-elements';\nimport { fetchProductRecommendations } from '../../../lib/shopify/product-recommendations.js';\nimport html from './index.html';\nimport styles from './index.scss';\n\n/**\n * Shows a carousel of related or complementary products on the product page.\n *\n * This exists because Shopify only populates its recommendations data when a section is rendered\n * through its product recommendations endpoint. A normal page render leaves that data empty, so the\n * recommendations have to be fetched from that endpoint on the client after load.\n *\n * It waits until it's about to scroll into view, then fetches the rendered product slides through\n * the Section Rendering API for the given product and intent. The section renders the slides inside\n * a carousel-element, which mounts the carousel on its own once it lands in the shadow root.\n */\nclass ProductRecommendations extends HTMLElement implements CustomElement {\n  declare readonly dataset: {\n    /**\n     * Whether to fetch and display the related or complementary products\n     */\n    intent: 'complementary' | 'related';\n\n    /**\n     * Shopify id of the main product\n     */\n    productId: string;\n\n    /**\n     * Section id to fetch using Shopify Section Rendering API\n     */\n    sectionId: string;\n\n    /**\n     * Section heading\n     *\n     * Defaults to \"You may also like\" for related products and \"Pair it with\" for complementary\n     * products\n     */\n    title: string;\n\n    /**\n     * Relative URL to the Shopify Section Rendering API\n     */\n    url: string;\n  };\n\n  private declare content: string | undefined;\n  private declare observer: IntersectionObserver | undefined;\n\n  private renderBound = this.render.bind(this);\n  private onIntersectedBound = this.onIntersected.bind(this);\n\n  public connectedCallback() {\n    this.initializeShadowRoot();\n\n    // Start the fetch well before the section scrolls into view, so for most visitors the products\n    // are already in place by the time they reach it and the grow animation plays out of sight.\n    this.observer = new IntersectionObserver(this.onIntersectedBound, {\n      rootMargin: '0px 0px 600px 0px'\n    });\n    this.observer.observe(this);\n  }\n\n  private initializeShadowRoot() {\n    if (!this.shadowRoot) {\n      this.attachShadow({ mode: 'open' });\n      this.shadowRoot.innerHTML = `<style>${styles}</style>${html}`;\n    }\n  }\n\n  private onIntersected(entries: IntersectionObserverEntry[]) {\n    const entry = entries[0];\n    if (!entry.isIntersecting) {\n      return;\n    }\n\n    this.observer.disconnect();\n    this.fetchProductSlides().then(this.renderBound).catch(console.warn);\n  }\n\n  private async fetchProductSlides() {\n    this.content = await fetchProductRecommendations({\n      intent: this.dataset.intent,\n      product_id: this.dataset.productId,\n      section_id: this.dataset.sectionId,\n      url: this.dataset.url\n    });\n  }\n\n  private render() {\n    // content is fetched asynchronously, so there's no guarantee the component is still connected\n    if (!this.isConnected) {\n      return;\n    }\n\n    // exit if the carousel was rendered already\n    if (this.shadowRoot.querySelector('carousel-element')) {\n      return;\n    }\n\n    const title = this.shadowRoot.querySelector('h2');\n    if (this.dataset.title) {\n      title.textContent = this.dataset.title;\n    } else if (this.dataset.intent === 'related') {\n      title.textContent = 'You may also like';\n    } else {\n      title.textContent = 'Pair it with';\n    }\n\n    const carousel = this.shadowRoot.querySelector('.carousel');\n    carousel.innerHTML = this.content;\n\n    // The section leaves out the carousel when there are no products to recommend. Otherwise we\n    // show it now, before the carousel has had time to mount, so the mount measures visible slides.\n    const carouselElement = carousel.querySelector('carousel-element');\n    if (carouselElement) {\n      const recommendations = this.shadowRoot.querySelector<HTMLElement>('.recommendations');\n      recommendations.hidden = false;\n      growIn(recommendations, carouselElement).catch(console.warn);\n    }\n  }\n}\n\n/**\n * Opens the recommendations from zero height to their full height, so the content below gets pushed\n * down smoothly rather than jumping when the products show up.\n */\nasync function growIn(recommendations: HTMLElement, carousel: HTMLElement) {\n  if (prefersReducedMotion()) {\n    return;\n  }\n\n  // Before the carousel mounts, its slides are laid out several times taller than they end up, so\n  // measuring then would grow toward the wrong height and snap back at the end. So we hold the\n  // element closed until the carousel says it mounted, and only then measure and grow.\n  const style = getComputedStyle(recommendations);\n  const marginBottom = style.marginBottom;\n  const marginTop = style.marginTop;\n  const closed = { height: '0px', marginBottom: '0px', marginTop: '0px' };\n\n  recommendations.style.overflow = 'hidden';\n  const hold = recommendations.animate([closed, closed], { fill: 'forwards' });\n\n  await waitForMount(carousel);\n\n  // Right after the mount, the browser still has a lot of work to do drawing the new carousel, and\n  // that first frame can take over a hundred milliseconds. If we started the grow now, its clock\n  // would run through that slow frame and the first part of the grow would never be drawn, so it\n  // would look like a jump followed by a rushed grow. So we let one frame go by first, and start\n  // the grow in the frame after it.\n  await waitForNextFrame();\n  await waitForNextFrame();\n\n  const height = recommendations.scrollHeight;\n  hold.cancel();\n\n  const keyframes = [closed, { height: `${height}px`, marginBottom, marginTop }];\n  const animation = recommendations.animate(keyframes, { duration: 375, easing: 'ease-in-out' });\n  await animation.finished;\n\n  recommendations.style.overflow = '';\n}\n\n/**\n * Resolves once the carousel itself has mounted. The slides could hold carousels of their own, and\n * their mount events bubble up through the carousel too, so those are ignored.\n */\nfunction waitForMount(carousel: HTMLElement) {\n  return new Promise<void>((resolve) => {\n    carousel.addEventListener('carousel-element-mounted', function onCarouselMounted(event) {\n      if (event.target === carousel) {\n        carousel.removeEventListener('carousel-element-mounted', onCarouselMounted);\n        resolve();\n      }\n    });\n  });\n}\n\n/**\n * Whether the visitor asked for less motion. When the browser cannot tell us, we treat it as asking\n * for less, so the section just shows without animating.\n */\nfunction prefersReducedMotion() {\n  if (typeof matchMedia !== 'function') {\n    return true;\n  }\n\n  return matchMedia('(prefers-reduced-motion: reduce)').matches;\n}\n\n/**\n * Resolves just before the browser draws its next frame.\n */\nfunction waitForNextFrame() {\n  return new Promise(requestAnimationFrame);\n}\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'product-recommendations': ProductRecommendations;\n  }\n}\n\nif (!customElements.get('product-recommendations')) {\n  customElements.define('product-recommendations', ProductRecommendations);\n}\n","import { decorateError } from '../decorate-error.js';\n\n/**\n * Input to fetching a rendered recommendations section.\n */\nexport interface Input {\n  /**\n   * Whether we want related products or complementary products\n   *\n   * Shopify defaults to related when we leave this out.\n   */\n  intent?: 'complementary' | 'related';\n\n  /**\n   * Shopify id of the product the recommendations are for, as a string of digits\n   *\n   * Shopify documents this as required and answers with a 422 when it is left out, so we throw\n   * before sending a request without a valid one.\n   */\n  product_id: string;\n\n  /**\n   * Id of the section Shopify renders the recommendations into\n   *\n   * Shopify documents this as required for the html version of the endpoint and answers with a 422\n   * when it is left out.\n   */\n  section_id?: string;\n\n  /**\n   * Url of the recommendations endpoint, which is best passed as an absolute url but may be\n   * relative\n   *\n   * This is meant to be the value of `routes.product_recommendations_url` from Liquid with the\n   * store's own domain in front of it. The section renders it into a data attribute, and the\n   * section's script reads that attribute and passes it here. The route on its own is a path such\n   * as `/recommendations/products`, with the locale prefix in front of it when another locale is\n   * active.\n   *\n   * We take this instead of building it ourselves. Shopify only puts the site root on the page for\n   * javascript to read, not the recommendations route, so building it here would mean hardcoding\n   * the route and hoping Shopify never moves it.\n   */\n  url: string;\n}\n\n/**\n * Fetch the html of a recommendations section that Shopify rendered on the server.\n *\n * Shopify serves recommendations in two versions. The url ending in `.json` answers with the\n * recommended products as json and takes no section id. The url without `.json` renders the section\n * named by the section id with the recommendations filled in and answers with its html. This uses\n * the html version, since the url callers pass is the route Liquid gives us, which has no `.json`\n * on the end.\n *\n * Callers have their own preferences about how to parse the html, or whether to parse it at all, so\n * we hand back the raw text and let them decide.\n *\n * @param input which product and section to ask for\n *\n * @see https://shopify.dev/docs/api/ajax/reference/product-recommendations\n */\nexport async function fetchProductRecommendations(input: Input) {\n  if (!/^\\d+$/.test(input.product_id)) {\n    const error: any = new Error('Invalid product id fetching recommendations');\n    error.input = input;\n    throw error;\n  }\n\n  // Callers pass an absolute url, and when the url has its own host the base below is ignored. We\n  // keep the base so a relative url still works. A relative url picks up whatever host the page\n  // reports, though, and a page is not always running on the store's own domain. For example, a\n  // tool can load our html while reporting some other site as the page url, and then the request\n  // goes to that site instead of the store. That is why callers pass an absolute url.\n  const url = new URL(input.url, location.origin);\n  url.searchParams.set('product_id', input.product_id);\n\n  if (input.section_id) {\n    url.searchParams.set('section_id', input.section_id);\n  }\n\n  if (input.intent) {\n    url.searchParams.set('intent', input.intent);\n  }\n\n  let response;\n  try {\n    response = await fetch(url.href);\n  } catch (error) {\n    decorateError(error, 'input', input);\n    decorateError(error, 'url', url.href);\n    throw error;\n  }\n\n  if (!response.ok) {\n    // Some of these statuses are routine enough that Sentry filters them out by message, so take\n    // care when rewording this.\n    const error: any = new Error(`Received status ${response.status} fetching recommendations`);\n    error.code = response.status;\n    error.input = input;\n    error.url = response.url;\n    throw error;\n  }\n\n  let responseText;\n  try {\n    responseText = await response.text();\n  } catch (error) {\n    decorateError(error, 'input', input);\n    decorateError(error, 'status', response.status);\n    decorateError(error, 'url', response.url);\n    throw error;\n  }\n\n  return responseText;\n}\n"],"names":["decorateError","error","key","value","assignmentError","reason","Error","message","console","log","name","tag","Object","prototype","toString","call","ProductRecommendations","HTMLElement","renderBound","this","render","bind","onIntersectedBound","onIntersected","connectedCallback","initializeShadowRoot","observer","IntersectionObserver","rootMargin","observe","shadowRoot","attachShadow","mode","innerHTML","entries","isIntersecting","disconnect","fetchProductSlides","then","catch","warn","content","async","input","test","product_id","url","URL","location","origin","response","responseText","searchParams","set","section_id","intent","fetch","href","ok","status","code","text","fetchProductRecommendations","dataset","productId","sectionId","isConnected","querySelector","title","textContent","carousel","carouselElement","recommendations","hidden","matchMedia","matches","prefersReducedMotion","style","getComputedStyle","marginBottom","marginTop","closed","height","overflow","hold","animate","fill","Promise","resolve","addEventListener","onCarouselMounted","event","target","removeEventListener","waitForMount","waitForNextFrame","scrollHeight","cancel","keyframes","animation","duration","easing","finished","growIn","requestAnimationFrame","customElements","get","define"],"sourceRoot":""}