Skip to content

Product page support

Diagnostic and troubleshooting reference for the product detail page (PDP) — gallery + zoom, variant picker, sticky add-to-cart, product recommendations, back-in-stock signup, selling-plan subscriptions, combined-listing parent-child, breadcrumb JSON-LD, and per-preset template ordering.

FAQ

Hover-zoom on desktop requires a viewport width of ≥ 1024 px and a pointing device that supports hover (mouse, trackpad). If you are on a touch-first device (iPad Pro, Surface Go with keyboard-only), the theme detects pointer: coarse and swaps hover-zoom for click-zoom (full-screen viewer). This is intentional — hover-zoom on touch triggers during scrolling and is bad UX.

To verify the expected mode, run the diagnostic snippet below and inspect gallery.zoomMode. Expected values:

  • hover — ≥ 1024 px desktop with hover-capable pointer
  • click — < 1024 px or touch-first device
  • disabledprefers-reduced-motion: reduce is set by OS, or a merchant-added CSS override disabled zoom

If your zoomMode is hover but zoom is not firing, confirm product-gallery custom element is present and registered: document.querySelector('product-gallery').constructor.name should return ProductGallery.

Pinch-to-zoom does not work inside the full-screen viewer on mobile.

Pinch-to-zoom is implemented as two-finger gesture detection inside the <gallery-viewer> overlay. Requirements:

  • Touch device with pointer: coarse (phones, tablets)
  • The viewer is open (tap an image to open it)
  • No competing gesture listeners — some PWA shell wrappers or custom theme additions bind their own touchstart handlers that preventDefault early, swallowing the gesture

Verify via the diagnostic snippet — gallery.viewer.present === true should be true when the viewer is open. If pinch still fails, try disabling browser extensions and test in an incognito window.

The deep-link handler reads location.hash on page load, parses media-<n> where n is 1-based, and calls gallery.selectMedia(n). If it's not working:

  1. Confirm the hash is exactly #media-<n> (no typo, correct casing).
  2. Confirm the product has at least n media items — if you deep-link to #media-5 but the product has 3 images, the handler clamps to the last image and logs a warning to the console.
  3. Confirm the page loaded the product template — deep-links do not work on non-product templates.

The variant picker does not show the combined-listing chip.

The combined-listing chip only renders when:

  • The current product has a combined_listing.parent relationship set (Shopify Admin → Products → Organization → Combined listings)
  • The combined_listing block is added to the product section (Theme editor → Product section → Add block → Combined listing)
  • At least one sibling product exists in the combined-listing group (the chip is hidden if the current product is the only child)

Run the diagnostic snippet — inspect variantPicker.combinedListing. Expected:

json
{
  "parentHandle": "commercial-fit-out",
  "siblingCount": 2,
  "chipVisible": true
}

If parentHandle is null, the product is not part of a combined listing. Configure via Shopify Admin or run the demo-data script: node scripts/populate-combined-listing-demo.mjs --preset <preset>.

Sticky add-to-cart bar does not appear on scroll.

The sticky bar uses an IntersectionObserver on the in-page buy-button. When the buy-button scrolls out of viewport, the sticky bar is shown; when it scrolls back in, the sticky bar hides (avoiding double-render).

If the sticky bar never shows:

  1. Confirm the sticky-add-to-cart custom element is mounted: document.querySelector('sticky-add-to-cart') should return the element (not null).
  2. Confirm the page is long enough to scroll — on very short product descriptions (no additional blocks below buy-button), the sticky bar never triggers because the buy-button stays visible.
  3. Confirm no custom CSS has overflow: hidden on a parent element that would break IntersectionObserver viewport detection.
  4. Confirm sticky-add-to-cart.css is loaded — check the Network tab; the asset should return 200.

Some merchants hide the sticky bar intentionally for minimalist PDPs. Custom CSS override:

css
sticky-add-to-cart {
  display: none !important;
}

The back-in-stock form is not submitting to Klaviyo / my CRM.

