Product detail and combined purchases
Synchronize model and variant facts before media, accessories and specifications.
Models, variants and the purchase form
Let the selected variant determine price, availability and the submitted ID. Provide a reloadable variant URL before asynchronous selection, then keep media, SKU and URL aligned.
Data and dependencies
- product and selected_or_first_available_variant.
- Submit variant.id rather than product.id.
Interaction logic
- The GET selector reloads the chosen variant before purchase.
- Keep sold-out variants viewable while disabling purchase.
Example code · snippets/ww-variant-fallback.liquid
{% assign current_variant = product.selected_or_first_available_variant %}
<h1>{{ product.title | escape }}</h1>
<p>{{ current_variant.title | escape }} — {{ current_variant.price | money_with_currency }}</p>
<form action="{{ product.url }}" method="get">
<label for="Variant-{{ section.id }}">Variant</label>
<select id="Variant-{{ section.id }}" name="variant">
{% for variant in product.variants %}
<option value="{{ variant.id }}"{% if variant.id == current_variant.id %} selected{% endif %}>
{{ variant.title | escape }}{% unless variant.available %} — Sold out{% endunless %}
</option>
{% endfor %}
</select>
<button type="submit">View this variant</button>
</form>
{% assign purchase_form_id = 'Purchase-' | append: section.id %}
{% form 'product', product, id: purchase_form_id %}
<input type="hidden" name="id" value="{{ current_variant.id }}">
<button type="submit"{% unless current_variant.available %} disabled{% endunless %}>
{% if current_variant.available %}Add to cart{% else %}Sold out{% endif %}
</button>
{% endform %}
Acceptance checks
- Open a variant URL and compare price with form ID.
- Check sold-out states, changes and JavaScript disabled.
This is a no-script baseline for a small variant set. For high-variant products, use current theme option and on-demand rendering patterns rather than relying on this loop.
Official documentation
Implementation references
WESWOO guides
Thumbnails, main media and video
Render actual media first. When adding thumbnails, preserve the relationship between selected media and variant. Keep important diagram facts in HTML and avoid blocking buying content with video.
Data and dependencies
- product.media and media_type.
- Use descriptive alt text from the model media record.
Interaction logic
- Keep images readable and video user-controlled.
- Select variant media when available and retain a missing-image fallback.
Example code · snippets/ww-media.liquid
<ol aria-label="Product media">
{% for media in product.media %}
<li id="Media-{{ section.id }}-{{ media.id }}">
{% case media.media_type %}
{% when 'image' %}
{{ media | image_url: width: 1400 | image_tag: widths: '480, 800, 1400', sizes: '(max-width: 749px) 100vw, 60vw', loading: 'lazy' }}
{% when 'video' %}
{{ media | video_tag: controls: true, autoplay: false, preload: 'none' }}
{% else %}
<a href="{{ product.url | escape }}">View media on the product page</a>
{% endcase %}
</li>
{% endfor %}
</ol>
Acceptance checks
- Check selected thumbnails, keyboard, touch and main media.
- Do not defer the first-view image; defer lower media when appropriate.
The example is a media-list fragment. Use eager loading for first-view media and integrate the existing gallery for carousel and focus behavior.
Compatible accessories and explicit selection
Take compatibility from reviewed model data and expose purchasable accessory destinations. Before a combined add, confirm main and accessory variant IDs and quantities, then update from the actual cart response.
Data and dependencies
- custom.compatible_accessories: list.product_reference.
- Record variant-specific compatibility separately.
Interaction logic
- Revalidate accessory choices after the model changes.
- Prevent duplicate submissions and avoid automatic mutation retries.
Example code · snippets/ww-accessories.liquid
{% assign accessories = product.metafields.custom.compatible_accessories.value %}
{% if accessories != blank %}
<section aria-labelledby="Accessories-{{ section.id }}">
<h2 id="Accessories-{{ section.id }}">Accessories for this model</h2>
<ul>
{% for accessory in accessories %}
<li>
<a href="{{ accessory.url | escape }}">{{ accessory.title | escape }}</a>
<span>{{ accessory.price | money_with_currency }}</span>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
Example code · assets/ww-add-selection.js
export async function addSelection(root, items) {
if (!root.startsWith('/') || root.startsWith('//')) throw new Error('Invalid store route');
if (!items.length || items.some(item => !Number.isSafeInteger(item.id) || item.id <= 0 ||
!Number.isSafeInteger(item.quantity) || item.quantity <= 0)) {
throw new Error('Select valid variants and quantities');
}
const response = await fetch(`${root}cart/add.js`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
body: JSON.stringify({items})
});
const result = await response.json();
if (!response.ok) throw new Error(result.description || 'The selection could not be added');
return result;
}
// Pass window.Shopify.routes.root from a Shopify theme.
// Disable repeat submission while pending, then refresh actual cart state.
// A network error must not trigger an automatic second add request.
Acceptance checks
- Test incompatible items, unavailable variants and quantities.
- Read the cart after success or failure to confirm actual lines.
Recommendations do not prove compatibility. The list does not add items itself; integrate the Ajax function with real routes and status feedback.
Official documentation
Implementation references
WESWOO guides
Specification tabs, packaging and policies
Organize specifications, packaging, compatibility and policies into clear regions. Start with native disclosure; if enhanced into tabs, retain server-rendered content and shared fact and policy versions.
Data and dependencies
- Use the ww-specifications dependency.
- custom.compatibility is single-line text; link to current policies.
Interaction logic
- Do not invent copy for missing records.
- Keep headings and sources usable after expansion.
Example code · snippets/ww-product-details.liquid
<details>
<summary>Specifications and compatibility</summary>
{% render 'ww-specifications', product: product %}
{% assign compatibility = product.metafields.custom.compatibility.value %}
{% if compatibility != blank %}<p>{{ compatibility | escape }}</p>{% endif %}
</details>
<details>
<summary>Shipping and returns</summary>
{% if shop.shipping_policy != blank %}<a href="{{ shop.shipping_policy.url | escape }}">Shipping policy</a>{% endif %}
{% if shop.refund_policy != blank %}<a href="{{ shop.refund_policy.url | escape }}">Returns policy</a>{% endif %}
</details>
Example code · snippets/ww-specifications.liquid
{% assign rows = product.metafields.custom.specification_rows.value %}
{% if rows != blank %}
<table>
<caption>{{ product.title | escape }} — specifications</caption>
<tbody>
{% for row in rows %}
{% if row.label.value != blank and row.spec_value.value != blank %}
<tr>
<th scope="row">{{ row.label.value | escape }}</th>
<td>{{ row.spec_value.value | escape }} {{ row.unit.value | escape }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% endif %}
Acceptance checks
- Read facts and policies with scripts disabled.
- Check phone tables, long values and policy destinations.
A policy link does not establish applicability in every market. Use the policy module for regional conditions.
Official documentation
WESWOO guides
Read it, integrate it, verify it
- Choose a module and confirm the data contract.
- Connect the fragment to the current theme or framework.
- Check real variants, markets and recovery states.
Examples target Shopify Online Store 2.0 themes. Headless has separate data and version boundaries. Integrate fragments with actual fields, theme components and locale files.
Architecture references
Original educational examples with Shopify documentation and pinned upstream references. Follow the applicable license when adopting repository code.
References checked 2026-11-12