{{ 'bs-product.css' | asset_url | stylesheet_tag }}

{%- comment -%} Collapses the review / Q&A bands when a product has neither.
  Lives here rather than in the story section because the no-buy template drops
  the story but still carries both Yotpo widgets. {%- endcomment -%}
<script src="{{ 'bs-yotpo-empty.js' | asset_url }}" defer></script>

{%- comment -%}
  Owned replacement for the Builder "Product Page V2" (shop-bs-2). Reads the
  SAME Shopify data the Builder version bridged into window.BigData, so the
  admin stays the source of truth and JP localization keeps working:
    - per-variant metafields: store.subtitle, store.ship_date, store.ship_msg,
      store.info_box / info_link, store.addon_groups (group metaobjects with
      header/description/ui_style/prop_key/toggleable/checked/disabled/
      ephemeral variant lists)
    - addon variant metafields: addon.description, addon.price_text,
      addon.fine_print, addon.ships_separately, addon.swatch_colors
    - cart rules: cart.qty_limit + cart.qty_count_products
    - strings: shop.metaobjects.product_misc.live.*
  Ephemeral variants (e.g. the included Soft Strap) are selectable but never
  added to the cart and never priced into the total.

  NEW vs the Builder page: the render stage. Selections drive a combo render
  (dyn-bs2--{shell}-{halo-}{strap}-{cushion}.webp, the marketing site's
  Dynamic Configurator matrix, self-hosted in assets). Combos without a render
  (e.g. the VRChat Edition colorway) fall back to the selected model variant's
  own product photo.
{%- endcomment -%}

{%- assign sroot = routes.root_url -%}
{%- if sroot == '/' -%}{%- assign sroot = '' -%}{%- endif -%}

{%- comment -%} cart-limit state (same counting the Builder Liquid did) {%- endcomment -%}
{%- assign qty_in_cart = 0 -%}
{%- for item in cart.items -%}
  {%- assign is_match = false -%}
  {%- if item.product_id == product.id -%}
    {%- assign is_match = true -%}
  {%- else -%}
    {%- for counted_product in product.metafields.cart.qty_count_products.value -%}
      {%- if item.product_id == counted_product.id -%}{%- assign is_match = true -%}{%- endif -%}
    {%- endfor -%}
  {%- endif -%}
  {%- if is_match -%}{%- assign qty_in_cart = qty_in_cart | plus: item.quantity -%}{%- endif -%}
{%- endfor -%}
{%- assign qty_limit = product.metafields.cart.qty_limit | default: 0 -%}
{%- assign is_beyond = false -%}
{%- if product.handle == 'bigscreen-beyond-2' -%}{%- assign is_beyond = true -%}{%- endif -%}

{%- comment -%} ---- the price the configurator can actually reach -------------
  "Starting from" prints the cheapest REACHABLE build, not product.price (the
  bare headset nobody can buy). The walk lives in the bs-config-floor snippet,
  shared with bs-item-grid so the grid tile and this line can never disagree —
  the full reasoning is documented there. Captured as a string and coerced
  back, the only way a snippet can hand a number to its caller.
{%- endcomment -%}
{%- capture start_price_s -%}{% render 'bs-config-floor', floor_product: product %}{%- endcapture -%}
{%- assign start_price = start_price_s | plus: 0 -%}

{%- comment -%} Photo gallery: accessories often ship several product shots and
the stage only ever showed the first one. Beyond 2 is excluded by construction —
its stage is the dyn-bs2 combo matrix, driven by the configuration, not a set of
stills to page through. {%- endcomment -%}
{%- assign has_gallery = false -%}
{%- if is_beyond == false and product.images.size > 1 -%}{%- assign has_gallery = true -%}{%- endif -%}

{%- comment -%} Renders float, photographs fill. The stage box is built for a
cut-out on transparent air — 82% of the plate, hardware centred inside it — and
that is exactly wrong for a shot taken on set, where the frame IS the picture:
the Audio Strap's lab photos landed as a small letterboxed rectangle adrift in
grey. Those take the whole plate instead, cropped to it.

Not every photograph qualifies. The test is whether the shot has a background of
its own, not whether a camera made it: the Miami Vice model and the cushion's
white-sweep stills are cut-outs with a soft shadow, and the gloves size chart is
a diagram whose table would be cropped away. All three stay in the floating box.
Ruled by image POSITION, not by product, because most of these products mix the
two kinds — audio-strap is a render then two photos, custom-fit-cushion is one
photo among four renders.

Metafield first so a new shot needs no deploy (`store.photo_positions`, a comma
list of 1-based positions); the map below is the fallback for what is already in
the admin. {%- endcomment -%}
{%- assign photo_positions = product.metafields.store.photo_positions -%}
{%- if photo_positions == blank -%}
  {%- case product.handle -%}
    {%- when 'audio-strap' -%}{%- assign photo_positions = '2,3' -%}
    {%- when 'custom-fit-cushion' -%}{%- assign photo_positions = '3' -%}
  {%- endcase -%}
{%- endif -%}
{%- comment -%} ",2,3," — comma-wrapped so a probe of ",3," can never match the
"3" inside a position 13, which a plain `contains` would. {%- endcomment -%}
{%- assign photo_key = '' -%}
{%- if photo_positions != blank -%}
  {%- assign photo_key = photo_positions | replace: ' ', '' | prepend: ',' | append: ',' -%}
{%- endif -%}
{%- assign featured_probe = product.featured_image.position | prepend: ',' | append: ',' -%}
{%- assign featured_is_photo = false -%}
{%- if photo_key != '' and photo_key contains featured_probe -%}{%- assign featured_is_photo = true -%}{%- endif -%}

{%- comment -%} Prescription products are owned end-to-end by LensAdvizor: its
modal collects the Rx and does its OWN /cart/add.js with the line-item
properties attached. This configurator must therefore never add such a product
directly — doing so puts lenses in the cart with no prescription against them.
Flagged here so the order button can hand off to the app instead.
Metafield first so a future Rx product needs no code change. {%- endcomment -%}
{%- comment -%} The Rx add-on offered inside the Beyond 2 configurator. It IS an
addon_group metaobject now (addon_type ui_style "Link Card"), so its presence,
ordering intent, and copy live in the admin like every other group — but it is
still rendered OUTSIDE #bsp-groups, and the group loop below skips it:
LensAdvizor owns this line item end to end, so it must stay out of the
configurator's total and out of its /cart/add payload (addonInputs() only ever
reads inside that host), and the once-bound cart-readout JS needs the card to
survive model switches. The group is read off the first model variant — same
assumption the footnote hand-off already makes. {%- endcomment -%}
{%- assign rx_group_data = blank -%}
{%- for g in product.variants.first.metafields.store.addon_groups.value -%}
  {%- if g.addon_type.value.ui_style == 'Link Card' and rx_group_data == blank -%}
    {%- assign rx_group_data = g -%}
  {%- endif -%}
{%- endfor -%}
{%- assign rx_variant = rx_group_data.variants.value | first -%}
{%- assign rx_product = rx_variant.product -%}
{%- assign has_rx_addon = false -%}

{%- assign needs_rx = false -%}
{%- if product.metafields.store.requires_prescription.value == true -%}{%- assign needs_rx = true -%}{%- endif -%}
{%- if product.handle contains 'prescription-lens' -%}{%- assign needs_rx = true -%}{%- endif -%}
{%- comment -%} the card shows wherever the admin assigns a Link Card group —
and never on the Rx product's own page (it already is that product)
{%- endcomment -%}
{%- if rx_group_data != blank and needs_rx == false and rx_variant != blank and rx_variant.id != blank -%}
  {%- assign has_rx_addon = true -%}
{%- endif -%}