Submissions only forward to a merchant webhook when back_in_stock_webhook_url is populated on the block. Verify:

  1. Theme editor → Product section → Back-in-stock block → Back-in-stock webhook URL — confirm the URL is pasted.
  2. The URL is reachable — try a curl -X POST <your-url> with a sample payload. If you get 401/403/404, the CRM endpoint is rejecting — check their docs for the expected headers and format.
  3. Open DevTools → Network tab on a sold-out PDP, submit the form, and confirm a POST to your webhook URL appears with 200 response. If you see POST /contact instead, the webhook URL field is empty or malformed.

The theme posts application/x-www-form-urlencoded with these fields:

FieldDescription
emailSubmitter email address
product_idShopify product numeric ID
variant_idShopify variant numeric ID (the specific sold-out variant)
handleProduct handle
localeCurrent storefront locale code
restock_dateISO date from the custom.restock_date metafield if populated; empty otherwise

If your CRM expects JSON, run a proxy at Cloudflare Workers / Vercel that transforms the form POST to JSON and forwards. The theme intentionally submits form-encoded for compatibility with Shopify's native contact form handler fallback.

The save badge is not showing on my subscription plan.

The badge renders only when:

  • show_save_badge is enabled on the selling-plan block (default: on)
  • The selling plan has a non-zero discount configured in Shopify Admin → Subscriptions

If your plan is priced equal to one-time purchase (0% save), the badge intentionally does not render — a "Save 0%" badge would be misleading UX.

Verify via diagnostic snippet — inspect sellingPlan.plans[].savePercent. Expected: non-zero for discounted plans, null or 0 for non-discounted.

Recommendations strip is showing sold-out products.

The exclude_sold_out setting filters client-side. Verify:

  1. Theme editor → Product recommendations section → Hide sold-out products — confirm enabled.
  2. Run the diagnostic snippet — inspect recommendations.cards[].available. All cards should be true when exclude_sold_out is on.
  3. If the setting is on but sold-out products still appear, push the latest theme — the client-side filter logic lives in assets/product-recommendations.js which must be at the current commit.

Check the deployed theme asset version via: curl -s <your-store>/products/<any-product>.js | head — confirm the HTML references product-recommendations.js with a current timestamp parameter.

The OOS cross-sell strip does not render on a sold-out PDP.

The cross-sell replaces the buy-button area only when every variant is sold out. If one variant is still available, the buy-button stays and the cross-sell is not triggered (the user can still add-to-cart via the available variant).

If every variant is sold out but the strip still does not render:

  1. Confirm show_oos_recommendations is enabled on the product section (default: on).
  2. Confirm product-recommendations.js is loaded — check the Network tab.
  3. Confirm the recommendations API endpoint returns at least one related product: curl -s <your-store>/recommendations/products.json?product_id=<id>&limit=4 should return a non-empty products array.

If the recommendations API returns empty (Shopify platform requires the store to have at least ~30 days of traffic data for the algorithm to train), the strip does not render and a fallback back-in-stock form is shown. This is intentional — empty cross-sell cards are worse than a signup form.

Restock-date banner shows but the countdown is wrong.

The day-count is computed server-side in Liquid from product.metafields.custom.restock_date vs 'now' | date: '%s'. Both are UTC. If the countdown is off by one day in your timezone, that is correct server behavior — the display localizes the date string, not the day-count math.

If the countdown is wildly wrong (e.g., negative, or year-off):

  1. Confirm the metafield value is a valid ISO date (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ).
  2. Confirm the metafield namespace.key matches the oos_restock_date_metafield section setting (default custom.restock_date).
  3. The theme falls back to the variant-level metafield if product-level is empty. If both are empty, no banner renders.

The breadcrumb JSON-LD emits inline in snippets/breadcrumb.liquid when rendered. Verify:

bash
curl -s <your-store>/products/<any-product> | grep -A 2 'BreadcrumbList'

