{"version":3,"file":"product-recommendations-section.min.js","mappings":"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","sources":["webpack://lange/./lib/decorate-error.ts","webpack://lange/./sections/product-recommendations/index.ts","webpack://lange/./lib/dom-ready.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 { ready } from '../../lib/dom-ready.js';\nimport { fetchProductRecommendations } from '../../lib/shopify/product-recommendations.js';\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 the recommendations are about to scroll into view, then fetches the rendered\n * product slides through the Section Rendering API for the given product and intent. The section\n * renders the slides inside a carousel-element, which mounts the carousel on its own once it lands\n * on the page.\n */\nfunction onReady() {\n  const recommendations = document.querySelector<HTMLElement>(\n    '.product-recommendations-section .recommendations[data-url]');\n  if (!recommendations) {\n    return;\n  }\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. We\n  // observe the section rather than the recommendations, since the recommendations start out\n  // hidden, and an observer never reports a hidden element as intersecting.\n  const section = recommendations.closest('.product-recommendations-section');\n  const observer = new IntersectionObserver(function onIntersected(entries) {\n    if (!entries[0].isIntersecting) {\n      return;\n    }\n\n    observer.disconnect();\n    load(recommendations).catch(console.warn);\n  }, { rootMargin: '0px 0px 600px 0px' });\n\n  observer.observe(section);\n}\n\nasync function load(recommendations: HTMLElement) {\n  const content = await fetchProductRecommendations({\n    intent: <'complementary' | 'related'>recommendations.dataset.intent,\n    product_id: recommendations.dataset.productId,\n    section_id: recommendations.dataset.sectionId,\n    url: recommendations.dataset.url\n  });\n\n  const carousel = recommendations.querySelector('.carousel');\n  carousel.innerHTML = content;\n\n  // The section leaves out the carousel when there are no products to recommend. Otherwise we show\n  // 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    recommendations.hidden = false;\n    await growIn(recommendations, carouselElement);\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\nready(onReady);\n","/**\n * Releases the handle returned from the ready() function.\n *\n * This is idempotent. Additional calls are pointless but harmless.\n */\nexport type ReleaseHandle = () => void;\n\n/**\n * A subset of events that pertain to document ready state.\n */\ntype LoadingEventMap = Pick<DocumentEventMap, 'DOMContentLoaded' | 'load'>;\n\n/**\n * Calls the provided callback when the document is ready. This works both when the document is\n * already ready and when it is not, so callers do not have to care. This exists because it ensures\n * that each caller consistently expresses its dependency on document readiness, and to save a bit\n * of boilerplate.\n *\n * A document is considered ready when it has been parsed. This corresponds to the ready state of\n * 'interactive'. When the document has been fully loaded, the ready state is 'complete'. The\n * callback runs either way, so callers do not have to check which one they are in.\n *\n * Ready means the html is parsed and the DOM is there to query. It does not mean the page's other\n * scripts have run. Deferred scripts and module scripts run after the ready state turns\n * 'interactive' and before DOMContentLoaded fires, so a callback here can run before them. When a\n * caller needs some other script to have run already, this is the wrong tool, and it should wait on\n * whatever that script produces instead.\n *\n * That remedy has a catch of its own when the calling bundle is loaded with async. An async bundle\n * can execute after DOMContentLoaded has already fired, so a plain listener for that event never\n * runs and the work silently never happens. Nothing here can detect that, because by the time this\n * file executes the event is long gone. Catching it needs something registered at parse time,\n * before any bundle loads.\n *\n * There is a second trap right next to that one, and it applies only when waiting for\n * DOMContentLoaded. The ready state stays 'interactive' after DOMContentLoaded has fired, all the\n * way until 'complete', so the ready state cannot tell you whether DOMContentLoaded is still coming\n * or has already gone. Waiting for load is not exposed to this, because the ready state turns\n * 'complete' and load fires in the same task, so seeing 'complete' means load has fired too.\n *\n * When the document is already ready, the callback is invoked via setTimeout. This way, if the\n * callback throws, the error surfaces to the global error handler in a way that is similar to what\n * happens when an event listener throws.\n *\n * We might one day want to move this to an NPM library.\n *\n * Beware of a quirk here. setTimeout yields, and the busier the frontend, the longer it takes to\n * fire. Page load is exactly when the frontend is busiest. The timeout is a floor and not a\n * guarantee, so there is no real bound on how long that yield lasts. What this means is that using\n * this library can introduce more delay than you might expect. Compare code that runs on async load\n * and checks ready state and invokes immediately to code that runs after setTimeout. The former\n * runs in the same tick, the latter runs in \"some later tick\". That later tick can be surprisingly\n * much later. That can cause initial paint issues.\n *\n * @see https://developer.mozilla.org/en-US/docs/Web/API/Document/readyState\n * @see https://developer.mozilla.org/en-US/docs/Web/API/Document/DOMContentLoaded_event\n * @see https://html.spec.whatwg.org/multipage/parsing.html#the-end\n */\nexport function ready(callback: (event?: Event) => void,\n  type: keyof LoadingEventMap = 'DOMContentLoaded') {\n  if (document.readyState === 'complete' || (type === 'DOMContentLoaded' &&\n    document.readyState === 'interactive')) {\n    const timer = setTimeout(callback, 0);\n    const handle: ReleaseHandle = () => clearTimeout(timer);\n    return handle;\n  } else {\n    addEventListener(type, callback);\n    const handle: ReleaseHandle = () => removeEventListener(type, callback);\n    return handle;\n  }\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","waitForNextFrame","Promise","requestAnimationFrame","callback","type","document","readyState","timer","setTimeout","clearTimeout","addEventListener","ready","recommendations","querySelector","section","closest","observer","IntersectionObserver","entries","isIntersecting","disconnect","async","content","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","carousel","innerHTML","carouselElement","hidden","matchMedia","matches","prefersReducedMotion","style","getComputedStyle","marginBottom","marginTop","closed","height","overflow","hold","animate","fill","resolve","onCarouselMounted","event","target","removeEventListener","waitForMount","scrollHeight","cancel","keyframes","animation","duration","easing","finished","growIn","load","catch","warn","rootMargin","observe"],"sourceRoot":""}