{%- comment -%} bsp--beyond gates the mobile folding plate. It is the
configurator's behaviour, not the section's: Beyond 2 has ~5000px of options to
scroll and a render that changes as they are chosen, so the plate has to stay on
screen and has to get out of the way. An accessory has neither — a short page and
one photograph — so there it is just a picture, and a picture that sticks and
shrinks is machinery with nothing to do. {%- endcomment -%}
<section class="bsp{% if is_beyond %} bsp--beyond{% endif %}" id="bsp">
  <div class="bsp-split">

    <div class="bsp-stage{% if has_gallery %} bsp-stage--gallery{% endif %}{% if featured_is_photo %} bsp-stage--photo{% endif %}" id="bsp-stage-plate">
      <div class="bsp-stage-bg" aria-hidden="true"></div>
      <div class="bsp-stage-vrc" aria-hidden="true"><img src="{{ 'VRC_white.svg' | asset_url }}" alt="" loading="lazy"></div>
      <div class="bsp-stage-inner" id="bsp-stage">
        {%- if is_beyond -%}
          {%- comment -%} The pre-JS first frame. Must depict the cushion
          group's checked default (mountGroups() repaints from the real inputs
          on load, so a mismatch here is a flash, not a wrong page) — the
          Universal-Fit Cushion since Sept 2026, when the default flipped to
          make the opening configuration the "Starting from" build.
          {%- endcomment -%}
          <img id="bsp-render-a" class="is-active" alt="{{ product.title | escape }}" src="{{ 'dyn-bs2--clear-softstrap-universalcushion.webp' | asset_url }}">
        {%- else -%}
          <img id="bsp-render-a" class="is-active{% if featured_is_photo %} is-bleed{% endif %}" alt="{{ product.title | escape }}" src="{{ product.featured_image | image_url: width: 1400 }}">
        {%- endif -%}
        <img id="bsp-render-b" alt="" aria-hidden="true" src="">
        {%- comment -%} Add-on preview (landing's .bsx-addon-preview). Lives
        INSIDE the product box, not the plate corner, so it reads as attached to
        the headset: "this product + this extra". The plus leads for that reason —
        it sits between the render and the add-on. Shown only when the render
        can't depict a selection (e.g. Halo's lightseal is pictured while a
        Custom-Fit Cushion is also in the basket). Driven by syncAddonPreview().

        A stack, not a single chip: a cushion the render can't show and the Rx
        inserts can both be pending at once. It hangs off the product box (not
        the plate corner) and grows DOWNWARD from just under the hardware, so
        the first chip always sits against the headset it belongs to and any
        second one runs out into the plate's empty floor. {%- endcomment -%}
        <div class="bsp-addon-stack" id="bsp-addon-stack" aria-hidden="true"></div>
      </div>
      {%- comment -%} Rating — our markup, not Yotpo's star widget. The numbers
      come from metafields Yotpo already writes (the standard reviews.* pair,
      falling back to its own yotpo.* namespace), which means the row is ours to
      style: brand violet instead of the app's orange, mono numerals, and the
      whole thing is one link down to the reviews band. The template's
      "TEMPORARY STARS SECTION" is the app's own block and stays disabled.
      It sits on the STAGE rather than in the config rail: the rail was already
      running title, price, footnote and description before the first control,
      and the stage is sticky, so social proof stays on screen for the whole
      configurator flow instead of scrolling away after the first fold. Top-left
      mirrors the model caption in the opposite corner. {%- endcomment -%}
      {%- assign rating_count = product.metafields.reviews.rating_count | default: product.metafields.yotpo.reviews_count -%}
      {%- assign rating_value = product.metafields.reviews.rating.value.rating | default: product.metafields.yotpo.reviews_average -%}
      {%- if rating_value != blank and rating_count != blank and rating_count > 0 -%}
        {%- assign rating_shown = rating_value | round: 1 -%}
        {%- comment -%} the fill is the true average out of 5 (x20 = percent), so
        the partial star tracks the score rather than snapping to a half {%- endcomment -%}
        {%- assign rating_pct = rating_value | times: 20.0 -%}
        {%- comment -%} The singular/plural fork moved into the locale files
        (one/other): accessories ship with a single review often enough that
        "1 reviews" would be read out loud, and a language whose plural rules
        differ from English now applies its own instead of inheriting ours.
        {%- endcomment -%}
        <a class="bsp-rating" href="#bs-reviews" data-bsp-rating-link
           aria-label="{{ 'bigscreen.product.rating_aria' | t: rating: rating_shown, count: rating_count | escape }}">
          <span class="bsp-rating-stars" style="--bsp-fill: {{ rating_pct }}%" aria-hidden="true">
            <span class="bsp-rating-set bsp-rating-off">{% render 'bs-rating-stars' %}</span>
            <span class="bsp-rating-on"><span class="bsp-rating-set">{% render 'bs-rating-stars' %}</span></span>
          </span>
          <span class="bsp-rating-score" aria-hidden="true">{{ rating_shown }}</span>
          <span class="bsp-rating-count" aria-hidden="true">{{ 'bigscreen.product.rating_count' | t: count: rating_count }}</span>
        </a>
      {%- endif -%}
      {%- comment -%} The caption is Beyond's alone: there it reads out the
      SELECTED model (Beyond 2 / 2e / 2e VRChat Edition) as the render swaps, so
      it's a readout, not a label. On every other product nothing swaps — it just
      restated the h1 two feet away, so it isn't rendered there. {%- endcomment -%}
      {%- if is_beyond -%}
        <span class="bsp-stage-caption" id="bsp-caption">{{ product.title }}</span>
      {%- endif -%}
      {%- comment -%} Mobile only, and only once the plate has folded (the CSS
      fades it in on --bsp-p). The model caption above is hidden there: it
      restated the option card a few pixels below it. This names the STRIP
      instead — by the time the plate is a 132px band it has stopped being a hero
      shot and become a running summary of what has been chosen, and the small
      render wants saying so. {%- endcomment -%}
      <span class="bsp-stage-tag" aria-hidden="true">{{ shop.metaobjects.product_misc.live.stage_tag | default: 'Your configuration' }}</span>
      {%- comment -%} Gallery controls sit on the plate, not inside the product
      box, so they hug the plate edges the way the caption hugs its corner. The
      counter takes the floor's centre — on a gallery product it replaces the
      scroll hint rather than stacking on top of it. {%- endcomment -%}
      {%- if has_gallery -%}
        <button type="button" class="bsp-gal-arrow bsp-gal-arrow--prev" id="bsp-gal-prev" aria-label="{{ 'general.slider.previous_slide' | t }}" aria-controls="bsp-stage">
          <svg viewBox="0 0 24 24" width="24" height="24" fill="none" aria-hidden="true" focusable="false"><path d="M14.5 5 7.5 12l7 7M7.5 12H21" stroke="currentColor" stroke-width="1.25"/></svg>
        </button>
        <button type="button" class="bsp-gal-arrow bsp-gal-arrow--next" id="bsp-gal-next" aria-label="{{ 'general.slider.next_slide' | t }}" aria-controls="bsp-stage">
          <svg viewBox="0 0 24 24" width="24" height="24" fill="none" aria-hidden="true" focusable="false"><path d="M9.5 5l7 7-7 7M16.5 12H3" stroke="currentColor" stroke-width="1.25"/></svg>
        </button>
        <p class="bsp-gal-count" id="bsp-gal-count" aria-live="polite">01 / {{ product.images.size | prepend: '0' | slice: -2, 2 }}</p>
      {%- else -%}
        {%- comment -%} mono label, so it is uppercased by CSS rather than by the
        copy — a translated value does not have to be shouted in its own script {%- endcomment -%}
        <div class="bsp-scroll-hint" id="bsp-scroll-hint" aria-hidden="true">{{ shop.metaobjects.product_misc.live.scroll_hint | default: 'SCROLL' }} <span>&darr;</span></div>
      {%- endif -%}
    </div>

    <div class="bsp-col">
      <div class="bsp-col-inner">
        {%- comment -%} The violet rule split ("Bigscreen Beyond ——— 2") is
        Beyond 2's alone — every other product (accessories, t-shirts) keeps the
        plain title, so the motif stays a flagship signal rather than page
        furniture. The "/ CONFIGURATOR" mono kicker that used to sit above it is
        still parked; .bsp-kicker remains in bs-product.css. {%- endcomment -%}
        {%- if product.handle == 'bigscreen-beyond-2' -%}
          {%- assign title_words = product.title | split: ' ' -%}
          {%- assign title_last = title_words | last -%}
          {%- assign title_head = product.title | remove_last: title_last | strip -%}
          {%- comment -%} The numeral carries a hidden "e" that appears when an
          eyetracking model is chosen, and the rule swaps to the landing's b2e
          gradient. The "e" is absolutely positioned so it reserves no space —
          the "2" must not shift when it arrives. {%- endcomment -%}
          {%- comment -%} The violet rule sits between the two halves as an empty
          span, so the words ran together for anything reading the text rather
          than the layout: the accessible name was "Bigscreen Beyond2" and so was
          the copy anyone selected off the page. The rule's span carries the space
          as its content — a real character in the flow, hidden from the reader
          along with the rest of the decoration, and absorbed by the rule's own
          fixed width so nothing shifts. {%- endcomment -%}
          <h1 class="bsp-title" id="bsp-title"><span>{{ title_head }}</span><span class="bsp-title-rule" aria-hidden="true"></span><span class="bsp-title-sp"> </span><span class="bsp-title-num">{{ title_last }}<span class="bsp-title-e" data-title-e aria-hidden="true">e</span></span></h1>
        {%- else -%}
          <h1 class="bsp-title">{{ product.title }}</h1>
        {%- endif -%}
        {%- comment -%} "Starting from" is only true where the price actually
        moves: Beyond 2 (models + add-ons) and any product whose variants are
        priced differently. A single-price accessory states its price flat —
        the prefix there read as a hedge on a number that never changes.
        store.price_prefix still overrides per product. {%- endcomment -%}
        {%- comment -%} The qualifier is a before/after PAIR, not a word glued in
        front of a number: English leads with it ("Starting from ¥169,800"),
        Japanese trails it ("¥169,800〜"). One concatenated prefix can only be
        right in one of those, so each locale fills whichever half it uses and
        leaves the other blank. Same shape as the cart's tax_usa / tax_usa_end.
        store.price_prefix still overrides the leading half per product.
        {%- endcomment -%}
        {%- assign price_prefix = product.metafields.store.price_prefix.value -%}
        {%- assign price_suffix = '' -%}
        {%- if price_prefix == blank -%}
          {%- if is_beyond or product.price_varies -%}
            {%- assign price_prefix = 'bigscreen.product.price_from_before' | t -%}
            {%- assign price_suffix = 'bigscreen.product.price_from_after' | t -%}
          {%- endif -%}
        {%- endif -%}
        {%- comment -%} start_price, not product.price — the cheapest build the
        configurator can actually produce (computed at the top of this file).
        On a product with no add-on groups the two are identical. {%- endcomment -%}
        <p class="bsp-from">{% if price_prefix != blank %}{{ price_prefix }} {% endif %}<span class="bsp-from-price">{{ start_price | money_with_currency }}</span>{{ price_suffix }}</p>
        {%- comment -%} price_footnote is a per-currency JSON map — show only the
        active currency's line, falling back to OTHER {%- endcomment -%}
        {%- assign footnotes = product.metafields.store.price_footnote.value -%}
        {%- comment -%} An explicit "" entry means "this market shows no
        footnote" (USD: US prices carry no VAT and sales tax is added at
        checkout, so every wording would be wrong). `default:` treats "" as
        absent and fell through to OTHER's VAT line — so walk the map and only
        fall back when the currency has no entry at all. {%- endcomment -%}
        {%- assign footnote_text = footnotes['OTHER'] -%}
        {%- for fn in footnotes -%}
          {%- if fn[0] == cart.currency.iso_code -%}{%- assign footnote_text = fn[1] -%}{%- endif -%}
        {%- endfor -%}
        {%- if footnote_text != blank -%}
          <p class="bsp-from-note">{{ footnote_text }}</p>
        {%- endif -%}
        <p class="bsp-desc">{{ product.description | strip_html | truncate: 460 }}</p>

        {%- comment -%} ---- model picker: hidden entirely when there is only one
        variant (the checked input must still exist for the JS) ---- {%- endcomment -%}
        {%- comment -%} Header: only Beyond's variants are headset models. Every
        other product's picker is whatever its first option happens to be (Size
        on apparel), so name that option instead of claiming a model choice.
        store.selector_header still overrides per product. {%- endcomment -%}
        {%- comment -%} Built by interpolation, never by append: "Pick your " and
        the option name are one sentence, and Japanese reverses their order
        ("サイズを選択"). Gluing a translated option onto a translated fragment
        produces word salad in every language that isn't English, so the whole
        sentence is one key with the option as a placeholder. {%- endcomment -%}
        {%- assign selector_header = product.metafields.store.selector_header -%}
        {%- if selector_header == blank -%}
          {%- if is_beyond -%}
            {%- assign selector_header = 'bigscreen.product.pick_model' | t -%}
          {%- else -%}
            {%- assign opt_name = product.options_with_values.first.name | downcase -%}
            {%- assign selector_header = 'bigscreen.product.pick_option' | t: option: opt_name -%}
          {%- endif -%}
        {%- endif -%}
        {%- comment -%} Sold-out variants stay on the page. A size that exists
        but is gone is information — dropping it from the list reads as "we
        never made that size", and the visitor goes looking for it elsewhere.
        So they render greyed, locked, and named, the way the old store theme
        greyed its size pills.
        The initial selection has to skip them, or the page opens priced on
        something the cart will refuse. NOT
        selected_or_first_available_variant: it hands ?variant= the selection
        whether or not that variant is in stock, so every old link to a size
        that has since sold out would open on a dead configuration. The URL only
        wins while it points at something buyable; otherwise the first available
        variant does, and the first variant of all when the whole product is out
        — so there is always exactly one checked input for the JS to read.
        {%- endcomment -%}
        {%- assign default_variant = product.selected_variant -%}
        {%- if default_variant == nil or default_variant.available == false -%}
          {%- assign default_variant = product.first_available_variant | default: product.variants.first -%}
        {%- endif -%}
        {%- assign sold_out_label = shop.metaobjects.product_misc.live.sold_out | default: 'Sold out' -%}
        <div class="bsp-group"{% if product.variants.size < 2 %} hidden{% endif %}>
          <h2 class="bsp-group-head">{{ selector_header }}</h2>
          <p class="bsp-group-desc">{{ product.metafields.store.selector_description }}</p>
          {%- for variant in product.variants -%}
            {%- comment -%} store.selector_class is the admin's styling hook for
            a variant's card — the Builder page stamped it as data-handle and
            keyed the VRChat Edition band off it, so the value in the data is
            already "bs2e-vrc". Stamped as data-style here; bs-product.css owns
            what each value looks like. Title matching was a stopgap and broke
            the moment the admin was the one deciding which card is special.
            {%- endcomment -%}
            <div class="bsp-opt{% unless variant.available %} bsp-opt--soldout{% endunless %}"{% if variant.metafields.store.selector_class.value != blank %} data-style="{{ variant.metafields.store.selector_class.value | strip | escape }}"{% endif %}>
              <input type="radio" name="bsp-model" id="bsp-model-{{ variant.id }}"
                value="{{ variant.id }}"
                data-cents="{{ variant.price }}"
                data-title="{{ variant.title | escape }}"
                data-sku="{{ variant.sku | escape }}"
                data-ship-date="{{ variant.metafields.store.ship_date.value }}"
                {%- comment -%} composed ETA (parts -> legacy ship_msg fallback);
                data-eta-parts carries the part handles for the _eta_parts prop
                {%- endcomment -%}
                {%- capture _eta_msg -%}{% render 'bs-eta-msg', variant: variant %}{%- endcapture -%}
                data-ship-msg="{{ _eta_msg | strip | escape }}"
                data-eta-parts="{% for _pp in variant.metafields.store.eta_text.value %}{{ _pp.system.handle }}{% unless forloop.last %},{% endunless %}{% endfor %}"
                {%- comment -%} guard the nil: image_url on a variant with no
                image raises "invalid url", and the error STRING then lands in
                data-image, which comboUrl() feeds straight to the stage <img>
                for non-Beyond products — producing a 404 for a URL made of the
                error text. Falls back to the product photo. {%- endcomment -%}
                data-image="{% if variant.image %}{{ variant.image | image_url: width: 1400 }}{% elsif product.featured_image %}{{ product.featured_image | image_url: width: 1400 }}{% endif %}"
                {% if variant.id == default_variant.id %}checked{% endif %}
                {%- unless variant.available %} disabled data-soldout="1"{% endunless %}>
              {%- comment -%} Same card anatomy as the add-on options: check
              indicator, thumb, then title with the subtitle stacked underneath.
              It used to be a compact inline row, which made the model picker
              read as a different kind of control from everything below it.
              {%- endcomment -%}
              <label for="bsp-model-{{ variant.id }}">
                <span class="bsp-opt-check" aria-hidden="true"></span>
                {%- if variant.image -%}
                  <span class="bsp-opt-thumb"><img src="{{ variant.image | image_url: width: 160 }}" alt="" loading="lazy"></span>
                {%- endif -%}
                <span class="bsp-opt-body">
                  {%- comment -%} the VRC mark stands in for the word "VRChat" {%- endcomment -%}
                  {%- if variant.title contains 'VRChat' -%}
                    {%- assign vrc_parts = variant.title | split: 'VRChat' -%}
                    <span class="bsp-opt-title">{{ vrc_parts[0] }}<img class="bsp-opt-vrc" src="{{ 'VRC_white.svg' | asset_url }}" alt="VRChat" loading="lazy">{{ vrc_parts[1] }}</span>
                  {%- else -%}
                    <span class="bsp-opt-title">{{ variant.title }}</span>
                  {%- endif -%}
                  {%- if variant.metafields.store.subtitle.value != blank -%}
                    <span class="bsp-opt-sub">{{ variant.metafields.store.subtitle.value }}</span>
                  {%- endif -%}
                </span>
                {%- comment -%} Only a real upcharge earns a number. Equally
                priced variants (apparel sizes) used to print "+$0.00" on every
                row, and a cheaper later variant printed "+-$10". {%- endcomment -%}
                {%- comment -%} ...and no span at all when there is no upcharge.
                The &nbsp; placeholder it used to hold was invisible on desktop,
                where the price shares the card's single row. On a phone the
                price takes a full row of its own, so the blank one left ~34px of
                dead space under every base-priced card — most visibly under the
                selected model, which is the card the eye lands on first.
                {%- endcomment -%}
                {%- comment -%} An out-of-stock row spends its price slot saying
                so. Grey alone is ambiguous on a card that has no photo — it
                could as easily read as "not chosen yet". {%- endcomment -%}
                {%- assign price_delta = variant.price | minus: product.variants.first.price -%}
                {%- unless variant.available -%}
                  <span class="bsp-opt-price bsp-opt-price--soldout">{{ sold_out_label }}</span>
                {%- else -%}
                  {%- if price_delta > 0 -%}
                    <span class="bsp-opt-price">+{{ price_delta | money_with_currency }}</span>
                  {%- endif -%}
                {%- endunless -%}
              </label>
            </div>
          {%- endfor -%}
          {%- comment -%} ---- per-model info plate (store.info_box) ----
          A variant carrying store.info_box gets a tonal plate under the picker
          while that model is selected — today that is the VRChat Edition's
          "check out the details" box, same data the Builder page read (so the
          admin copy and its JP translations keep working). store.info_link is
          a root-relative URL, made locale-aware with sroot the way Builder
          prefixed Shopify.routes.root; the link label is the localized
          product_misc.info_box string. All variants' plates render and JS in
          mountGroups() shows the selected model's one, mirroring how the
          add-on groups swap.
          The "Already own a Beyond 1?" upgrade-kit note that used to sit here
          was dropped (it interrupted the flow with an offer to leave it), but
          its .bsp-note plate recipe is what these boxes wear;
          store.info_note itself is still not read by this section.
          {%- endcomment -%}
          {%- assign info_link_label = shop.metaobjects.product_misc.live.info_box | default: 'Learn more' -%}
          {%- for variant in product.variants -%}
            {%- if variant.metafields.store.info_box.value != blank -%}
              {%- comment -%} the plate carries the variant's selector_class
              too, so an edition's box can be skinned from the same data hook
              as its picker card {%- endcomment -%}
              <div class="bsp-note" data-info-for="{{ variant.id }}"{% if variant.metafields.store.selector_class.value != blank %} data-style="{{ variant.metafields.store.selector_class.value | strip | escape }}"{% endif %}{% unless variant.id == default_variant.id %} hidden{% endunless %}>
                <p class="bsp-note-title">{{ variant.metafields.store.info_box.value }}</p>
                {%- if variant.metafields.store.info_link.value != blank -%}
                  <a class="bsp-note-link" href="{{ sroot }}{{ variant.metafields.store.info_link.value }}">{{ info_link_label }} <svg class="bsp-arrow" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M4 10h11M11 5l5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
                {%- endif -%}
              </div>
            {%- endif -%}
          {%- endfor -%}
        </div>

        {%- comment -%} ---- addon groups: one <template> per model variant ---- {%- endcomment -%}
        <div id="bsp-groups"></div>
        {%- for variant in product.variants -%}
          <template id="bsp-groups-{{ variant.id }}">
            {%- comment -%} The groups render exactly as the admin metaobjects
              ship them (since Aug 2026 the data carries the landing flow
              directly: cushions = Custom-Fit / Universal-Fit, straps = Soft /
              Audio / Halo Mount). The only reshaping left is LAYOUT: a strap
              group holding a Halo option renders as the landing's tree —
              straps in a 2-col row, a bracket, then the halo card full-width
              (see the else-branch below). {%- endcomment -%}

            {%- comment -%} ---- footnote markers run ONCE down the whole rail ----
              They used to restart at 1 inside every group, which put three
              different "1"s, two "2"s and two "3"s on one page — and then the Rx
              card hardcoded "4,5" as though the run had been continuous all
              along. A reader who saw ² twice had two footnote 2s and no way to
              tell which note it pointed at.
              So the counter is hoisted out of the group loop and never resets.
              Notes still print at the foot of their own group; they just carry a
              number that is unique on the page.
            {%- endcomment -%}
            {%- assign card_n = 0 -%}
            {%- for group in variant.metafields.store.addon_groups.value -%}
              {%- assign gkey = group.addon_type.value.prop_key | strip -%}
              {%- assign gstyle = group.addon_type.value.ui_style | strip -%}
              {%- comment -%} Link Card groups (the LensAdvizor hand-off) render
              once, outside #bsp-groups — see the Rx block after this loop. A
              purchasable card here would add lenses with no prescription.
              {%- endcomment -%}
              {%- if gstyle == 'Link Card' -%}{%- continue -%}{%- endif -%}
              {%- assign is_cushion_group = false -%}
              {%- if gkey contains 'cushion' -%}{%- assign is_cushion_group = true -%}{%- endif -%}
              {%- assign is_strap_group = false -%}
              {%- if gkey contains 'strap' -%}{%- assign is_strap_group = true -%}{%- endif -%}
              <div class="bsp-group" data-group-key="{{ gkey | escape }}">
                {%- comment -%} the id is what the swatch row's aria-labelledby
                points at, so it is only stamped on the one group that renders as
                swatches (a second Color Swatch group per model would need the
                key in the id) {%- endcomment -%}
                <h2 class="bsp-group-head"{% if gstyle == 'Color Swatch' %} id="bsp-swhead-{{ variant.id }}"{% endif %}>{{ group.addon_type.value.header }}</h2>
                {%- liquid
                  assign gdesc = group.addon_type.value.description
                  if gdesc == blank and is_cushion_group
                    assign gdesc = 'The part that touches you. Choose your perfect fit.'
                  endif
                  if gdesc == blank and is_strap_group
                    assign gdesc = 'The Soft Strap is always included. Add the Audio Strap or Halo Mount on top.'
                  endif
                -%}
                {%- if gdesc != blank -%}
                  <p class="bsp-group-desc">{{ gdesc }}</p>
                {%- endif -%}

                {%- comment -%} radio vs checkbox comes straight from the
                metaobject's `toggleable`. The cushion group must stay
                toggleable in the admin: Custom-Fit is moulded to one face,
                Universal-Fit is the share-with-anyone option, so buying both
                is a real configuration. A facial interface is still physically
                required — the JS blocks unchecking the last one. {%- endcomment -%}
                {%- assign input_type = 'radio' -%}
                {%- if group.toggleable.value -%}{%- assign input_type = 'checkbox' -%}{%- endif -%}

                {%- comment -%} Sold-out fallback for the starting selection.
                The metaobject names which option ships checked, and that name
                goes stale the moment the option sells out. A radio group has to
                end with exactly one selection — a group with none leaves the
                render stage and the total with nothing to price — so the check
                moves to the first option still in stock. Checkbox groups need
                no fallback: zero checked is a legal state for them.
                {%- endcomment -%}
                {%- assign checked_fallback = 0 -%}
                {%- if input_type == 'radio' -%}
                  {%- assign checked_live = false -%}
                  {%- for av in group.variants.value -%}
                    {%- for cid in group.checked_variants.value -%}
                      {%- if cid.id == av.id and av.available -%}{%- assign checked_live = true -%}{%- endif -%}
                    {%- endfor -%}
                  {%- endfor -%}
                  {%- unless checked_live -%}
                    {%- for av in group.variants.value -%}
                      {%- if av.available and checked_fallback == 0 -%}{%- assign checked_fallback = av.id -%}{%- endif -%}
                    {%- endfor -%}
                  {%- endunless -%}
                {%- endif -%}

                {%- if gstyle == 'Color Swatch' -%}
                  {%- comment -%} name + upcharge line sits above the swatch row,
                  matching the shipped layout {%- endcomment -%}
                  {%- comment -%} the marker and the note under the row belong to
                  whichever shell is selected, so both are filled by the JS from
                  that swatch's own fine print (limited-edition colorways carry
                  it, the standard ones don't) {%- endcomment -%}
                  {%- comment -%} This group takes ONE number from the page's run,
                  and only if some shell in it actually carries fine print — the
                  row can only ever show one note, since only one shell is
                  selected at a time. Standard colorways have none, so the marker
                  hides and its number is simply unused for that selection: a gap
                  in the sequence, never a second footnote wearing the same
                  digit, which is the failure this is fixing. {%- endcomment -%}
                  {%- assign swatch_index = 0 -%}
                  {%- for av in group.variants.value -%}
                    {%- if av.metafields.addon.fine_print.value != blank and swatch_index == 0 -%}
                      {%- assign card_n = card_n | plus: 1 -%}
                      {%- assign swatch_index = card_n -%}
                    {%- endif -%}
                  {%- endfor -%}
                  <p class="bsp-swatch-name" data-swatch-name
                     data-included-label="{{ shop.metaobjects.product_misc.live.included_short | default: 'Included' }}"><strong><span data-swatch-title></span><sup data-swatch-mark hidden>{{ swatch_index }}</sup></strong> <span data-swatch-price></span></p>
                  {%- comment -%} the group's own h2 names the row, so point at it
                  rather than inventing a second label that could drift from it —
                  and a per-model id, since one template exists per variant and
                  duplicate ids would make aria-labelledby ambiguous {%- endcomment -%}
                  <div class="bsp-swatches" role="radiogroup" aria-labelledby="bsp-swhead-{{ variant.id }}">
                    {%- for av in group.variants.value -%}
                      {%- assign checked = false -%}
                      {%- for cid in group.checked_variants.value -%}{%- if cid.id == av.id -%}{%- assign checked = true -%}{%- endif -%}{%- endfor -%}
                      {%- unless av.available -%}{%- assign checked = false -%}{%- endunless -%}
                      {%- if checked_fallback != 0 and checked_fallback == av.id -%}{%- assign checked = true -%}{%- endif -%}
                      <div class="bsp-swatch{% unless av.available %} bsp-swatch--soldout{% endunless %}">
                        <input type="radio" name="bsp-addon-{{ gkey | escape }}" id="bsp-a-{{ variant.id }}-{{ av.id }}"
                          value="{{ av.id }}" data-cents="{{ av.price }}" data-title="{{ av.title | escape }}" data-sku="{{ av.sku | escape }}"
                          data-ship-date="{{ av.metafields.store.ship_date.value }}"
                          {%- capture _eta_msg -%}{% render 'bs-eta-msg', variant: av %}{%- endcapture -%}
                          data-ship-msg="{{ _eta_msg | strip | escape }}"
                          data-eta-parts="{% for _pp in av.metafields.store.eta_text.value %}{{ _pp.system.handle }}{% unless forloop.last %},{% endunless %}{% endfor %}"
                          data-fine="{{ av.metafields.addon.fine_print.value | escape }}"
                          {% if av.metafields.addon.ships_separately.value %}data-ships-separately="1"{% endif %}
                          {% if checked %}checked{% endif %}
                          {%- unless av.available %} disabled data-soldout="1"{% endunless %}>
                        <label for="bsp-a-{{ variant.id }}-{{ av.id }}" title="{{ av.title | escape }}{% unless av.available %}, {{ sold_out_label }}{% endunless %}">
                          {%- assign sw = av.metafields.addon.swatch_colors.value | first -%}
                          {%- comment -%} Atomic Purple's swatch metafield is
                          #7202fb — a hard blue-violet that reads nothing like
                          the translucent magenta shell in the render beside it
                          (sampling that render gives ~#a95ea0 lit, #904878 in
                          the upper mids). Using the marketing site's own value
                          for this colorway so the two repos agree. Delete this
                          override once addon.swatch_colors is corrected in
                          admin — the metafield stays the source of truth.
                          {%- endcomment -%}
                          {%- comment -%} keyed on SKU, not title: variant titles
                          are translated per-locale (JA broke title matching),
                          SKUs never are. BS2S-P = Atomic Purple, BS2S-C =
                          Crystal Clear. {%- endcomment -%}
                          {%- if av.sku == 'BS2S-P' -%}{%- assign sw = '#a92fd6' -%}{%- endif -%}
                          <span class="bsp-swatch-chip" style="background: {{ sw | default: '#333' }};{% if av.sku == 'BS2S-C' %} background: linear-gradient(135deg, #ffffff 20%, #cdd1d4);{% endif %}"></span>
                          {%- comment -%} The colour IS the control here, which
                          means the label had no text in it and the radio reached
                          assistive tech with no name at all — three unnamed
                          buttons where a sighted visitor reads the name off the
                          line above. The title attribute above is a tooltip on
                          the LABEL, so it never enters the input's accessible
                          name. This span does, and paints nothing: it is the
                          same colour name the swatch row prints when selected.
                          {%- endcomment -%}
                          <span class="visually-hidden">{{ av.title }}{% unless av.available %}, {{ sold_out_label }}{% endunless %}</span>
                        </label>
                      </div>
                    {%- endfor -%}
                  </div>
                  <p class="bsp-fine" data-swatch-fine hidden><sup>{{ swatch_index }}</sup><span data-swatch-fine-text></span></p>
                {%- else -%}
                  {%- comment -%} Strap tree (the landing configurator's layout):
                  a strap group carrying a Halo option renders its straps as a
                  2-col row, then a bracket connector, then the Halo add-on
                  full-width — soft/audio visibly both feed the halo. Pure
                  layout: the halo is one of this group's own variants, pulled
                  out of the row so it can sit under the bracket. {%- endcomment -%}
                  {%- assign halo_found = false -%}
                  {%- if is_strap_group -%}
                    {%- for tav in group.variants.value -%}
                      {%- comment -%} SKU, not title: titles translate (JA broke
                      this), SKUs don't. BSBAHK = the Halo Mount kit variant.
                      {%- endcomment -%}
                      {%- if tav.sku == 'BSBAHK' and halo_found == false -%}
                        {%- assign halo_av = tav -%}
                        {%- assign halo_found = true -%}
                      {%- endif -%}
                    {%- endfor -%}
                  {%- endif -%}
                  {%- assign show_tree = halo_found -%}
                  <div class="bsp-opts{% if show_tree %} bsp-opts--tree{% endif %}">
                  {%- comment -%} Footnote markers count RENDERED cards, not
                  variant positions — halo leaves this list and two cards are
                  injected, so variant index no longer matches what's on screen
                  (it briefly gave two different cards the same marker).
                  card_n is NOT reset here: it carries on from the group above
                  (see the hoisted counter). group_first_n is where this group
                  entered the run, so the notes loop below can replay the same
                  numbers the cards were given. {%- endcomment -%}
                  {%- assign group_first_n = card_n -%}
                  {%- for av in group.variants.value -%}
                    {%- comment -%} the halo card renders after the bracket, so
                    keep it out of the 2-col row — identified by id against the
                    halo found above, so no title/locale dependence {%- endcomment -%}
                    {%- if show_tree and av.id == halo_av.id -%}{%- continue -%}{%- endif -%}
                    {%- comment -%} A number is spent only by a card that actually
                    carries a note — the snippet renders the marker under exactly
                    this condition. Counting every rendered card instead burned
                    numbers on the Soft and Audio Strap, which have no fine print,
                    and the visible run came out 1,2,3,6,7,8 with two digits
                    missing and nothing to explain them. {%- endcomment -%}
                    {%- if av.metafields.addon.fine_print.value != blank -%}
                      {%- assign card_n = card_n | plus: 1 -%}
                    {%- endif -%}
                    {%- assign checked = false -%}
                    {%- assign disabled = false -%}
                    {%- assign ephemeral = false -%}
                    {%- for cid in group.checked_variants.value -%}{%- if cid.id == av.id -%}{%- assign checked = true -%}{%- endif -%}{%- endfor -%}
                    {%- if checked_fallback != 0 and checked_fallback == av.id -%}{%- assign checked = true -%}{%- endif -%}
                    {%- for did in group.disabled_variants.value -%}{%- if did.id == av.id -%}{%- assign disabled = true -%}{%- endif -%}{%- endfor -%}
                    {%- for eid in group.ephemeral_variants.value -%}{%- if eid.id == av.id -%}{%- assign ephemeral = true -%}{%- endif -%}{%- endfor -%}
                    {% render 'bs-opt-card', variant: variant, av: av, gkey: gkey, input_type: input_type, checked: checked, disabled: disabled, ephemeral: ephemeral, wide: false, index: card_n %}
                  {%- endfor -%}
                  {%- if show_tree -%}
                    {%- comment -%} bracket: both strap columns drop into the
                    halo row (path from the landing's .bsx-strap-svg) {%- endcomment -%}
                    <div class="bsp-tree-link" aria-hidden="true"><svg class="bsp-tree-svg" viewBox="0 0 100 24" preserveAspectRatio="none"><path d="M25,1 V10 H50 M75,1 V10 H50 M50,10 V23"/></svg></div>
                    {%- if halo_av.metafields.addon.fine_print.value != blank -%}
                      {%- assign card_n = card_n | plus: 1 -%}
                    {%- endif -%}
                    {%- assign halo_index = card_n -%}
                    {%- comment -%} same data lookups as the row cards — halo is
                    an ordinary member of this group, only its position differs
                    {%- endcomment -%}
                    {%- assign halo_checked = false -%}
                    {%- assign halo_disabled = false -%}
                    {%- assign halo_ephemeral = false -%}
                    {%- for cid in group.checked_variants.value -%}{%- if cid.id == halo_av.id -%}{%- assign halo_checked = true -%}{%- endif -%}{%- endfor -%}
                    {%- for did in group.disabled_variants.value -%}{%- if did.id == halo_av.id -%}{%- assign halo_disabled = true -%}{%- endif -%}{%- endfor -%}
                    {%- for eid in group.ephemeral_variants.value -%}{%- if eid.id == halo_av.id -%}{%- assign halo_ephemeral = true -%}{%- endif -%}{%- endfor -%}
                    {%- comment -%} the variant is still named "Halo Mount +
                    Universal-Fit Cushion" in the admin; the card and the note
                    below already say the cushion is included, so shorten it to
                    the part before the "+". Locale-safe: the old version
                    matched 'Universal' and substituted English "Halo Mount",
                    which shipped English into the JA page; splitting keeps the
                    translated words. Self-retiring: rename the variant to plain
                    "Halo Mount" (no "+") and the split passes the full title
                    through — the cart line item shows the admin name either
                    way. {%- endcomment -%}
                    {%- assign halo_title = halo_av.title | split: '+' | first | strip -%}
                    {%- if halo_title == blank -%}{%- assign halo_title = halo_av.title -%}{%- endif -%}
                    {% render 'bs-opt-card', variant: variant, av: halo_av, gkey: gkey, input_type: input_type, checked: halo_checked, disabled: halo_disabled, ephemeral: halo_ephemeral, wide: true, index: halo_index, title_override: halo_title %}
                    <div class="bsp-halo-note" data-halo-note data-included-label="{{ shop.metaobjects.product_misc.live.included_label | default: 'Included with Halo Mount' }}" hidden>{{ shop.metaobjects.product_misc.live.halo_note | default: 'The Halo Mount includes the Universal-fit Cushion, so it&rsquo;s selected for you automatically.' }}</div>
                  {%- endif -%}
                  </div>
                  {%- comment -%} same rendered-card count as the markers above,
                  so every footnote points at the card that carries it — replayed
                  from where this group entered the page's run, not from 1
                  {%- endcomment -%}
                  {%- assign note_n = group_first_n -%}
                  {%- for av in group.variants.value -%}
                    {%- comment -%} halo's card sits under the bracket, so its
                    footnote prints there too (below) — otherwise the marker
                    would have nothing to point at {%- endcomment -%}
                    {%- if show_tree and av.id == halo_av.id -%}{%- continue -%}{%- endif -%}
                    {%- comment -%} same gate the cards used, so the two counters
                    stay in step {%- endcomment -%}
                    {%- if av.metafields.addon.fine_print.value != blank -%}
                      {%- assign note_n = note_n | plus: 1 -%}
                      <p class="bsp-fine"><sup>{{ note_n }}</sup>{{ av.metafields.addon.fine_print.value }}</p>
                    {%- endif -%}
                  {%- endfor -%}
                  {%- if show_tree and halo_av.metafields.addon.fine_print.value != blank -%}
                    <p class="bsp-fine"><sup>{{ halo_index }}</sup>{{ halo_av.metafields.addon.fine_print.value }}</p>
                  {%- endif -%}
                {%- endif -%}
              </div>
            {%- endfor -%}
            {%- comment -%} Where the page's footnote run stands once the option
            groups are done, so the Rx card below can continue it instead of
            hardcoding "4,5". Taken from the FIRST model because the Rx card is
            rendered once, outside the per-model templates (LensAdvizor owns that
            line item and it must stay out of #bsp-groups) — so it cannot carry a
            different number per model. Every model ships the same group set
            today, which is what makes that safe; if one ever doesn't, the Rx
            markers follow the base model. {%- endcomment -%}
            {%- if forloop.first -%}{%- assign rx_mark_from = card_n -%}{%- endif -%}
          </template>
        {%- endfor -%}
        {%- assign rx_mark_a = rx_mark_from | plus: 1 -%}
        {%- assign rx_mark_b = rx_mark_from | plus: 2 -%}

        {%- comment -%} ---- Lenses / Optics: the LensAdvizor hand-off ----------
          A card, not a priced option. Clicking it opens the app's own modal
          (by forwarding to #la-select-lenses-btn); the app collects the Rx and
          runs its own /cart/add.js with the prescription properties attached.
          The card's checked state is a READ-OUT of the cart, never a toggle —
          which is why the click is preventDefault'ed. Removal happens in the
          cart drawer, since taking it out here would mean this section issuing
          cart writes against a line item the app owns. {%- endcomment -%}
        {%- if has_rx_addon -%}
          <div class="bsp-group" id="bsp-rx-group"
               data-rx-product="{{ rx_product.id }}"
               data-rx-variant="{{ rx_variant.id }}">
            {%- comment -%} header/description come from the group's addon_type
            entry, exactly like the loop's groups; defaults keep the page whole
            if the fields are ever blanked in admin {%- endcomment -%}
            <h2 class="bsp-group-head">{{ rx_group_data.addon_type.value.header | default: 'Lenses / Optics' }}</h2>
            <p class="bsp-group-desc">{{ rx_group_data.addon_type.value.description | default: 'Beyond 2 is too small to wear glasses inside. Magnetic prescription inserts sit on top of the optics instead, and slip on or off in seconds.' }}</p>
            {%- comment -%} A link, not an option. LensAdvizor only runs on its
            own product page, so rather than fake an in-configurator selection
            this card says plainly that lenses are ordered there — new tab, so
            the configuration being built here is never lost. The arrow and the
            copy both carry that; the card still reads the cart so it can
            confirm once the visitor comes back. {%- endcomment -%}
            <div class="bsp-opts">
              <a class="bsp-opt bsp-opt--wide bsp-opt--link" id="bsp-rx-opt"
                 href="{{ rx_product.url }}" target="_blank" rel="noopener"
                 data-title="{{ rx_product.title | escape }}"
                 aria-label="{{ rx_product.title | escape }} — {{ shop.metaobjects.product_misc.live.rx_a11y | default: 'opens in a new tab, prescription required' }}">
                {%- if rx_product.featured_image -%}
                  <span class="bsp-opt-thumb"><img src="{{ rx_product.featured_image | image_url: width: 160 }}" alt="" loading="lazy"></span>
                {%- endif -%}
                <span class="bsp-opt-body">
                  {%- comment -%} the last option group hands the run over here,
                  so these two continue it rather than restating fixed digits
                  {%- endcomment -%}
                  <span class="bsp-opt-title">{{ rx_product.title }}<sup>{{ rx_mark_a }},{{ rx_mark_b }}</sup></span>
                  {%- comment -%} same variant metafields the loop's cards read
                  (bs-opt-card): addon.description is the sub, addon.callout the
                  chip {%- endcomment -%}
                  <span class="bsp-opt-sub" data-rx-sub>{{ rx_variant.metafields.addon.description.value | default: 'Magnetic frames snap onto Beyond&rsquo;s optics and lift straight back off. Each pair is ground to your own prescription, so VR stays sharp without glasses.' }}</span>
                  <span class="bsp-opt-chip" data-rx-chip>{{ rx_variant.metafields.addon.callout.value | default: 'Made to your prescription' }}</span>
                </span>
                <span class="bsp-opt-price">+ {{ rx_variant.price | money_with_currency }}</span>
                <svg class="bsp-opt-ext" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M5.5 10.5 10.5 5.5M6.5 5.5h4v4" stroke-linecap="round" stroke-linejoin="round"/></svg>
              </a>
            </div>
            {%- comment -%} the practical detail nobody wants in the headline:
            the first = what to buy and which prescription to give us, the
            second = how the hand-off to the lens page and back actually goes
            {%- endcomment -%}
            <p class="bsp-fine"><sup>{{ rx_mark_a }}</sup>{{ rx_variant.metafields.addon.fine_print.value | default: 'Contact lenses work in VR too, but inserts keep eye strain, headaches, and discomfort away without anything in your eyes. Order them alongside your headset and everything ships together. Because we build each pair to order, custom lenses can take up to 4 weeks to make. For best results, give us your eyeglasses prescription, not your contact lens prescription.' }}</p>
            <p class="bsp-fine"><sup>{{ rx_mark_b }}</sup>{{ shop.metaobjects.product_misc.live.rx_fine_flow | default: 'Ordering opens a new tab where you upload your prescription and add the inserts to your cart. Come back to this tab when you are done: the card above will show them as added, and you can finish your order from here.' }}</p>
          </div>
        {%- endif -%}

        <p class="bsp-error" id="bsp-error" hidden></p>
      </div>

      {%- comment -%} ---- sticky summary footer ---- {%- endcomment -%}
      <div class="bsp-footer">
        <div class="bsp-footer-inner">
          <div class="bsp-footer-row">
            <div class="bsp-ship">
              <span class="bsp-ship-label">{{ shop.metaobjects.cart.live.estimated_delivery | default: 'Estimated Delivery' }}</span>
              <span class="bsp-ship-msg" id="bsp-ship-msg">{{ shop.metaobjects.product_misc.live.ship_msg | default: '---' }}</span>
            </div>
            <span class="bsp-total" id="bsp-total">{{ product.price | money_with_currency }}</span>
          </div>
          {%- comment -%} the no-buy template shows the product without a
          purchase path (the old shop-bs-2-no-buy behaviour) {%- endcomment -%}
          {%- unless template.suffix contains 'no-buy' -%}
            {%- comment -%} Every variant gone: the button is the last thing that
            can still claim the product is buyable, so it names the state instead
            of failing on click. Checked first, ahead of the qty rule — "Added"
            over a sold-out product would be a lie the visitor can act on.
            {%- endcomment -%}
            <button type="button" class="bsp-order" id="bsp-order"
              {% if product.available == false %}disabled data-sold-out="1"{% elsif qty_limit > 0 and qty_in_cart >= qty_limit %}disabled data-limit-reached="1"{% endif %}>
              {%- if product.available == false -%}
                {{ sold_out_label }}
              {%- elsif qty_limit > 0 and qty_in_cart >= qty_limit -%}
                {{ shop.metaobjects.product_misc.live.added | default: 'Added' }}
              {%- elsif needs_rx -%}
                {%- comment -%} the button opens LensAdvizor rather than buying,
                so it must not promise a purchase {%- endcomment -%}
                {{ shop.metaobjects.product_misc.live.rx_insert_btn | default: 'Select lenses and purchase' }}
              {%- else -%}
                {{ product.metafields.store.add_text | default: shop.metaobjects.product_misc.live.order_now | default: 'Order now' }}
              {%- endif -%}
            </button>
          {%- endunless -%}
        </div>
      </div>
    </div>
  </div>
</section>

{%- comment -%} LensAdvizor hook — verbatim from the Builder page: hidden Dawn
product form + the prescription trigger the app's JS binds to. {%- endcomment -%}
<product-form class="product-form" style="display: none">
  <div class="product-form__error-message-wrapper" role="alert" hidden="">
    <span class="product-form__error-message"></span>
  </div>
  <form method="post" action="/cart/add" id="product-form-template--16190006231257__main" accept-charset="UTF-8" class="form" enctype="multipart/form-data" novalidate="novalidate" data-type="add-to-cart-form">
    <input type="hidden" name="form_type" value="product"><input type="hidden" name="utf8" value="✓"><input type="hidden" name="id" value="43041139392729">
    <div class="product-form__buttons">
      <button type="submit" name="add" class="product-form__submit button button--full-width button--primary" style="visibility: visible; display: none;">
        <span>Add to cart </span>
      </button>
      <button type="button" id="la-select-lenses-btn" class="la-select-lenses-btn" data-toggle="la-modal" data-target="#myPrescription">{{ shop.metaobjects.product_misc.live.rx_insert_btn | default: 'Upload prescription' }}</button>
    </div>
  </form>
</product-form>

<script>
(function () {
  var root = document.getElementById('bsp');
  if (!root) return;

  var IS_BEYOND = {{ is_beyond | json }};
  /* every product photo, in admin order — empty on Beyond 2 and on any product
     with a single shot, which is what switches the gallery off.
     `bleed` marks the shots that take the whole plate instead of floating in the
     stage box (see photo_positions above); paint() reads it off this list, so a
     step through the gallery carries the right treatment with it. */
  var GALLERY = [
    {%- if has_gallery -%}
      {%- for image in product.images -%}
        {%- assign probe = image.position | prepend: ',' | append: ',' -%}
        {%- assign is_photo = false -%}
        {%- if photo_key != '' and photo_key contains probe -%}{%- assign is_photo = true -%}{%- endif -%}
        { src: {{ image | image_url: width: 1400 | json }}, alt: {{ image.alt | default: product.title | json }}, bleed: {{ is_photo | json }} }{% unless forloop.last %},{% endunless %}
      {%- endfor -%}
    {%- endif -%}
  ];
  var CURRENCY = {{ cart.currency.iso_code | json }};
  /* the "Addon for" line-item property on every addon — the PRODUCT title
     ("Bigscreen Beyond 2"), not the selected variant's, matching the live cart */
  var PRODUCT_TITLE = {{ product.title | json }};
  var LOCALE = {{ request.locale.iso_code | json }};
  var CART_URL = {{ routes.cart_url | json }};
  var QTY_LIMIT = {{ qty_limit | default: 0 | json }};
  /* the same set Liquid counts for qty_in_cart: this product + qty_count_products */
  var COUNTED_PRODUCT_IDS = [{{ product.id }}{% for p in product.metafields.cart.qty_count_products.value %}, {{ p.id }}{% endfor %}];
  var ADDED_LABEL = {{ shop.metaobjects.product_misc.live.added | default: 'Added' | json }};
  /* the button's normal label, mirroring the server-side cascade above — so the
     "Added" state can be undone without a reload when the drawer empties */
  var ORDER_LABEL = {% if needs_rx %}{{ shop.metaobjects.product_misc.live.rx_insert_btn | default: 'Select lenses and purchase' | json }}{% else %}{{ product.metafields.store.add_text | default: shop.metaobjects.product_misc.live.order_now | default: 'Order now' | json }}{% endif %};
  var MONEY_FORMAT = {{ shop.money_with_currency_format | json }};   /* e.g. "€{{amount_with_comma_separator}} EUR" */
  var ASSET_BASE = {{ 'dyn-bs2--clear-softstrap-universalcushion.webp' | asset_url | json }}.split('?')[0].replace(/[^\/]+$/, '');
  var FALLBACK_SHIP = {{ shop.metaobjects.product_misc.live.ship_msg | default: '---' | json }};
  /* second line on the stage chips — see syncAddonPreview(). The non-Rx chip
     only ever fires when a SECOND facial interface is checked (the render
     depicts exactly one), so name that rather than the vaguer "an extra" —
     it answers why the thing isn't in the picture. */
  var ADDON_EXTRA_NOTE = {{ shop.metaobjects.product_misc.live.addon_extra_note | default: 'Added as a 2nd cushion' | json }};
  var RX_EXTRA_NOTE = {{ shop.metaobjects.product_misc.live.rx_extra_note | default: 'Extra — in your cart' | json }};

  /* ---- ETA overrides + localized property keys (see ETA-SYSTEM.md) --------
     The buyer-visible property KEYS are localized now (they exist to aid the
     customer); machine logic downstream keys on the hidden props
     (_ships_separately, _ship_date, _eta_parts), never on these labels.
     English defaults keep the page working if a cart.live field is blanked. */
  var PROP_EST_KEY = {{ shop.metaobjects.cart.live.estimated_delivery | default: 'Estimated ship date' | json }};
  var PROP_ADDON_FOR_KEY = {{ shop.metaobjects.cart.live.prop_addon_for | default: 'Addon for' | json }};
  var PROP_SHIPS_SEP_KEY = {{ shop.metaobjects.cart.live.prop_ships_separately | default: 'Ships separately' | json }};
  var ETA_PREFIX = {{ shop.metaobjects.cart.live.eta_estimated_prefix | default: 'Estimated ' | json }};
  /* Every ACTIVE eta_override targeting one of this product's variants,
     Liquid-precomposed in the visitor's locale (JS only selects, never
     localizes). Matching: model must be the selected model and every id in
     .addons must be selected (ephemeral and ships-separately included —
     matching is about configuration identity); extras don't break a match.
     A matching override REPLACES the bundle estimate. Best = most addons
     matched, ties broken by latest date ('' sorts as epoch). */
  {%- assign _model_ids = product.variants | map: 'id' -%}
  var ETA_OVERRIDES = [
  {%- for _ov in metaobjects.eta_override.values -%}
    {%- if _model_ids contains _ov.model_variant.value.id -%}
      {%- capture _ov_msg -%}{% render 'bs-eta-msg', parts: _ov.eta_text.value, eta_date: _ov.eta_date.value %}{%- endcapture -%}
      {%- if _ov_msg != blank %}
    { model: {{ _ov.model_variant.value.id | json }},
      addons: [{% for _av in _ov.addon_variants.value %}{{ _av.id }}{% unless forloop.last %},{% endunless %}{% endfor %}],
      date: {{ _ov.eta_date.value | default: '' | json }},
      msg: {{ _ov_msg | strip | json }},
      {%- capture _ov_parts -%}{% for _pp in _ov.eta_text.value %}{{ _pp.system.handle }}{% unless forloop.last %},{% endunless %}{% endfor %}{%- endcapture -%}
      parts: {{ _ov_parts | json }} },
      {%- endif -%}
    {%- endif -%}
  {%- endfor -%}
  ];
  /* the override the current selection matches, refreshed by update() */
  var activeOverride = null;
  function matchOverride() {
    var m = modelInput();
    if (!m || !ETA_OVERRIDES.length) return null;
    var modelId = parseInt(m.value, 10);
    var selected = {};
    addonInputs().forEach(function (i) { selected[parseInt(i.value, 10)] = true; });
    var best = null;
    ETA_OVERRIDES.forEach(function (o) {
      if (o.model !== modelId) return;
      for (var k = 0; k < o.addons.length; k++) { if (!selected[o.addons[k]]) return; }
      if (!best ||
          o.addons.length > best.addons.length ||
          (o.addons.length === best.addons.length && o.date > best.date)) best = o;
    });
    return best;
  }

  var groupsHost = document.getElementById('bsp-groups');
  var renderA = document.getElementById('bsp-render-a');
  var renderB = document.getElementById('bsp-render-b');
  var caption = document.getElementById('bsp-caption');   /* Beyond only */
  var totalEl = document.getElementById('bsp-total');
  var shipEl = document.getElementById('bsp-ship-msg');
  var orderBtn = document.getElementById('bsp-order');
  var errEl = document.getElementById('bsp-error');
  var activeRender = renderA;

  /* format cents with Shopify's own money template so separators match the market */
  function group(s, sep) { return s.replace(/\B(?=(\d{3})+(?!\d))/g, sep); }
  function fmt(cents) {
    var v = (cents / 100).toFixed(2), whole = v.split('.')[0], dec = v.split('.')[1];
    return MONEY_FORMAT.replace(/\{\{\s*(\w+)\s*\}\}/, function (_, key) {
      switch (key) {
        case 'amount':                       return group(whole, ',') + '.' + dec;
        case 'amount_with_comma_separator':  return group(whole, '.') + ',' + dec;
        case 'amount_no_decimals':           return group(whole, ',');
        case 'amount_no_decimals_with_comma_separator': return group(whole, '.');
        case 'amount_with_apostrophe_separator': return group(whole, "'") + '.' + dec;
        default: return group(whole, ',') + '.' + dec;
      }
    });
  }

  function modelInput() { return root.querySelector('input[name="bsp-model"]:checked'); }
  function addonInputs() { return [].slice.call(groupsHost.querySelectorAll('input:checked')); }

  /* ---- variant identity: SKU first, title as EN-only fallback --------------
     Variant titles are TRANSLATED per-locale (the JA page broke every title
     regex here), SKUs never are. Every matcher below asks the SKU first and
     falls back to the old title test, so a variant missing its SKU degrades to
     the previous EN-only behavior instead of no behavior. SKU map (admin):
     BS2/BS2E/BS2VRC models · BS2S-B/O/C/P shells · BSBAHK halo kit ·
     BS1U universal / BSBAPC custom cushion · BS1A audio strap. */
  function skuOf(i) { return (i && i.dataset.sku) || ''; }
  function titleOf(i) { return (i && i.dataset.title) || ''; }
  function isVrc(i) { return skuOf(i) === 'BS2VRC' || /vrchat/i.test(titleOf(i)); }
  function isEyeModel(i) { return skuOf(i) === 'BS2E' || skuOf(i) === 'BS2VRC' || /\b2e\b/i.test(titleOf(i)); }
  function isHaloOpt(i) { return skuOf(i) === 'BSBAHK' || /halo/i.test(titleOf(i)); }
  function isUniOpt(i) { return skuOf(i) === 'BS1U' || (/universal/i.test(titleOf(i)) && !isHaloOpt(i)); }
  function isCustomOpt(i) { return skuOf(i) === 'BSBAPC' || /custom/i.test(titleOf(i)); }
  function isAudioOpt(i) { return skuOf(i) === 'BS1A' || /audio strap/i.test(titleOf(i)); }

  /* ---- render-stage image: map selections onto the dyn-bs2 combo matrix ---- */
  var SHELL_SKUS = { 'BS2S-B': 'black', 'BS2S-O': 'orange', 'BS2S-C': 'clear', 'BS2S-P': 'purple' };
  /* title fallback ordered so "Nuclear Orange" hits 'orange' before its embedded "clear" */
  var SHELL_TOKENS = [['orange', 'orange'], ['black', 'black'], ['purple', 'purple'], ['crystal', 'clear'], ['clear', 'clear']];
  function shellToken(input) {
    if (SHELL_SKUS[skuOf(input)]) return SHELL_SKUS[skuOf(input)];
    var title = titleOf(input).toLowerCase();
    for (var i = 0; i < SHELL_TOKENS.length; i++) if (title.indexOf(SHELL_TOKENS[i][0]) !== -1) return SHELL_TOKENS[i][1];
    return null;
  }
  /* the shell token driving both the render file and the stage tint */
  function currentShell() {
    var m = modelInput();
    if (m && isVrc(m)) return 'purple';   /* the VRChat colorway */
    var shell = null;
    addonInputs().forEach(function (i) {
      if (i.name.indexOf('shell') === -1) return;
      var s = shellToken(i);
      if (s) shell = s;
    });
    return shell || 'clear';
  }
  function comboUrl() {
    var m = modelInput();
    /* accessories show their own photos — paged by the gallery where there is
       more than one, otherwise the selected variant's single shot */
    if (!IS_BEYOND) {
      if (GALLERY.length) return GALLERY[galIndex].src;
      return (m && m.dataset.image) || null;
    }
    var halo = false, strap = 'softstrap', hasCustom = false, hasUniversal = false;
    addonInputs().forEach(function (i) {
      if (isHaloOpt(i)) halo = true;
      else if (isUniOpt(i)) hasUniversal = true;
      else if (isCustomOpt(i)) hasCustom = true;
      if (isAudioOpt(i)) strap = 'audiostrap';
    });
    /* both cushions can be bought together, so the render needs a deterministic
       winner rather than "whichever input came last in the DOM": show the
       custom fit, since that's the one moulded to your face and therefore the
       one you'd wear by default. Halo still overrides both (it ships the seal). */
    var cushion = hasUniversal && !hasCustom ? 'universalcushion' : 'customcushion';
    var shell = currentShell();
    var file = halo
      ? 'dyn-bs2--' + shell + '-halo-' + strap + '-universallightseal.webp'
      : 'dyn-bs2--' + shell + '-' + strap + '-' + cushion + '.webp';
    return ASSET_BASE + file;
  }
  /* cross-fade the A/B pair to `url`. Split out of swapRender() so the gallery
     arrows reuse the exact same transition the configurator does. */
  function paint(url, alt) {
    /* compare on the path alone so the initial Liquid-rendered src counts as the
       same image (see imgKey: the three URL forms in play here disagree on
       scheme and host, and on the ?v= asset version) */
    if (!url || imgKey(activeRender.src) === imgKey(url)) return;
    var next = activeRender === renderA ? renderB : renderA;
    /* Per IMAGE, not per plate: a step from a photo to a render cross-fades the
       two treatments against each other, so the outgoing one has to keep its
       own. Empty GALLERY (Beyond 2, single-shot products) simply never matches. */
    var entry = GALLERY[galleryIndexOf(url)];
    var bleed = !!(entry && entry.bleed);
    next.classList.toggle('is-bleed', bleed);
    next.onload = function () {
      next.onload = null;   /* one-shot: cached images fire this twice otherwise */
      next.classList.add('is-active');
      if (activeRender !== next) activeRender.classList.remove('is-active');
      activeRender = next;
      /* the plate's own chrome (rating, arrows, counter) is dark-on-#F4F3F3 and
         disappears over a photograph — this is what backs it. Flipped with the
         arrival rather than at fade start, so it changes with the picture. */
      var plate = document.getElementById('bsp-stage-plate');
      if (plate) plate.classList.toggle('bsp-stage--photo', bleed);
    };
    if (alt != null) next.alt = alt;
    next.src = url;
    if (next.complete && next.onload) next.onload();
  }

  function swapRender() {
    syncGalleryToVariant();
    var url = comboUrl();
    /* Park the add-on chip against the headset's lower-right corner. How low
       that corner sits depends on the combo — measured off the alpha channel of
       the matrix, the right-hand silhouette bottoms out at 71% of the frame on a
       soft strap, 75-76% with an audio strap or halo, 78% with both — so the
       anchor rides with the render rather than sitting at the worst case.
       Set here (not in syncAddonPreview) because it tracks the picture, not the
       basket, and it must follow even when no chip is currently up. */
    var stack = document.getElementById('bsp-addon-stack');
    if (stack && url) {
      var deep = /halo|audiostrap/.test(url);
      stack.style.setProperty('--bsp-addon-y', deep ? '81%' : '74%');
    }
    paint(url);
  }

  /* ---- Photo gallery (non-Beyond products with more than one shot) ---------
     Arrows page the product's own photos through the same A/B cross-fade the
     configurator uses. Variant selection still wins: switching to a variant
     that has its own photo jumps the gallery to that photo (see
     syncGalleryToVariant), and paging from there is relative to it. */
  var galIndex = 0;
  var galPrev = document.getElementById('bsp-gal-prev');
  var galNext = document.getElementById('bsp-gal-next');
  var galCount = document.getElementById('bsp-gal-count');
  var lastModelId = null;

  function pad(n) { return n < 10 ? '0' + n : String(n); }
  /* The same CDN image reaches this script in three shapes: "/cdn/..." from a
     Liquid attribute, "//shop.myshopify.com/cdn/..." from `| json`, and the
     fully-resolved "http://host/cdn/..." the DOM hands back for img.src. Compare
     the path only — host, scheme and the ?v= version all differ harmlessly. */
  function imgKey(url) {
    return (url || '').split('?')[0].replace(/^https?:/, '').replace(/^\/\/[^\/]+/, '');
  }
  function galleryIndexOf(url) {
    if (!url) return -1;
    var key = imgKey(url);
    for (var i = 0; i < GALLERY.length; i++) {
      if (imgKey(GALLERY[i].src) === key) return i;
    }
    return -1;
  }
  /* Only on an actual model change — otherwise every unrelated update() (an
     add-on toggle) would yank the visitor back to the variant's photo mid-browse. */
  function syncGalleryToVariant() {
    if (!GALLERY.length) return;
    var m = modelInput();
    var id = m ? m.value : null;
    if (id === lastModelId) return;
    lastModelId = id;
    var i = galleryIndexOf(m && m.dataset.image);
    if (i !== -1) galIndex = i;
    renderCount();
  }
  function renderCount() {
    if (galCount) galCount.textContent = pad(galIndex + 1) + ' / ' + pad(GALLERY.length);
  }
  function galGo(step) {
    if (!GALLERY.length) return;
    galIndex = (galIndex + step + GALLERY.length) % GALLERY.length;
    renderCount();
    paint(GALLERY[galIndex].src, GALLERY[galIndex].alt);
  }
  if (GALLERY.length) {
    galPrev && galPrev.addEventListener('click', function () { galGo(-1); });
    galNext && galNext.addEventListener('click', function () { galGo(1); });
    /* arrow keys once the stage (or an arrow) has focus */
    var plateEl = document.getElementById('bsp-stage-plate');
    plateEl && plateEl.addEventListener('keydown', function (e) {
      if (e.key === 'ArrowLeft') { galGo(-1); e.preventDefault(); }
      else if (e.key === 'ArrowRight') { galGo(1); e.preventDefault(); }
    });
    /* swipe on touch: the arrows are small targets on a phone */
    var tx = null, ty = null;
    plateEl && plateEl.addEventListener('touchstart', function (e) {
      var t = e.changedTouches[0]; tx = t.clientX; ty = t.clientY;
    }, { passive: true });
    plateEl && plateEl.addEventListener('touchend', function (e) {
      if (tx === null) return;
      var t = e.changedTouches[0], dx = t.clientX - tx, dy = t.clientY - ty;
      tx = null;
      /* horizontal intent only, so a page scroll started on the plate is not a swipe */
      if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.5) galGo(dx < 0 ? 1 : -1);
    }, { passive: true });
    /* the whole set is a handful of images — preload so a step is instant */
    GALLERY.forEach(function (g) { var im = new Image(); im.src = g.src; });
  }

  /* ---- Halo <-> Universal cushion interlock (the landing's flow) -----------
     The Halo Mount ships with the Universal-Fit Cushion. When the halo add-on
     (straps group) is checked: the standalone Universal-Fit option in the
     cushion group is auto-selected and locked — greyed, priced as included
     (data-ephemeral suppresses both the total line and the cart line; the halo
     variant's own price already covers the cushion) — and the note under the
     halo card explains it. Unchecking halo restores the cushion the visitor
     had before. Picking Custom-Fit while halo is on drops halo (the landing
     rule: custom cushion and halo are mutually exclusive).
     Identified by SKU (BSBAHK in straps, BS1U in cushions) with a title
     fallback — see the identity helpers above. Changing either SKU in the
     admin silently disarms this interlock. */
  function syncHalo() {
    var straps = [].slice.call(groupsHost.querySelectorAll('input[name*="strap"]'));
    var halo = straps.filter(isHaloOpt)[0];
    var cushions = [].slice.call(groupsHost.querySelectorAll('input[name*="cushion"]'));
    var uni = cushions.filter(isUniOpt)[0];
    if (!halo || !uni) return;
    var note = groupsHost.querySelector('[data-halo-note]');
    var priceEl = uni.parentElement.querySelector('[data-price]');
    var locked = uni.dataset.haloLocked === '1';
    /* the interlock owns "disabled" for this card, so it has to hand it back to
       whatever owned it first — an out-of-stock cushion stays locked out */
    var uniSold = uni.dataset.soldout === '1';

    function unlock() {
      uni.disabled = uniSold;
      delete uni.dataset.haloLocked;
      delete uni.dataset.ephemeral;
      uni.closest('.bsp-opt').classList.remove('bsp-opt--locked');
      if (priceEl) priceEl.textContent = priceEl.dataset.original;
      if (note) note.hidden = true;
    }

    if (halo.checked && !locked && !uniSold) {
      var cur = cushions.filter(function (i) { return i.checked; })[0];
      halo.dataset.prevCushion = (cur && cur !== uni) ? cur.value : '';
      uni.checked = true;
      uni.disabled = true;
      uni.dataset.haloLocked = '1';
      uni.dataset.ephemeral = '1';
      uni.closest('.bsp-opt').classList.add('bsp-opt--locked');
      if (priceEl && note) priceEl.textContent = note.dataset.includedLabel || 'Included';
      if (note) note.hidden = false;
    } else if (!halo.checked && locked) {
      unlock();
      var prev = halo.dataset.prevCushion;
      if (prev) {
        var back = cushions.filter(function (i) { return i.value === prev; })[0];
        if (back) { uni.checked = false; back.checked = true; }
      }
    }

    /* Halo already ships the Universal-Fit Cushion, so a visitor who has that
       cushion selected pays only the difference when they add the halo: the
       cushion turns ephemeral the moment halo is checked, and the total moves
       by halo minus cushion. Show that difference on the card so the number
       the visitor reads is the number the total moves by. Display only — the
       cents on the input never change, so the math and the cart stay put. */
    var haloPrice = halo.parentElement.querySelector('[data-price]');
    if (haloPrice && !haloPrice.classList.contains('bsp-opt-price--included') && halo.dataset.soldout !== '1') {
      var credit = (!halo.checked && uni.checked) ? (parseInt(uni.dataset.cents, 10) || 0) : 0;
      haloPrice.textContent = credit
        ? '+ ' + fmt((parseInt(halo.dataset.cents, 10) || 0) - credit)
        : haloPrice.dataset.original;
    }
  }

  /* ---- add-on preview chip -------------------------------------------------
     The stage depicts exactly ONE facial interface: the halo's lightseal when
     halo is on, otherwise the custom fit if chosen, otherwise the universal.
     Now that both cushions can be bought together, a selection can be missing
     from the picture — so name it on the stage rather than let the visitor think
     it fell out of the order. General by construction: whichever cushion the
     render isn't showing is the one that gets the chip. */
  function syncAddonPreview() {
    var stack = document.getElementById('bsp-addon-stack');
    if (!stack) return;
    if (!IS_BEYOND) { stack.innerHTML = ''; return; }

    var halo = false, custom = null, universal = null;
    addonInputs().forEach(function (i) {
      if (isHaloOpt(i)) halo = true;
      else if (isUniOpt(i)) universal = i;
      else if (isCustomOpt(i)) custom = i;
    });

    var depicts = halo ? 'universal' : (custom ? 'custom' : 'universal');
    var pending = [];
    if (custom && depicts !== 'custom') pending.push(custom);
    else if (universal && depicts !== 'universal') pending.push(universal);

    /* the Rx inserts can never be depicted — no render in the matrix carries
       them — so they earn a chip once they're actually in the cart */
    var rx = document.getElementById('bsp-rx-opt');
    if (rx && rx.dataset.inCart === '1') pending.push(rx);

    /* key on titles so an unchanged stack isn't rebuilt (which would restart
       the fade every time the total recalculates) */
    var key = pending.map(function (i) { return i.dataset.title; }).join('|');
    if (stack.dataset.key === key) return;
    stack.dataset.key = key;
    stack.innerHTML = '';

    pending.forEach(function (i) {
      var card = i.closest('.bsp-opt');
      var img = card && card.querySelector('.bsp-opt-thumb img');
      var chip = document.createElement('div');
      chip.className = 'bsp-addon-preview';
      chip.innerHTML =
        '<span class="bsp-addon-plus" aria-hidden="true"></span>' +
        (img && img.getAttribute('src')
          /* eager: the same URL is already on screen in the option card, so it's
             a cache hit, and a lazy image inside a just-inserted chip can sit at
             zero height long enough to be seen */
          ? '<span class="bsp-addon-thumb"><img src="" alt="" loading="eager"></span>' : '') +
        '<span class="bsp-addon-txt">' +
          '<span class="bsp-addon-t"></span>' +
          '<span class="bsp-addon-note"></span>' +
        '</span>';
      var t = chip.querySelector('.bsp-addon-t');
      if (t) t.textContent = i.dataset.title || '';
      /* the violet second line: without it the chip reads as a caption for the
         headset in the picture, when it actually names something extra that the
         render can't show */
      var note = chip.querySelector('.bsp-addon-note');
      if (note) note.textContent = i === rx ? RX_EXTRA_NOTE : ADDON_EXTRA_NOTE;
      var thumb = chip.querySelector('.bsp-addon-thumb img');
      if (thumb) thumb.src = img.src;
      stack.appendChild(chip);
      /* next frame so the chip transitions in rather than appearing */
      requestAnimationFrame(function () { chip.classList.add('is-on'); });
    });
  }

  /* ---- LensAdvizor hand-off inside the configurator ------------------------
     The Rx card is a trigger, not a toggle: clicking forwards to the app's own
     button, and the checkbox is only ever a read-out of what the app has put in
     the cart. We never write to the cart for this line item. */
  var rxGroup = document.getElementById('bsp-rx-group');
  if (rxGroup) {
    /* The card is a plain external link — the browser handles the navigation,
       so there is no click handler and nothing here can interfere with
       LensAdvizor. All this does is READ the cart, so that when the visitor
       finishes on the other tab and comes back, the card confirms it. */
    var rxCard = document.getElementById('bsp-rx-opt');
    var rxProductId = parseInt(rxGroup.dataset.rxProduct, 10);
    var rxSub = rxGroup.querySelector('[data-rx-sub]');
    var rxChip = rxGroup.querySelector('[data-rx-chip]');
    var rxSubDefault = rxSub ? rxSub.textContent : '';
    var rxChipDefault = rxChip ? rxChip.textContent : '';
    var rxAddedSub = {{ shop.metaobjects.product_misc.live.rx_added | default: 'Added to your cart with your prescription. Open the page again to change it.' | json }};
    var rxAddedChip = {{ shop.metaobjects.product_misc.live.rx_added_chip | default: 'In your cart' | json }};

    function refreshRxState() {
      return fetch('/cart.js', { headers: { 'Accept': 'application/json' } })
        .then(function (r) { return r.json(); })
        .then(function (cart) {
          var inCart = (cart.items || []).some(function (li) { return li.product_id === rxProductId; });
          if ((rxCard.dataset.inCart === '1') === inCart) return;
          rxCard.dataset.inCart = inCart ? '1' : '';
          rxCard.classList.toggle('is-in-cart', inCart);
          if (rxSub) rxSub.textContent = inCart ? rxAddedSub : rxSubDefault;
          if (rxChip) {
            rxChip.textContent = inCart ? rxAddedChip : rxChipDefault;
            rxChip.classList.toggle('bsp-opt-chip--accent', inCart);
          }
          syncAddonPreview();
        })
        .catch(function () { /* cart unreachable: leave the card as-is */ });
    }

    /* coming back from the other tab is the moment the answer can change */
    window.addEventListener('focus', refreshRxState);
    document.addEventListener('visibilitychange', function () {
      if (!document.hidden) refreshRxState();
    });

    /* ...but the cart can also change without ever leaving this page: removing
       the inserts in the cart drawer used to leave the card claiming "in your
       cart" (and its chip on the stage) until a reload. eg-cart-drawer funnels
       every mutation it makes — remove, quantity, upsell — through
       window.loadEgCartDrawer(), so wrap that as a pure pass-through and re-read
       the cart once it settles. The app's own function still runs untouched and
       its return value is handed back unchanged; we only observe.
       The drawer section renders after this one, so install on DOMContentLoaded
       if the function isn't there yet. */
    function hookCartDrawer() {
      var orig = window.loadEgCartDrawer;
      if (typeof orig !== 'function' || orig.bsRxHooked) return true;
      var wrapped = function () {
        var out = orig.apply(this, arguments);
        Promise.resolve(out).then(refreshRxState, refreshRxState);
        return out;
      };
      wrapped.bsRxHooked = true;
      window.loadEgCartDrawer = wrapped;
      return true;
    }
    if (typeof window.loadEgCartDrawer !== 'function') {
      document.addEventListener('DOMContentLoaded', hookCartDrawer);
      window.addEventListener('load', hookCartDrawer);
    } else {
      hookCartDrawer();
    }

    refreshRxState();
  }

  /* ---- totals + ship estimate ---- */
  function update() {
    var m = modelInput();
    if (!m) return;
    syncHalo();   /* may move/lock selections; must run before the math */
    var total = parseInt(m.dataset.cents, 10) || 0;
    var candidates = [{ date: m.dataset.shipDate, msg: m.dataset.shipMsg }];
    addonInputs().forEach(function (i) {
      if (!i.dataset.ephemeral) total += parseInt(i.dataset.cents, 10) || 0;
      if (!i.dataset.shipsSeparately) candidates.push({ date: i.dataset.shipDate, msg: i.dataset.shipMsg });
    });
    totalEl.textContent = fmt(total);
    var latest = candidates.filter(function (c) { return c.date; })
      .sort(function (a, b) { return a.date < b.date ? 1 : -1; })[0];
    /* a matching ETA override replaces the fold outright (ETA-SYSTEM.md) */
    activeOverride = matchOverride();
    if (activeOverride) {
      shipEl.textContent = activeOverride.msg;
    } else {
      shipEl.textContent = (latest && latest.msg) ? latest.msg : FALLBACK_SHIP;
    }
    if (caption) caption.textContent = m.dataset.title || '';
    /* headline follows the model: "Beyond — 2" becomes "Beyond — 2e" and the
       rule swaps to the landing's b2e gradient. Both 2e and the 2e VRChat
       Edition are eyetracking models, so match the "2e" token rather than an
       exact title. aria-hidden tracks it so the h1 doesn't read an "e" that
       isn't part of the current selection. */
    var titleEl = document.getElementById('bsp-title');
    if (titleEl) {
      var isE = isEyeModel(m);
      titleEl.classList.toggle('bsp-title--e', isE);
      var eEl = titleEl.querySelector('[data-title-e]');
      if (eEl) eEl.setAttribute('aria-hidden', isE ? 'false' : 'true');
    }
    /* stage backdrop tint follows the shell; the VRC mark only on that edition */
    var plate = document.getElementById('bsp-stage-plate');
    if (plate) {
      plate.setAttribute('data-shell', currentShell());
      plate.classList.toggle('bsp-stage--vrchat', isVrc(m));
    }
    /* swatch name line */
    var sw = groupsHost.querySelector('input[name*="shell"]:checked');
    var nameLine = groupsHost.querySelector('[data-swatch-name]');
    if (sw && nameLine) {
      nameLine.querySelector('[data-swatch-title]').textContent = sw.dataset.title;
      var c = parseInt(sw.dataset.cents, 10) || 0;
      /* the right-hand slot is always filled so the row never half-collapses,
         but a no-charge shell says "Included" rather than "+ 0.00" — the same
         wording the shipped configurator used */
      nameLine.querySelector('[data-swatch-price]').textContent =
        c ? '+ ' + fmt(c) : (nameLine.dataset.includedLabel || 'Included');
      /* marker + note only when the selected shell carries fine print, so the
         limited-edition line follows the colorway instead of sitting there
         permanently under the row */
      var swFine = (sw.dataset.fine || '').trim();
      var swMark = nameLine.querySelector('[data-swatch-mark]');
      var swNote = groupsHost.querySelector('[data-swatch-fine]');
      if (swMark) swMark.hidden = !swFine;
      if (swNote) {
        swNote.querySelector('[data-swatch-fine-text]').textContent = swFine;
        swNote.hidden = !swFine;
      }
    }
    swapRender();
    syncAddonPreview();
  }

  /* ---- add-on choices survive a model switch ------------------------------
     mountGroups() re-stamps the groups from the new model's <template>, which
     used to wipe every selection: picking accessories and then switching to 2e
     for eyetracking reset the lot.

     Options are keyed by input name + option title. Both are stable across
     templates (name is bsp-addon-{group key}, title is the add-on's own title);
     the element ids are NOT — they embed the model's variant id.

     Only groups the visitor actually touched are carried over. A group they
     never opened has nothing to preserve, so the new template's own default
     stands — which is also what keeps the 2e Carbon Black rule below working. */
  function optKey(i) { return i.name + '|' + (i.dataset.sku || i.dataset.title || ''); }

  function snapshot() {
    var s = {};
    [].slice.call(groupsHost.querySelectorAll('input')).forEach(function (i) {
      s[optKey(i)] = i.checked;
    });
    return s;
  }

  var touched = {};   /* input names the visitor has changed */

  function restore(prev, defaults) {
    var inputs = [].slice.call(groupsHost.querySelectorAll('input'));
    inputs.forEach(function (i) {
      if (!touched[i.name]) return;   /* never chosen — leave the template default */
      /* disabled options are template-owned (the ephemeral Soft Strap ships
         checked-and-locked), so never write a stale state onto them */
      if (i.disabled) return;
      var k = optKey(i);
      if (k in prev) i.checked = prev[k];
    });
    /* a radio group must end with exactly one checked. If the visitor's pick
       doesn't exist for this model, fall back to what the template shipped. */
    var seen = {};
    inputs.forEach(function (i) {
      if (i.type !== 'radio' || seen[i.name] || !touched[i.name]) return;
      seen[i.name] = true;
      var grp = inputs.filter(function (x) { return x.name === i.name; });
      if (!grp.some(function (x) { return x.checked; })) {
        grp.forEach(function (x) { x.checked = !!defaults[optKey(x)]; });
      }
    });
  }

  function mountGroups() {
    var m = modelInput();
    if (!m) return;

    /* the model's info plate (store.info_box — the VRChat Edition box) rides
       the same swap as the add-on groups: every variant's plate is in the DOM,
       the selected model's is the one showing */
    [].slice.call(root.querySelectorAll('.bsp-note[data-info-for]')).forEach(function (el) {
      el.hidden = el.getAttribute('data-info-for') !== m.value;
    });

    var prev = snapshot();                       /* capture before the wipe */
    var tpl = document.getElementById('bsp-groups-' + m.value);
    groupsHost.innerHTML = '';
    if (tpl) groupsHost.appendChild(tpl.content.cloneNode(true));

    /* presentation default: 2e opens on Carbon Black so the model switch
       visibly changes the render (black is a no-charge shell) */
    var shells = [].slice.call(groupsHost.querySelectorAll('input[name*="shell"]'));
    /* plain 2e only — the VRChat Edition ships its own purple look */
    if ((skuOf(m) === 'BS2E' || /2e$/.test(titleOf(m))) && shells.length) {
      var black = shells.filter(function (i) { return shellToken(i) === 'black'; })[0];
      if (black) {
        shells.forEach(function (i) { i.checked = false; });
        black.checked = true;
      }
    }

    restore(prev, snapshot());
    update();
  }

  root.addEventListener('change', function (e) {
    if (e.target.name === 'bsp-model') { mountGroups(); return; }
    /* A facial interface is physically required, and the cushion group is now
       multi-select, so unchecking the last one would ship a headset with
       nothing against your face. Silently refuse, the same way the included
       Soft Strap refuses to be unchecked. */
    if (e.target.name.indexOf('cushion') !== -1 && e.target.type === 'checkbox' && !e.target.checked) {
      var anyLeft = [].slice.call(groupsHost.querySelectorAll('input[name*="cushion"]'))
        .some(function (i) { return i.checked; });
      if (!anyLeft) { e.target.checked = true; return; }
    }
    touched[e.target.name] = true;
    update();
  });

  /* scroll hint fades once the visitor starts moving */
  var hint = document.getElementById('bsp-scroll-hint');
  if (hint) {
    window.addEventListener('scroll', function () {
      hint.classList.toggle('is-gone', window.scrollY > 120);
    }, { passive: true });
  }

  /* ---- mobile: the config panel scrolls into the plate ----------------------
     Phones get one column, so the full-height plate and the sticky order footer
     between them leave almost no room to read the options. Rather than swap the
     plate between two sizes, its height is recomputed every frame as the gap
     between the chrome and the top of the config panel: the panel rising eats
     the plate 1:1 until the plate hits its floor and holds there.

     Measuring the GAP rather than counting scrolled pixels is the point. It
     means the plate's bottom edge and the panel's top edge are the same line by
     construction, so no copy can ever slide underneath the plate on the way
     down — which is what a fixed 44vh plate did, clipping the product title in
     half against its own bottom edge. It also self-calibrates: nothing here
     restates the 44vh from the stylesheet, so the two cannot drift apart.

     The panel's document position is stable while this runs, because the mobile
     grid pins the stage's ROW (see .bsp-split in bs-product.css) — the plate
     resizes inside a row that does not. Re-measured on resize, since the row is
     viewport-relative and mobile browsers change viewport height when the URL
     bar collapses.

     Desktop never runs this — the split has room for both. */
  var plate = root.querySelector('.bsp-stage');
  var panel = root.querySelector('.bsp-col');
  /* Beyond only — see bsp--beyond on the section. Everything below writes
     --bsp-h on the section root, so leaving it off means the plate keeps the
     opening height its stylesheet gives it and simply scrolls. */
  if (IS_BEYOND && plate && panel) {
    var foldMq = window.matchMedia('(max-width: 991.98px)');
    /* the strip: the render, plus the 56px band underneath that holds the
       "your configuration" label and the add-on line (see .bsp-stage-inner) */
    var FLOOR = 144;
    var sectionTop = 0, panelTop = 0, heroH = 0, stickTop = 0, queued = false;

    function measure() {
      var y = window.pageYOffset;
      sectionTop = root.getBoundingClientRect().top + y;
      panelTop = panel.getBoundingClientRect().top + y;
      /* the pinned grid row's height — the plate's opening size, straight from
         the stylesheet rather than duplicated here */
      heroH = panelTop - sectionTop;
      stickTop = parseFloat(getComputedStyle(plate).top) || 0;
    }

    function clear() {
      root.style.removeProperty('--bsp-h');
      root.style.removeProperty('--bsp-p');
      root.classList.remove('bsp--folded');
    }

    function apply() {
      queued = false;
      if (!foldMq.matches) { clear(); return; }
      if (heroH <= 0) return;
      /* room left between the chrome and the panel's top edge */
      var gap = panelTop - window.pageYOffset - stickTop;
      var h = Math.max(FLOOR, Math.min(heroH, gap));
      var travel = heroH - FLOOR;
      var p = travel > 0 ? (heroH - h) / travel : 0;
      root.style.setProperty('--bsp-h', h.toFixed(1) + 'px');
      root.style.setProperty('--bsp-p', p.toFixed(3));
      /* only for the things that must stop being tappable once faded out */
      root.classList.toggle('bsp--folded', p > 0.55);
    }

    /* rAF-coalesced: scroll fires far more often than the screen refreshes, and
       writing a length on every event would lay the plate out several times per
       frame for one visible result */
    function onScroll() {
      if (!queued) { queued = true; requestAnimationFrame(apply); }
    }
    function onResize() { measure(); apply(); }

    measure();
    apply();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onResize);
    /* late webfonts and lazy images shift the panel's position under us */
    window.addEventListener('load', onResize);
    foldMq.addEventListener ? foldMq.addEventListener('change', onResize)
                            : foldMq.addListener(onResize);
  }

  /* ---- mobile: the order bar outlives the configurator ----------------------
     The footer sticks to the bottom of the config column, so on a phone it
     leaves at ~4,500px of a ~23,000px page — and the header's Order now is
     hidden below 992px, so everything past the configurator had no buy path at
     all. Past it, the same footer goes fixed and compacts (see .bsp--buybar).

     --bsp-footer-h is what stops the page jumping: going fixed takes the
     footer's height out of the column, so the column is given it back as
     padding. Measured rather than assumed, since the shipping line wraps at some
     widths and on some ship messages. Re-measured on resize for the same reason.

     Runs for every product, not just Beyond — this is about how long the page
     is, and the shared template gives an accessory the same story, reviews and
     Q&A bands underneath it. */
  var footerEl = root.querySelector('.bsp-footer');
  if (footerEl && orderBtn) {
    var barMq = window.matchMedia('(max-width: 991.98px)');
    var barQueued = false;

    function measureFooter() {
      /* height in FLOW — read it while the bar is off, or it returns the
         compact one and the column reserves too little */
      var was = root.classList.contains('bsp--buybar');
      if (was) setBar(false);
      root.style.setProperty('--bsp-footer-h', footerEl.offsetHeight + 'px');
      if (was) setBar(true);
    }

    /* The section wrapper, not just #bsp: every section carries z-index 1 from
       bs-chrome.css and is therefore its own stacking context, so a fixed bar
       inside this one cannot paint over the reviews section below it however
       high its own z-index goes. The wrapper has to be raised too — a class
       rather than :has() so it works wherever :has() does not. */
    var sectionEl = root.closest('.shopify-section');

    function setBar(on) {
      root.classList.toggle('bsp--buybar', on);
      if (sectionEl) sectionEl.classList.toggle('bsp-has-buybar', on);
    }

    /* The site footer is z-index 10 in bs-chrome.css, above both this bar's
       section (2) and the nav's (3), and raising past it would put the bar over
       the chrome again. It cannot be outranked, so the bar retires into it
       instead: once the footer reaches the viewport the product page is over and
       a half-buried order bar is worse than none. */
    /* Resolved lazily, not once up front: this script is inline in the section,
       so it runs while the parser is still inside <main> and the footer further
       down the document does not exist yet. Querying it here returned null, the
       guard below was skipped every time, and the bar stayed up underneath the
       footer exactly as before. Cached on the first hit. */
    var siteFooter = null;
    function getSiteFooter() {
      if (!siteFooter) siteFooter = document.querySelector('.bs-footer');
      return siteFooter;
    }

    function applyBar() {
      barQueued = false;
      if (!barMq.matches) { setBar(false); return; }
      /* the configurator's own bottom edge has cleared the viewport, which is
         exactly when the sticky footer would otherwise scroll away */
      var show = root.getBoundingClientRect().bottom < window.innerHeight;
      var sf = getSiteFooter();
      if (show && sf && sf.getBoundingClientRect().top < window.innerHeight) show = false;
      setBar(show);
    }

    function onBarScroll() {
      if (!barQueued) { barQueued = true; requestAnimationFrame(applyBar); }
    }
    function onBarResize() { measureFooter(); applyBar(); }

    measureFooter();
    applyBar();
    window.addEventListener('scroll', onBarScroll, { passive: true });
    window.addEventListener('resize', onBarResize);
    window.addEventListener('load', onBarResize);
    barMq.addEventListener ? barMq.addEventListener('change', onBarResize)
                           : barMq.addListener(onBarResize);
  }

  /* ---- order button vs. the real cart -------------------------------------
     The "Added" lock is only valid while the counted products are actually in
     the cart. The drawer lets the visitor remove them without a page load, so
     the state must be re-derived from /cart.js — both after our own add and
     whenever the drawer closes (Bootstrap's hidden.bs.offcanvas bubbles to the
     document, which also sidesteps any section-render ordering). Without this,
     deleting the bundle in the drawer left a disabled "Added" button that only
     a refresh could clear. */
  function syncOrderBtn() {
    if (!orderBtn || QTY_LIMIT <= 0) return;
    /* nothing the cart says can make a sold-out product buyable again, and this
       runs on every drawer close — without the guard, emptying the drawer would
       hand back an enabled "Sold out" button */
    if (orderBtn.hasAttribute('data-sold-out')) return;
    fetch('/cart.js', { headers: { 'Accept': 'application/json' } })
      .then(function (res) { return res.json(); })
      .then(function (cart) {
        var qty = 0;
        (cart.items || []).forEach(function (li) {
          if (COUNTED_PRODUCT_IDS.indexOf(li.product_id) !== -1) qty += li.quantity;
        });
        if (qty >= QTY_LIMIT) {
          orderBtn.setAttribute('data-limit-reached', '1');
          orderBtn.disabled = true;
          orderBtn.textContent = ADDED_LABEL;
        } else {
          orderBtn.removeAttribute('data-limit-reached');
          orderBtn.disabled = false;
          orderBtn.textContent = ORDER_LABEL;
        }
      })
      .catch(function () {
        /* cart unreadable: don't guess a new state, but never leave the button
           dead from the add flow's pre-fetch disable */
        if (!orderBtn.hasAttribute('data-limit-reached')) orderBtn.disabled = false;
      });
  }

  document.addEventListener('hidden.bs.offcanvas', function (e) {
    if (e.target && e.target.id === 'cart-drawer') syncOrderBtn();
  });

  /* ---- order: model + every checked non-ephemeral addon as line items ---- */
  var NEEDS_RX = {{ needs_rx | json }};

  orderBtn && orderBtn.addEventListener('click', function () {
    if (orderBtn.disabled) return;

    /* Prescription products: hand off to LensAdvizor instead of adding to cart.
       The app's modal collects the Rx and runs its own /cart/add.js with the
       properties attached, so adding the variant here would produce a lens order
       with no prescription against it. We only forward the click — the app's
       hidden <product-form> and #la-select-lenses-btn are untouched. */
    if (NEEDS_RX) {
      var la = document.getElementById('la-select-lenses-btn');
      if (la) { la.click(); return; }
      /* app missing (blocked/not loaded): refuse rather than sell an
         unprescribed lens */
      errEl.textContent = {{ shop.metaobjects.product_misc.live.rx_unavailable | default: 'Prescription selection is unavailable right now. Please refresh and try again.' | json }};
      errEl.hidden = false;
      return;
    }

    var m = modelInput();
    if (!m) return;
    /* ---- Bundle grouping + line-item properties: the live cart contract -----
       (Sep 2026 ETA overhaul — full design in ETA-SYSTEM.md.)
       Consumers of every key below:
         eg-cart-drawer   parent/child grouping (see its is_child block):
                            parent: _addon_variants       = addon variant ids
                                    joined by "-" (its remove button splits this
                                    to clear the whole bundle at once)
                            child:  _addon_parent_variant = the parent's variant
                                    id, as a string
                          — its "estimated delivery" line resolves ETAs LIVE
                          from variants/overrides; the frozen props are its
                          fallback for pre-overhaul carts. _eph_variants feeds
                          its override matching (ephemeral picks never become
                          line items). Ships-separately detection is the hidden
                          _ships_separately prop (legacy: the English key).
         checkout/order   the visible keys — LOCALIZED now (PROP_*_KEY), they
                          exist to aid the buyer; machine logic never matches
                          on them
         order export     the hidden keys (_ship_*, _est_*, _eta_parts, and the
                          per-group _shell_variant / _cushion_variant /
                          _strap_variant title arrays) ride into the order.
                          _ship_msg stays visitor-locale natural language by
                          choice (support staff readability); _eta_parts is the
                          structured form (part handles)
       A line item with no _addon_variants renders as a normal row. Dates are
       YYYY-MM-DD (store.ship_date's format), so plain string comparison orders
       them; blank values are skipped rather than sent as literal "null". */
    var modelId = parseInt(m.value, 10);
    var modelShipDate = m.dataset.shipDate || '';
    var modelShipMsg = m.dataset.shipMsg || '';
    var modelEtaParts = m.dataset.etaParts || '';

    /* the bundle ships as one box, so its window is the latest date among the
       model and every checked same-box addon — ephemeral ones included (the
       Soft Strap still has to be in that box), the same candidates update()
       feeds the estimate above the order button. A matching ETA override
       replaces the fold outright. */
    var effDate = modelShipDate, effMsg = modelShipMsg, effParts = modelEtaParts;
    var addonIds = [];
    var ephIds = [];
    var groupTitles = {};   /* group prop_key -> selected titles, ephemeral included */
    addonInputs().forEach(function (i) {
      if (!i.dataset.shipsSeparately && i.dataset.shipDate && i.dataset.shipDate > effDate) {
        effDate = i.dataset.shipDate;
        effMsg = i.dataset.shipMsg || '';
        effParts = i.dataset.etaParts || '';
      }
      if (i.dataset.ephemeral) ephIds.push(parseInt(i.value, 10));
      else addonIds.push(parseInt(i.value, 10));
      var g = i.closest('.bsp-group');
      if (g && g.dataset.groupKey) {
        (groupTitles[g.dataset.groupKey] = groupTitles[g.dataset.groupKey] || []).push(i.dataset.title || '');
      }
    });
    var ov = matchOverride();
    if (ov) { effDate = ov.date; effMsg = ov.msg; effParts = ov.parts; }

    var parentProps = {};
    if (modelShipDate) parentProps['_ship_date'] = modelShipDate;
    if (modelShipMsg) parentProps['_ship_msg'] = modelShipMsg;
    if (effMsg) {
      /* written whenever wording exists — the itemized ETA read on a
         multi-item cart; in-stock wording ("1-2 business days") included */
      parentProps[PROP_EST_KEY] = effMsg;
      if (effDate !== modelShipDate || effMsg !== modelShipMsg) {
        /* an addon or an override moved the bundle off the model's own window */
        if (effDate) parentProps['_est_date'] = effDate;
        parentProps['_est_msg'] = effMsg;
      }
    }
    if (effParts) parentProps['_eta_parts'] = effParts;
    if (addonIds.length) {
      parentProps['_addon_variants'] = addonIds.join('-');
      Object.keys(groupTitles).forEach(function (k) { parentProps[k] = groupTitles[k]; });
    }
    if (ephIds.length) parentProps['_eph_variants'] = ephIds.join('-');

    var parent = { id: modelId, quantity: 1 };
    if (Object.keys(parentProps).length) parent.properties = parentProps;
    var items = [parent];
    addonInputs().forEach(function (i) {
      if (i.dataset.ephemeral) return;   /* selectable but never a line item */
      var aDate = i.dataset.shipDate || '';
      var aMsg = i.dataset.shipMsg || '';
      var props = { '_addon_parent_variant': String(modelId) };
      if (aDate) props['_ship_date'] = aDate;
      if (aMsg) props['_ship_msg'] = aMsg;
      if (i.dataset.etaParts) props['_eta_parts'] = i.dataset.etaParts;
      props[PROP_ADDON_FOR_KEY] = PRODUCT_TITLE;
      if (i.dataset.shipsSeparately) {
        props['_ships_separately'] = '1';
        if (aMsg) props[PROP_SHIPS_SEP_KEY] = ETA_PREFIX + aMsg;
      } else if (effMsg && (effDate > aDate || (ov && effMsg !== aMsg))) {
        /* rides in the bundle's box, so it inherits the bundle's window */
        if (effDate) props['_est_date'] = effDate;
        props['_est_msg'] = effMsg;
      }
      items.push({ id: parseInt(i.value, 10), quantity: 1, properties: props });
    });
    orderBtn.disabled = true;
    errEl.hidden = true;
    fetch('/cart/add.js', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ items: items })
    }).then(function (r) {
      if (!r.ok) return r.json().then(function (j) { throw new Error(j.description || j.message || 'Could not add to cart'); });

      /* Open the cart drawer instead of navigating. loadEgCartDrawer is the
         drawer's own ajax hook ("Load cart drawer on ajax add/update"): it shows
         the offcanvas and re-reads the page to refresh both the drawer contents
         and the cart bubble. Sending the visitor to /cart threw them out of the
         build they were configuring, and the back button returns to a page whose
         selections have reset.
         Falls back to /cart if the drawer app isn't on the page. */
      if (typeof window.loadEgCartDrawer === 'function') {
        window.loadEgCartDrawer();
        /* The qty rule is rendered server-side and the page no longer reloads,
           so re-evaluate it against the real cart — counting the same products
           Liquid counts (this product plus cart.qty_count_products), not simply
           "one add happened". */
        if (QTY_LIMIT > 0) {
          syncOrderBtn();
        } else {
          orderBtn.disabled = false;
        }
        return;
      }
      window.location.href = CART_URL;
    }).catch(function (err) {
      orderBtn.disabled = false;
      errEl.textContent = err.message;
      errEl.hidden = false;
    });
  });

  /* ---- Deep link: a whole configuration in the URL --------------------------
     The contract the landing configurator's "order" hand-off (and any campaign
     link) writes against:

       ?variant=<id>            exact model by Shopify variant id (old form, kept)
       ?model=2|2e|vrchat       model by token — what the site repo should use,
                                since it doesn't know Shopify's variant ids
       ?shell=clear|black|orange|purple
       ?cushion=custom|universal|both
       ?strap=soft|audio|halo|audio,halo

     Every value is matched against the option inputs' own data-title, so the
     link keeps working when admin renames nothing structural — and any token
     that matches nothing is simply ignored, so a stale or mistyped link can
     never do worse than today's defaults. `variant` wins over `model` if both
     are present (it is the more specific claim).

     The model runs BEFORE the first mountGroups() so the add-on groups stamp
     from the right template straight away, with no flash of the base model's
     options. The add-ons run AFTER it, because their inputs only exist once the
     model's <template> is stamped into #bsp-groups. */
  (function preselectModelFromUrl() {
    /* exact id — digit-only by construction, so it is safe in a selector */
    var m = /[?&]variant=(\d+)/.exec(location.search);
    if (m) {
      var target = root.querySelector('input[name="bsp-model"][value="' + m[1] + '"]');
      /* a link to a variant that has since sold out falls through to the
         page's own default rather than selecting something unbuyable */
      if (target && !target.disabled) { target.checked = true; return; }
    }
    /* token form. Ordered most-specific first: every VRChat title also
       contains "2e", and every "2e" title also contains "2". */
    var t = /[?&]model=(vrchat|2e|2)(?:&|$)/i.exec(location.search);
    if (!t) return;
    var want = t[1].toLowerCase();
    var models = [].slice.call(root.querySelectorAll('input[name="bsp-model"]'));
    var hit = models.filter(function (i) {
      if (want === 'vrchat') return isVrc(i);
      if (want === '2e') return isEyeModel(i) && !isVrc(i);
      return !isEyeModel(i) && !isVrc(i);
    })[0];
    if (hit && !hit.disabled) hit.checked = true;
  })();

  mountGroups();

  (function preselectAddonsFromUrl() {
    if (!IS_BEYOND) return;
    var q = {};
    ['shell', 'cushion', 'strap'].forEach(function (k) {
      var m = new RegExp('[?&]' + k + '=([a-z,+]+)(?:&|$)', 'i').exec(location.search);
      if (m) q[k] = m[1].toLowerCase();
    });
    if (!q.shell && !q.cushion && !q.strap) return;

    /* Marking the group touched is what makes a deep-linked choice behave like
       a clicked one: restore() only carries touched groups across a model
       switch, so without it a visitor who landed on ?shell=black and then
       tapped 2e would silently lose the black shell to 2e's own default. */
    function set(input, on) {
      if (!input || input.disabled) return;
      input.checked = on;   /* a radio unchecks its siblings on its own */
      touched[input.name] = true;
    }

    if (q.shell) {
      /* through shellToken, not a bare contains — "clear" must never land on
         Nuclear Orange, whose name contains it twice over */
      var shells = [].slice.call(groupsHost.querySelectorAll('input[name*="shell"]'));
      var shell = shells.filter(function (i) { return shellToken(i) === q.shell; })[0];
      if (shell) set(shell, true);
    }

    if (q.cushion) {
      var wantCustom = q.cushion === 'custom' || q.cushion === 'both';
      var wantUniversal = q.cushion === 'universal' || q.cushion === 'both';
      var cushions = [].slice.call(groupsHost.querySelectorAll('input[name*="cushion"]'));
      if (wantCustom || wantUniversal) {
        cushions.forEach(function (i) {
          if (isCustomOpt(i)) set(i, wantCustom);
          else if (isUniOpt(i)) set(i, wantUniversal);
        });
        /* same guard as the change handler: a headset never ships with nothing
           against your face, whatever the URL said */
        if (!cushions.some(function (i) { return i.checked; })) {
          var back = cushions.filter(isCustomOpt)[0] || cushions[0];
          if (back) back.checked = true;
        }
      }
    }

    if (q.strap) {
      /* "soft" is a valid value and means exactly the default: the Soft Strap
         is ephemeral and always included, so there is nothing to check — only
         audio/halo are real toggles. Comma-separated because "+" arrives as a
         space through URL decoding. */
      var wantAudio = /(^|,)audio($|,)/.test(q.strap);
      var wantHalo = /(^|,)halo($|,)/.test(q.strap);
      [].slice.call(groupsHost.querySelectorAll('input[name*="strap"]')).forEach(function (i) {
        if (isAudioOpt(i)) set(i, wantAudio);
        else if (isHaloOpt(i)) set(i, wantHalo);
      });
    }

    /* one settle pass: totals, ship estimate, the render, and syncHalo's
       halo<->universal interlock — the same call a click ends in, so a
       deep-linked page is indistinguishable from a hand-built one */
    update();
  })();
})();
</script>