Expected: one JSON-LD script block containing "@type": "BreadcrumbList" with itemListElement entries using absolute URLs (starts with https://).

If the block is missing, the breadcrumb snippet is not rendered. The product.<preset>.json templates include the breadcrumb by default — check you did not accidentally remove it via theme editor.

Swatch hover updates the gallery image to match the hovered variant's featured_image. If the variant does not have a featured_image assigned in Shopify Admin, the gallery does not update (hover is a no-op). To fix:

  1. Shopify Admin → Products → <product> → Media → drag each image onto the matching variant.
  2. The theme reads product.media_index_by_variant_id to map variants to images. Admin-driven, no theme-editor configuration.

The PDP is slow to load on mobile.

Mobile Lighthouse performance below 0.6 on PDPs is typically caused by large product images, video heroes, or embedded iframes. Common causes:

  • Unoptimized product images (>1MB each) — Shopify auto-resizes for responsive loading but source file size matters for the first request
  • Video-as-hero products — consider an image placeholder with a play-button overlay
  • Large descriptions with embedded YouTube/Vimeo — lazy-load via native loading="lazy" on the iframe wrapper

Run Lighthouse per-page to identify the blocker — .lighthouserc.cjs config ships with the theme and targets mobile + desktop.

Decision tree: "Why is X not working?"

text
Symptom: gallery zoom not firing on desktop
  -> Is viewport >= 1024 px? (DevTools → width indicator)
    -> no: hover-zoom disabled by design; click-zoom should work
    -> yes: continue
  -> Is pointer hover-capable? (console: matchMedia('(hover: hover)').matches)
    -> false: touch-first device; click-zoom should work
    -> true: continue
  -> Is prefers-reduced-motion set? (console: matchMedia('(prefers-reduced-motion: reduce)').matches)
    -> true: zoom disabled by a11y preference; respect it
    -> false: confirm <product-gallery> mounted
      -> null: asset failed to load; check network tab for product-gallery.js
      -> present: check for CSS override `pointer-events: none` on gallery

Symptom: sticky ATC bar never appears on scroll
  -> Is <sticky-add-to-cart> in DOM? (console: document.querySelector('sticky-add-to-cart'))
    -> null: section removed or asset failed; re-add section + check Network
    -> present: continue
  -> Is the product page long enough to scroll past the buy-button?
    -> no: sticky bar stays hidden by design
    -> yes: continue
  -> IntersectionObserver support? (console: 'IntersectionObserver' in window)
    -> false: very old browser; polyfill not shipped in 06.4 (browser-matrix is modern evergreens)
    -> true: check for custom CSS `position: fixed` overrides on parent elements

Symptom: back-in-stock submission does not reach Klaviyo/CRM
  -> Is webhook URL set? (Theme editor → block setting)
    -> empty: submits to Shopify admin inbox; add URL to forward to CRM
    -> populated: continue
  -> Does `curl -X POST <url>` with sample payload return 2xx?
    -> 4xx/5xx: CRM endpoint rejecting — check docs for expected format
    -> 2xx: continue
  -> DevTools → Network → submit form; what is the POST target?
    -> <your-url>: success; check CRM inbox
    -> /contact: webhook URL field is empty or malformed; re-paste

Symptom: combined-listing chip missing
  -> Does the product have combined_listing.parent set? (Shopify Admin → Organization)
    -> no: configure via Admin or run populate-combined-listing-demo.mjs
    -> yes: continue
  -> Is the combined-listing block added to the section? (Theme editor)
    -> no: add it (Theme editor → Product → Add block → Combined listing)
    -> yes: continue
  -> Is there at least 1 sibling product in the group? (Shopify Admin)
    -> no: chip correctly hidden (the current product is the only child)
    -> yes: check Network tab; `combined_listing_json` fetch should return 200

Symptom: recommendations strip showing sold-out products
  -> Is `exclude_sold_out` enabled on the section? (Theme editor)
    -> off: enable it
    -> on: continue
  -> Push latest theme — filter logic lives in assets/product-recommendations.js
  -> Diagnostic snippet → check `recommendations.cards[].available` (all should be true)

Symptom: OOS cross-sell not rendering on sold-out PDP
  -> Is every variant sold out? (some variants still available triggers buy-button, not cross-sell)
    -> no: design-correct; fix sold-out status or expect buy-button
    -> yes: continue
  -> Is `show_oos_recommendations` enabled? (Theme editor → Product section)
    -> off: enable it
    -> on: continue
  -> Does `/recommendations/products.json?product_id=<id>&limit=4` return non-empty?
    -> empty: Shopify algorithm needs traffic history; fallback to back-in-stock form only
    -> non-empty: check product-recommendations.js is loaded + check console for JS errors

Symptom: breadcrumb JSON-LD missing
  -> curl the PDP: grep 'BreadcrumbList' — present?
    -> no: breadcrumb snippet not rendered; check template includes it
    -> yes: continue
  -> Google Rich Results Test (paste HTML) — BreadcrumbList enhancement green?
    -> red: URLs relative instead of absolute — push latest theme (fixed in 06.4)
    -> green: correctly configured

Symptom: PDP slow on mobile (Lighthouse perf < 0.6)
  -> Identify LCP element (DevTools → Performance → LCP flame)
    -> Hero image: optimize source size; Shopify auto-resizes but source matters
    -> Video: swap to placeholder image with play-button overlay
    -> Description block: check for embedded iframes; add loading="lazy"
  -> Are fonts preloaded? (head → `<link rel=preload as=font>`)
    -> no: enable via theme settings → Performance → enable_lcp_preload
    -> yes: continue checking

Validator interpretation

ValidatorOutput messageMeaningFix
Lighthouse"CLS > 0.1 on PDP"Gallery image loading caused layout shiftCheck gallery images have width + height attributes; theme emits them but custom edits may strip them
Lighthouse"Perf < 0.6 on mobile PDP"LCP element > 2.5s mobileSee decision tree "PDP slow on mobile"; check hero image size, fonts preload, video heroes
Lighthouse"TBT > 300 ms mobile"Total Blocking Time exceeded mobile gateShopify platform baseline (4× CPU throttle lab artifact). Not a theme defect. See Phase 06.2 baseline
Lighthouse"BP = 0.78"Best Practices score capped at 0.78Shopify platform cap (portable-wallets.en.js, bugsnag telemetry). Not a theme defect
Lighthouse"Perf < 0.6 on mobile PDP"LCP element > 2.5s mobile on a product with large mediaRe-export hero at ≤ 600 KB; Shopify CDN auto-resizes responsive variants but source size dominates first render
axe"color-contrast on .product__pill--selected"Variant pill selected state contrast ratio fails WCAG AA06.4 fix shipped but cached CSS may lag — clear CDN + re-push theme; if still failing, escalate (contrast math changed)
axe"aria-expanded invalid on pickup-availability"Drawer aria-expanded value not syncing on open/close06.4 fix shipped via capture-phase listener (F38 in audit). Clear CDN + re-push. If still failing, check for custom JS overriding handler
GRRT"Product.offers.itemCondition missing"product:condition hardcoded "new" in JSON-LD, no merchant settingDeferred to Phase 7
GRRT"BreadcrumbList.itemListElement.item must be a URL"Breadcrumb emitted relative URLsFixed in 06.4 (absolute URLs). Push latest theme
Meta Sharing Debugger"URL cannot be scraped"Dev-store password protection blocks URL-only scrapersPlatform constraint — dev stores cannot disable password. Reachable post-Theme-Store-transfer. Use HTML-paste validators meanwhile
LinkedIn Post Inspector"Unable to retrieve preview"Same as Meta (dev-store password)Same as above
Shopify Theme Check"@app block dispatch requires content_for 'blocks'"PDP sections use legacy {% for block in section.blocks %} instead of content_for 'blocks'Deferred to Phase 7
Shopify Theme Check"AssetCount > 60"Theme file count exceeds D-22 capAsset count (~106) exceeds the original 60-file cap; Phase 7 renegotiation in progress

Diagnostic snippet

Paste into your browser DevTools Console on any product detail page of your live store. Produces a single JSON blob describing current PDP state — safe, read-only, and adds zero theme code. The snippet runs one read-only traversal of the PDP DOM and writes nothing.

javascript
(() => {
  const state = {
    pageUrl: location.href,
    viewport: { w: innerWidth, h: innerHeight },
    theme: document.documentElement.getAttribute('data-theme') || 'light',
    dir: document.documentElement.getAttribute('dir') || 'ltr',
    lang: document.documentElement.getAttribute('lang'),
    template: document.body.getAttribute('data-template') || 'unknown',
    preferences: {
      hover: matchMedia('(hover: hover)').matches,
      pointerCoarse: matchMedia('(pointer: coarse)').matches,
      reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
    },
    gallery: {
      present: !!document.querySelector('product-gallery'),
      activeIndex: (() => {
        const el = document.querySelector('product-gallery [data-active]');
        return el ? Number(el.dataset.active) : null;
      })(),
      mediaCount: document.querySelectorAll('product-gallery [data-media]').length,
      zoomMode: (() => {
        const el = document.querySelector('product-gallery');
        return el ? el.getAttribute('data-zoom-mode') : null;
      })(),
      deepLink: location.hash.match(/^#media-(\d+)$/)?.[1] || null,
      viewer: {
        present: !!document.querySelector('gallery-viewer'),
        open: !!document.querySelector('gallery-viewer[open]'),
      },
    },
    variantPicker: {
      present: !!document.querySelector('variant-picker'),
      activeVariantId: (() => {
        const el = document.querySelector('variant-picker [data-active-variant]');
        return el ? el.dataset.activeVariant : null;
      })(),
      variantsAvailable: document.querySelectorAll('variant-picker [data-variant]:not([data-sold-out])').length,
      variantsSoldOut: document.querySelectorAll('variant-picker [data-variant][data-sold-out]').length,
      combinedListing: (() => {
        const el = document.querySelector('combined-listing-chip');
        if (!el) return { parentHandle: null, siblingCount: 0, chipVisible: false };
        return {
          parentHandle: el.getAttribute('data-parent-handle'),
          siblingCount: Number(el.getAttribute('data-sibling-count') || 0),
          chipVisible: getComputedStyle(el).display !== 'none',
        };
      })(),
    },
    buyButton: {
      present: !!document.querySelector('[data-product-form]'),
      enabled: (() => {
        const btn = document.querySelector('[data-product-form] button[type="submit"]');
        return btn ? !btn.disabled : null;
      })(),
      unitPriceVisible: !!document.querySelector('[data-unit-price]'),
    },
    stickyAtc: {
      present: !!document.querySelector('sticky-add-to-cart'),
      visible: (() => {
        const el = document.querySelector('sticky-add-to-cart');
        return el ? getComputedStyle(el).display !== 'none' && getComputedStyle(el).visibility !== 'hidden' : false;
      })(),
      observing: !!document.querySelector('sticky-add-to-cart[data-observing]'),
    },
    backInStock: {
      formPresent: !!document.querySelector('.product__back-in-stock-form'),
      webhookConfigured: (() => {
        const form = document.querySelector('.product__back-in-stock-form');
        return form ? !!form.dataset.webhookUrl : false;
      })(),
      successShown: !!document.querySelector('.product__back-in-stock-form.product__back-in-stock-form--success'),
    },
    recommendations: {
      present: !!document.querySelector('product-recommendations'),
      excludeOos: (() => {
        const el = document.querySelector('product-recommendations');
        return el ? el.dataset.excludeOos === 'true' : null;
      })(),
      collectionScope: (() => {
        const el = document.querySelector('product-recommendations');
        return el ? el.dataset.collectionScope || null : null;
      })(),
      cardsCount: document.querySelectorAll('product-recommendations .product-card').length,
      cardsAvailableCount: document.querySelectorAll('product-recommendations .product-card:not([data-sold-out])')
        .length,
    },
    oosCrossSell: {
      present: !!document.querySelector('.product__oos-cross-sell'),
      restockBanner: !!document.querySelector('.product__restock-banner'),
      restockDays: (() => {
        const el = document.querySelector('.product__restock-banner [data-restock-days]');
        return el ? Number(el.dataset.restockDays) : null;
      })(),
    },
    sellingPlan: {
      present: !!document.querySelector('[data-selling-plan-block]'),
      planCount: document.querySelectorAll('[data-selling-plan-option]').length,
      saveBadgesVisible: document.querySelectorAll('[data-selling-plan-save-badge]:not([hidden])').length,
      costPerDeliveryVisible: document.querySelectorAll('[data-selling-plan-cost-per-delivery]:not([hidden])').length,
      plans: Array.from(document.querySelectorAll('[data-selling-plan-option]')).map((el) => ({
        id: el.dataset.sellingPlanId,
        savePercent: Number(el.dataset.savePercent || 0),
        costPerDelivery: el.dataset.costPerDelivery || null,
      })),
    },
    breadcrumb: {
      present: !!document.querySelector('[data-breadcrumb]'),
      jsonLdPresent: !!document.querySelector('script[type="application/ld+json"][data-breadcrumb-jsonld]'),
      jsonLdAbsoluteUrls: (() => {
        const el = document.querySelector('script[type="application/ld+json"][data-breadcrumb-jsonld]');
        if (!el) return null;
        try {
          const parsed = JSON.parse(el.textContent);
          const items = parsed.itemListElement || [];
          return items.every((i) => typeof i.item === 'string' && i.item.startsWith('https://'));
        } catch (e) {
          return false;
        }
      })(),
    },
    productJsonLd: {
      present: !!document.querySelector('script[type="application/ld+json"][data-product-jsonld]'),
      valid: (() => {
        const el = document.querySelector('script[type="application/ld+json"][data-product-jsonld]');
        if (!el) return null;
        try {
          const parsed = JSON.parse(el.textContent);
          return parsed['@type'] === 'Product' && !!parsed.offers;
        } catch (e) {
          return false;
        }
      })(),
    },
    shopperState: {
      available: typeof window.UisceShopperState === 'object',
      recentlyViewedCount: (() => {
        try {
          return JSON.parse(localStorage.getItem('uisce.shopper.recentlyViewed') || '[]').length;
        } catch (e) {
          return null;
        }
      })(),
    },
  };
  console.log('Uisce Theme — PDP diagnostic report');
  console.log(JSON.stringify(state, null, 2));
  return state;
})();

What to look for in the output:

  • gallery.present === true and gallery.mediaCount >= 1 — gallery is mounted with content.
  • gallery.zoomMode matches the expected mode for your device (hover / click / disabled).
  • variantPicker.present === true — picker mounted; combinedListing.chipVisible is true only on combined-listing children.
  • buyButton.enabled === true on an in-stock product, false on a sold-out product.
  • buyButton.unitPriceVisible === true on variants with unit_price_measurement set.
  • stickyAtc.present === true on every PDP; stickyAtc.visible depends on scroll position.
  • backInStock.formPresent === true on sold-out variants; backInStock.webhookConfigured === true only if merchant pasted a webhook URL.
  • recommendations.present === true + cardsAvailableCount === cardsCount when excludeOos === true.
  • oosCrossSell.present === true on fully-sold-out PDPs (every variant OOS); oosCrossSell.restockDays is an integer if the restock-date metafield is populated.
  • sellingPlan.plans[].savePercent is non-zero for discounted plans, zero for non-discounted (badge correctly hidden).
  • breadcrumb.jsonLdAbsoluteUrls === true — breadcrumb itemListElement.item values all start with https://.
  • productJsonLd.valid === true — Product JSON-LD is emitted with a valid offers block.
  • shopperState.available === true — shopper-state primitive loaded (drives recently-viewed, back-in-stock success memory, etc).

Curl cheat-sheet

Use these one-liners to verify your live store's PDP. Replace YOURSTORE with your domain and <product-handle> with an actual product. If your store is password-protected (dev store), add -H 'Cookie: storefront_digest=<hash>' — see your dev-store password storage in Shopify admin. For live production stores, no cookie is needed.

1. Verify the Product JSON-LD is emitted and valid.

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -A 20 'data-product-jsonld'

Expected: a JSON-LD <script> block containing "@type": "Product", "offers": {...}, and "name": "...".

2. Verify the BreadcrumbList JSON-LD uses absolute URLs.

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -oE '"item":"https://[^"]+"'

Expected: at least 2 lines, each starting with "item":"https://. Any result showing "item":"/ (relative) indicates a stale deployment.

3. Verify hreflang alternates in the head (regression check from Phase 06.2).

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -c '<link rel=.alternate. hreflang='

Expected: 1 or more if you have published locales.

4. Verify product-gallery custom element is present.

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -c '<product-gallery'

Expected: 1.

5. Verify variant-picker custom element is present and has variants.

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -oE '<variant-picker[^>]*>' | head -1

Expected: one line containing <variant-picker.

6. Verify sticky-add-to-cart is mounted.

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -c '<sticky-add-to-cart'

Expected: 1.

7. Verify product-recommendations.js is loaded with defer.

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -oE '<script[^>]*product-recommendations[^>]*>'

Expected: one line containing defer.

8. Verify the back-in-stock block is present on a sold-out product.

bash
curl -s https://YOURSTORE/products/<sold-out-product-handle> | grep -c 'product__back-in-stock-form'

Expected: 1 when the product has at least one variant sold out.

9. Verify commerce OG tags for social sharing.

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -oE '<meta[^>]*property="og:(type|price:amount|price:currency|availability)"[^>]*>'

Expected: 4 lines (og:type=product, og:price:amount, og:price:currency, og:availability).

10. Verify the template name is product (not a custom template) — regression check for per-preset template gating.

bash
curl -s https://YOURSTORE/products/<product-handle> | grep -oE 'data-template="[^"]+"' | head -1

Expected: data-template="product" or data-template="product.<preset>" (e.g., product.tine).

11. Verify the combined-listing chip renders on a parent-linked child product.

bash
curl -s https://YOURSTORE/products/<combined-listing-child-handle> | grep -c 'combined-listing-chip'

Expected: 1 on a child product in a group.

12. Verify selling-plan save badge renders on a discounted subscription.

bash
curl -s https://YOURSTORE/products/<subscription-product-handle> | grep -oE 'data-selling-plan-save-badge[^>]*>'

Expected: one or more lines if the product has selling plans with discount.

13. Verify the OOS restock banner renders when metafield is populated.

bash
curl -s https://YOURSTORE/products/<oos-product-handle> | grep -c 'product__restock-banner'

Expected: 1 when the product is fully sold out AND has custom.restock_date populated.

Deferred items (v1.1 / Phase 06.5 / Phase 07)

These items were surfaced during the Phase 06.4 audit but explicitly deferred to a later phase. All have seed files tracking the revisit criteria.

FindingRationaleStatus
Rating count CLDR fan-out (F24)Most locales only have one / other forms; 50-locale CLDR rollover belongs in Phase 06.5 PLP audit where rating is more prominent on collection cardsDeferred to Phase 06.5
Asset count cap D-22 breach (F41)Pre-existing breach (99→106 files vs cap of 60); Phase 7 renegotiates with fresh evidence (HTTP/2, Shopify CDN amortization, Lighthouse before/after)Deferred to Phase 7 renegotiation
@app block dispatch via content_for (F33)Retrofitting PDP sections from legacy {% for block in section.blocks %} to content_for 'blocks' is a Theme Store review gate; migrate before submissionDeferred to Phase 7
product:condition merchant settingHardcoded "new" in JSON-LD; merchants selling refurbished / used need a per-product override settingDeferred to Phase 7
Embedded PDP diagnostic tool (same as 06.3)Console snippet provides ~80% of the value at ~5% of the cost; v1 ships without an embedded UIDeferred to Milestone 2

Built for the Shopify Theme Store.