<script>
  /* Rating -> reviews, eased.
     Target: #bs-reviews is the [Reviews heading] section, present on
     product.shop-bs2 but NOT on the no-buy template, where the Yotpo widget
     renders with no heading of its own — so fall back to the widget element.
     Both lookups run on click, not at parse time: this section renders BEFORE
     the reviews heading, so at parse time #bs-reviews doesn't exist yet either
     way, and the Yotpo widget mounts later still.
     Duration is near-fixed rather than proportional to distance. The reviews
     band sits ~10,000px down this page, and a constant-speed smooth scroll over
     that turns into a ten-second ride the visitor can't do anything with. */
  (function () {
    var link = document.querySelector('[data-bsp-rating-link]');
    if (!link) return;

    var OFFSET = 120;   /* matches .bsf-anchor's scroll-margin-top */
    var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
    var CANCEL = ['wheel', 'touchstart', 'keydown'];

    function targetTop() {
      var el = document.getElementById('bs-reviews') ||
               document.querySelector('.yotpo-reviews-main-widget, .yotpo-widget-instance, [class*="yotpo-reviews"]');
      return el ? el.getBoundingClientRect().top + window.pageYOffset - OFFSET : null;
    }

    link.addEventListener('click', function (e) {
      var to = targetTop();
      if (to === null) return;            /* nothing to scroll to: leave the anchor alone */
      e.preventDefault();

      function land() { history.replaceState(null, '', '#bs-reviews'); }

      if (reduce.matches) { window.scrollTo(0, to); land(); return; }

      var from = window.pageYOffset;
      var dist = to - from;
      if (Math.abs(dist) < 2) { land(); return; }

      var dur = Math.min(1600, Math.max(700, Math.abs(dist) * 0.12));
      var t0 = null, cancelled = false;

      function stop() { cancelled = true; }
      function cleanup() { CANCEL.forEach(function (ev) { window.removeEventListener(ev, stop); }); }
      /* any real input from the visitor wins — never trap them mid-flight.
         Programmatic scrollTo fires `scroll`, not these, so it can't self-cancel. */
      CANCEL.forEach(function (ev) { window.addEventListener(ev, stop, { passive: true }); });

      function step(now) {
        if (cancelled) return cleanup();
        if (t0 === null) t0 = now;
        var p = Math.min(1, (now - t0) / dur);
        /* easeInOutQuart: leaves and settles, no bounce, no elastic */
        var k = p < 0.5 ? 8 * p * p * p * p : 1 - Math.pow(-2 * p + 2, 4) / 2;
        window.scrollTo(0, from + dist * k);
        if (p < 1) { requestAnimationFrame(step); return; }
        cleanup();
        land();
      }
      requestAnimationFrame(step);
    });
  })();
</script>

{% schema %}
{
  "name": "BS Product Configurator",
  "settings": []
}
{% endschema %}
