From 7707448554c8e31ce1ce8463c0c6fc22b67a6838 Mon Sep 17 00:00:00 2001 From: Boris Besemer Date: Fri, 5 Jun 2026 15:11:20 +0200 Subject: [PATCH 1/3] feat: latest storefront apis --- apps/docs/content/docs/anatomy/aeo-geo.mdx | 2 +- apps/docs/content/docs/anatomy/agent.mdx | 23 +- apps/docs/content/docs/anatomy/cart.mdx | 8 +- apps/docs/content/docs/anatomy/pages/pdp.mdx | 22 +- .../content/docs/anatomy/product-card.mdx | 10 +- apps/docs/content/docs/reference/routes.mdx | 12 +- .../content/docs/reference/storefront-api.mdx | 59 +++- .../docs/reference/troubleshooting.mdx | 6 +- apps/docs/content/docs/shopify/index.mdx | 16 +- apps/docs/content/docs/shopify/pdp.mdx | 36 +- .../docs/skills/enable-shopify-markets.mdx | 4 +- apps/template/app/products/[handle]/page.tsx | 31 +- .../template/components/agent/agent-panel.tsx | 1 + apps/template/components/agent/registry.tsx | 21 +- apps/template/components/cart/context.tsx | 4 +- .../template/components/cart/overlay-item.tsx | 22 +- .../product-detail/bundle-components.tsx | 78 ++++ .../components/product-detail/buy-buttons.tsx | 7 +- .../product-detail/color-picker.tsx | 46 +-- .../product-detail/option-picker.tsx | 41 +-- .../product-detail/product-detail-section.tsx | 153 ++------ .../product-detail/product-info.tsx | 33 +- .../components/product-detail/schema.tsx | 6 +- apps/template/lib/agent/index.ts | 14 +- apps/template/lib/agent/server.ts | 20 +- apps/template/lib/agent/tools/add-to-cart.ts | 3 +- apps/template/lib/agent/tools/get-cart.ts | 6 +- .../lib/agent/tools/get-product-details.ts | 20 ++ .../agent/tools/resolve-product-variant.ts | 102 ++++++ apps/template/lib/i18n/messages/en.json | 4 + apps/template/lib/markdown/product.ts | 16 +- apps/template/lib/product.ts | 127 ++----- apps/template/lib/shopify/fragments.ts | 235 ++++++++---- apps/template/lib/shopify/operations/cart.ts | 17 +- .../lib/shopify/operations/products.ts | 113 +++++- apps/template/lib/shopify/transforms/cart.ts | 18 +- .../lib/shopify/transforms/product.ts | 333 ++++++++++++++++-- apps/template/lib/types.ts | 40 ++- ...2026-06-05-modern-shopify-product-model.md | 90 +++++ 39 files changed, 1287 insertions(+), 512 deletions(-) create mode 100644 apps/template/components/product-detail/bundle-components.tsx create mode 100644 apps/template/lib/agent/tools/resolve-product-variant.ts create mode 100644 packages/plugin/template-rollout-log/2026-06-05-modern-shopify-product-model.md diff --git a/apps/docs/content/docs/anatomy/aeo-geo.mdx b/apps/docs/content/docs/anatomy/aeo-geo.mdx index 97354a8a..25cea453 100644 --- a/apps/docs/content/docs/anatomy/aeo-geo.mdx +++ b/apps/docs/content/docs/anatomy/aeo-geo.mdx @@ -66,7 +66,7 @@ The `Vary: Accept` header ensures CDNs cache markdown and HTML responses separat ### What's included in the markdown -- **Product pages** — handle, brand, category, pricing, options, variants, specs, images, tags, and SEO metadata +- **Product pages** — handle, brand, category, pricing, exact variant count, representative selectable variants, options, specs, images, tags, and SEO metadata - **Collection pages** — collection metadata, description, applied filters, available filters, products, pagination, image, and SEO metadata - **Search pages** — query metadata, active collection filter, applied filters, available filters, products, and pagination state diff --git a/apps/docs/content/docs/anatomy/agent.mdx b/apps/docs/content/docs/anatomy/agent.mdx index e52e607d..0d3cca79 100644 --- a/apps/docs/content/docs/anatomy/agent.mdx +++ b/apps/docs/content/docs/anatomy/agent.mdx @@ -30,7 +30,7 @@ AgentPanel (client) → POST /api/chat → ToolLoopAgent (Claude) → Shopify to The agent adapts its instructions based on where the user is browsing. Page context is resolved server-side from the `Referer` header: -- **Product page** - the full product is injected into the prompt with all variants, prices, availability, and variant IDs ready for "add to cart" +- **Product page** - the product, exact variant count, options, and a representative selectable variant set are injected into the prompt; the agent resolves exact choices on demand - **Collection page** - the collection handle and title are included - **Search page** - the active search query is passed through - **Cart page** - the agent knows the user is viewing their cart @@ -40,17 +40,18 @@ The system prompt also includes the user's locale so the agent responds in the c ## Tools -The agent has 11 tools organized into three groups. All tools access the agent context (chat ID, cart ID, locale, page) via AsyncLocalStorage. +The agent has 12 tools organized into three groups. All tools access the agent context (chat ID, cart ID, locale, page) via AsyncLocalStorage. ### Product discovery -| Tool | Purpose | -| --------------------------- | ------------------------------------------------------------------------------------- | -| `searchProducts` | Keyword search with sorting (best matches, price low/high) - returns up to 10 results | -| `getProductDetails` | Full product info: title, description, price, variants, availability, images | -| `getProductRecommendations` | AI-powered similar products (up to 5) | -| `listCollections` | All store categories | -| `browseCollection` | Products within a collection with sorting | +| Tool | Purpose | +| --------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `searchProducts` | Keyword search with sorting (best matches, price low/high) - returns up to 10 results | +| `getProductDetails` | Product info, exact variant count, mapped options, representative variants, and bundle relationships | +| `resolveProductVariant` | Resolves the user's exact option choices to a purchasable Shopify variant without downloading every variant | +| `getProductRecommendations` | AI-powered similar products (up to 5) | +| `listCollections` | All store categories | +| `browseCollection` | Products within a collection with sorting | ### Cart management @@ -74,10 +75,12 @@ The agent doesn't just return text - it renders rich UI components via json-rend - **AgentProductCard** - clickable card with image, title, price, compare-at price, and availability - **AgentProductGrid** - 2-column responsive grid wrapping multiple product cards -- **AgentCartSummary** - full cart breakdown with line item thumbnails, subtotal, tax, total, and checkout button +- **AgentCartSummary** - full cart breakdown with line item thumbnails, bundle components, subtotal, total, checkout-time tax note, and checkout button - **AgentCartConfirmation** - success card shown after adding an item to cart - **AgentVariantPicker** - display-only variant selector with option groups and availability status +For high-variant products and Combined Listings, the picker is intentionally representative. After the shopper specifies options, the agent calls `resolveProductVariant` to obtain the exact variant ID and owning product handle before adding it to cart. Customized bundle parents that require shopper-selected components are not added by the generic agent flow. + These components are registered in a json-render registry and rendered client-side from specs embedded in the assistant's streamed messages. ## Cart reconciliation diff --git a/apps/docs/content/docs/anatomy/cart.mdx b/apps/docs/content/docs/anatomy/cart.mdx index 383e2391..83b84ada 100644 --- a/apps/docs/content/docs/anatomy/cart.mdx +++ b/apps/docs/content/docs/anatomy/cart.mdx @@ -55,7 +55,9 @@ The overlay content in `overlay-content.tsx` composes three sections: an empty s ## Overlay items -Each line item in `overlay-item.tsx` reads from `cartWithPending` to reflect optimistic state. It renders the product image, title, variant options (color, size, etc.), quantity controls (+/- buttons), a remove button, and the line total. Quantity changes and removals dispatch through the cart context, which handles debouncing and server action calls. +Each line item in `overlay-item.tsx` reads from `cartWithPending` to reflect optimistic state. It renders the product image, title, variant options (color, size, etc.), bundle components, quantity controls (+/- buttons), a remove button, and the line total. Quantity changes and removals dispatch through the cart context, which handles debouncing and server action calls. + +The controls honor Shopify's `CartLine.instructions.canUpdateQuantity` and `canRemove` flags instead of assuming every line is editable. ## Cart page @@ -72,6 +74,10 @@ Below the items, a `RelatedProductsSection` shows product recommendations based Cart operations in `lib/shopify/operations/cart.ts` use GraphQL mutations: `cartLinesAdd`, `cartLinesUpdate`, and `cartLinesRemove`. The cart ID is stored in a `shopify_cartId` HTTP-only cookie with a 7-day expiry. Every mutation calls `invalidateCartCache()` to ensure subsequent reads reflect the latest state. +The cart fragment reads both `CartLine` and `ComponentizableCartLine`, preserving `lineComponents` as nested domain cart lines. This lets fixed and transformed bundles render as one parent with their included products. The `addToCart()` operation also accepts Shopify's optional `CartLineInput.parent` relationship as a foundation for app-specific customized bundle flows. + +The fragment intentionally does not query deprecated cart tax and duty amounts. Shopify finalizes those values at checkout, so storefront summaries show a taxes-and-shipping-at-checkout note instead of a misleading pre-checkout tax amount. + The transform layer in `lib/shopify/transforms/cart.ts` converts Shopify's GraphQL response shape into a domain `Cart` type used throughout the application. This isolates the rest of the codebase from Shopify's API structure. ## Key files diff --git a/apps/docs/content/docs/anatomy/pages/pdp.mdx b/apps/docs/content/docs/anatomy/pages/pdp.mdx index b5b309ba..fe9d68a9 100644 --- a/apps/docs/content/docs/anatomy/pages/pdp.mdx +++ b/apps/docs/content/docs/anatomy/pages/pdp.mdx @@ -12,26 +12,30 @@ The Product Detail Page renders a single product with variant selection, an imag The PDP runs under Next.js 16 Cache Components. Product data is fetched through `"use cache"` operations with `cacheLife("max")` and a `product-{handle}` tag, so the page renders from cache on every request and is only rebuilt when a Shopify webhook invalidates the matching tag — ISR-style freshness with no per-request fetch (see [Shopify Integration](/docs/shopify) for the webhook setup). -The only dynamic input is the `?variant=` query parameter. Rather than block the whole page on that lookup, the route passes the `searchParams` promise — unawaited — down into `ProductDetailSection`. Anything that doesn't depend on the chosen variant (title, shared gallery images, uniformly-priced variants, the description, related products) renders straight from the cached product. Anything that does (the color-specific gallery slot, variant-specific price, option highlight, buy buttons) sits inside its own Suspense boundary so a `?variant=` change streams the affected slot without skeletonizing the rest of the page. +The dynamic input is the selected option values in `searchParams`. Rather than block the whole page on that lookup, the route passes selection work into `ProductDetailSection` as a promise. Anything that doesn't depend on the chosen variant (title, shared gallery images, description, related products) renders straight from the cached product. Anything that does (the color-specific gallery slot, variant-specific price, option state, bundle relationships, buy buttons) sits inside its own Suspense boundary so an option change streams the affected slot without skeletonizing the rest of the page. ## Variant selection -Variants are selected via `searchParams`. Each option (color, size, etc.) renders as a `` pointing to the same product with Shopify's `?variant` query parameter: +Variants are selected via option-name query parameters: ``` -/products/classic-tee?variant=123456 +/products/classic-tee?Color=Black&Size=Medium ``` -The page receives `searchParams` as a promise, threads the variant ID through to `computeSelection()` on the server, and that function returns `{ selectedOptions, selectedVariant, colorImages }` in one pass — matching the numeric variant ID to the full variant object. The result is threaded down as props with no client-side variant context or `useState`. This means: +The route passes those values to `getProductSelection()`, which resolves the current variant and option state with Shopify's `selectedOrFirstAvailableVariant`, `adjacentVariants`, and encoded variant fields. `computeSelection()` then combines that response with the cached product and returns the selected variant, mapped options, representative variants, and color images. - Every variant selection is a navigation, giving you browser back/forward for free - URLs are shareable - opening a link loads the exact variant - The page is fully server-rendered with zero layout shift - Variant links use `scroll={false}` to prevent the page from jumping to the top on selection +- Combined Listing option links can navigate to the owning child product handle +- Existing but out-of-stock combinations remain navigable; combinations that do not exist render as inert elements -The `getVariantUrl()` helper in `lib/product.ts` computes the target URL for each option. When a user selects a new option value, it finds the matching variant (or falls back to the first variant with that option) and returns the URL with the correct `variant` query parameter. +Legacy `/products/:handle?variant=:variantId` links remain supported. The route resolves the variant ID to selected options, then uses the same modern selection query. -Unavailable options render as inert `` elements instead of links. +### High-variant products + +The product query intentionally does not request `variants(first: 50)` or any other exhaustive variant connection. Shopify supports up to 2,048 variants, so the template uses `options.optionValues.firstSelectableVariant`, `adjacentVariants`, and the selected variant as a representative set. `variantsCount` remains the exact count for structured data and agent output. Options with a single value render as a plain `Name: Value` label without a picker — there is no choice to surface, but the attribute is still useful information to the shopper (e.g. `Finish: Natural White Oak` on a one-variant product). Shopify's synthetic `Title: Default Title` option (emitted for products with no variant axes) is the only single-value option that is hidden entirely. @@ -47,7 +51,7 @@ Both layouts receive pre-filtered images as props from the server. ### Color-based image filtering -When a product has color variants, the gallery shows only images relevant to the selected color. Shared images render immediately, while the color-specific slot streams behind a small Suspense fallback so changing `?variant` does not skeletonize the whole gallery. The `getPartitionedImagesForSelectedColor()` function in `lib/product.ts`: +When a product has color variants, the gallery shows only images relevant to the selected color. Shared images render immediately, while the color-specific slot streams behind a small Suspense fallback so changing an option does not skeletonize the whole gallery. The `getPartitionedImagesForSelectedColor()` function in `lib/product.ts`: 1. Identifies the color option via swatch data or option name 2. Collects variant images assigned to each color @@ -66,6 +70,8 @@ The buy section includes stock status and two action buttons: A single `BuyButtons` component renders identically on both mobile and desktop. It receives the `selectedVariant` as a prop and is a client component for cart interactivity. +Fixed bundle variants render their component products above the buy buttons. Component products also show the bundles returned by Shopify's `groupedBy` relationship. Customized bundle parents with `requiresComponents: true` and no fixed components show a disabled "Choose bundle items" action because their picker and component-line inputs are app-specific. + ## Related Products Below the product details, a `RelatedProductsSection` component fetches related products from Shopify's recommendation API. It renders inside a `Suspense` boundary with a skeleton grid fallback. @@ -95,6 +101,6 @@ The page emits two JSON-LD schemas: | `components/product-detail/product-detail-section.tsx` | Orchestrates media, options, pricing, and buy buttons with per-section Suspense; emits JSON-LD | | `components/product-detail/product-media.tsx` | Responsive image gallery (mobile carousel, desktop grid + lightbox) | | `components/product-detail/buy-buttons.tsx` | Buy with Shop + add-to-cart client component | -| `lib/product.ts` | `computeSelection`, variant resolution, URL generation, color-image partitioning | +| `lib/product.ts` | `computeSelection`, option-param parsing, and color-image partitioning | The rest of the PDP — option pickers, price display, lightbox, schema, and so on — lives in `components/product-detail/`. Browse the directory when you need them. diff --git a/apps/docs/content/docs/anatomy/product-card.mdx b/apps/docs/content/docs/anatomy/product-card.mdx index 12a897ea..d0facece 100644 --- a/apps/docs/content/docs/anatomy/product-card.mdx +++ b/apps/docs/content/docs/anatomy/product-card.mdx @@ -48,14 +48,10 @@ Add a hover image carousel to the product card. Data: - In lib/shopify/fragments.ts, add an `images(first: 5)` block (using ...ImageFields) - to PRODUCT_CARD_FRAGMENT, and add a `variants(first: 50)` block selecting each - variant's `image { url }`. + to PRODUCT_CARD_FRAGMENT. - In lib/shopify/transforms/product.ts, restore an `images: Image[]` value on the - object returned by transformShopifyProductCard. Compute it with a - `filterVariantImages(product)` helper that flattens product.images, then excludes - any image whose URL (ignoring query params) matches a non-default variant's image - - so color-swatch variant images don't leak into the carousel. Add the matching - `images?` and `variants?` fields back to the ShopifyProductCard response interface. + object returned by transformShopifyProductCard by flattening `product.images`. Add + the matching `images?` field to the ShopifyProductCard response interface. - In lib/types.ts, add `images: Image[]` back to the ProductCard interface. Component: diff --git a/apps/docs/content/docs/reference/routes.mdx b/apps/docs/content/docs/reference/routes.mdx index b0c9789a..5ea59398 100644 --- a/apps/docs/content/docs/reference/routes.mdx +++ b/apps/docs/content/docs/reference/routes.mdx @@ -9,7 +9,7 @@ type: reference | Route | File | Description | | ----------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------- | | `/` | `app/page.tsx` | Home page with hero, featured products, info section | -| `/products/[handle]` | `app/products/[handle]/page.tsx` | Product detail page; `?variant=` selects a specific variant | +| `/products/[handle]` | `app/products/[handle]/page.tsx` | Product detail page; option-name query params select a variant | | `/collections/[handle]` | `app/collections/[handle]/page.tsx` | Collection listing with filters, sort, pagination | | `/collections/all` | `app/collections/[handle]/page.tsx` | Virtual "All Products" listing; synthesized since Shopify's Storefront API has no `all` collection | | `/search` | `app/search/page.tsx` | Search results (same grid as collections) | @@ -27,8 +27,10 @@ type: reference ## Variant URLs -Product variant selection uses Shopify's standard `variant` query parameter directly on the product route: +PDP option links use option-name query parameters so Shopify can resolve high-variant products and Combined Listings without loading every variant. Legacy Shopify `variant` URLs remain accepted: -| URL | Purpose | -| -------------------------------------- | --------------------------------------------------------- | -| `/products/:handle?variant=:variantId` | Opens the product page with the matching variant selected | +| URL | Purpose | +| ------------------------------------------------ | ------------------------------------------------------------------ | +| `/products/:handle?Color=Black&Size=Medium` | Canonical PDP option-selection URL | +| `/products/:handle?variant=:variantId` | Legacy-compatible URL; resolves the ID to selected options first | +| `/products/:combinedChild?Color=Blue&Size=Large` | Combined Listing selection using the owning child product's handle | diff --git a/apps/docs/content/docs/reference/storefront-api.mdx b/apps/docs/content/docs/reference/storefront-api.mdx index e5b16cf7..bdf7f3de 100644 --- a/apps/docs/content/docs/reference/storefront-api.mdx +++ b/apps/docs/content/docs/reference/storefront-api.mdx @@ -15,10 +15,10 @@ For Shopify admin setup and required token scopes, see [Storefront API Permissio ```ts import { shopifyFetch } from "@/lib/shopify/fetch"; -const data = await shopifyFetch<{ productByHandle: ShopifyProduct }>({ +const data = await shopifyFetch<{ product: ShopifyProduct | null }>({ operation: "getProductByHandle", query: GET_PRODUCT_BY_HANDLE_QUERY, - variables: { handle, country, language }, + variables: { handle, selectedOptions: [], country, language }, }); ``` @@ -52,10 +52,11 @@ const GET_PRODUCT_BY_HANDLE_QUERY = ` ${PRODUCT_FRAGMENT} query getProductByHandle( $handle: String! + $selectedOptions: [SelectedOptionInput!]! $country: CountryCode $language: LanguageCode ) @inContext(country: $country, language: $language) { - productByHandle(handle: $handle) { + product(handle: $handle) { ...ProductFields } } @@ -70,13 +71,16 @@ Always verify fields against the live Storefront API schema with `shopify-ai-too Shared fragments in `lib/shopify/fragments.ts` avoid repeating field selections: -| Fragment | Covers | -| --------------------------- | ---------------------------------------------------------------------------------------------- | -| `MONEY_FRAGMENT` | `amount` and `currencyCode` on `MoneyV2` | -| `IMAGE_FRAGMENT` | `url`, `altText`, `width`, `height` on `Image` | -| `PRODUCT_VARIANT_FRAGMENT` | Variant price, options, image, availability | -| `PRODUCT_FRAGMENT` | Full product: media, variants (up to 50), options with swatches, metafields, price ranges, SEO | -| `CATEGORY_PRODUCT_FRAGMENT` | Lightweight product for listing pages (fewer fields) | +| Fragment | Covers | +| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `MONEY_FRAGMENT` | `amount` and `currencyCode` on `MoneyV2` | +| `IMAGE_FRAGMENT` | `url`, `altText`, `width`, `height` on `Image` | +| `PRODUCT_VARIANT_FRAGMENT` | Representative variant price, options, image, availability, and owning product handle | +| `PURCHASABLE_PRODUCT_VARIANT_FRAGMENT` | Selected variant plus `requiresComponents`, fixed bundle `components`, and reverse `groupedBy` bundle relationships | +| `PRODUCT_SELECTION_FRAGMENT` | Modern high-variant and Combined Listing selection fields | +| `PRODUCT_FRAGMENT` | Full product metadata, media, exact variant count, modern selection data, price ranges, taxonomy, collections, and SEO | +| `CART_FRAGMENT` | Ordinary and componentizable cart lines, nested bundle components, line instructions, costs, and delivery groups | +| `CATEGORY_PRODUCT_FRAGMENT` | Lightweight product for listing pages (fewer fields) | Embed fragments in a query with template literal interpolation: `` `${PRODUCT_FRAGMENT} query ...` ``. @@ -102,19 +106,20 @@ export async function getProduct({ cacheLife("max"); cacheTag("products", `product-${handle}`); - const data = await shopifyFetch<{ productByHandle: ShopifyProduct }>({ + const data = await shopifyFetch<{ product: ShopifyProduct | null }>({ operation: "getProductByHandle", query: GET_PRODUCT_BY_HANDLE_QUERY, variables: { handle, + selectedOptions: [], country: getCountryCode(locale), language: getLanguageCode(locale), }, }); - if (!data.productByHandle) return undefined; + if (!data.product) return undefined; - return transformShopifyProductDetails(data.productByHandle); + return transformShopifyProductDetails(data.product); } ``` @@ -127,7 +132,7 @@ Key elements: ## Caching -Read operations use `"use cache"` with `cacheLife("max")` and one or more cache tags when their inputs are stable, such as product detail, collection, menu, sitemap, and recommendation queries. High-cardinality operations that depend on search, filters, sort, or cursors use `"use cache: remote"` instead. Tags follow a hierarchy: +Read operations use `"use cache"` with `cacheLife("max")` and one or more cache tags when their inputs are stable, such as base product detail, collection, menu, sitemap, and recommendation queries. High-cardinality operations that depend on selected options, search, filters, sort, or cursors use `"use cache: remote"` instead. Tags follow a hierarchy: | Tag pattern | Scope | | -------------------------- | ------------------------------------ | @@ -161,6 +166,23 @@ export async function addToCart(lines: CartLineInput[], cartId: string, locale: Shopify returns the full updated cart in every mutation response, so there's no need for a follow-up query. +`addToCart()` accepts Shopify's optional `CartLineInput.parent` relationship for customized bundle and add-on flows. The default PDP only adds ordinary products and fixed bundle parents directly; customized component selection remains app-specific. + +The cart fragment omits deprecated tax and duty amount fields. Shopify finalizes those values at checkout, so the storefront presents a checkout-time tax note instead. + +## Product selection + +The template does not query an exhaustive variants connection. `getProductSelection()` sends selected option values to Shopify and combines: + +- `selectedOrFirstAvailableVariant(selectedOptions:)` +- `adjacentVariants(selectedOptions:)` +- `options.optionValues.firstSelectableVariant` +- `encodedVariantExistence` and `encodedVariantAvailability` + +This avoids a historical `first: 50` cap, scales to Shopify's 2,048-variant product limit, and lets option values navigate across Combined Listing child product handles. `variantsCount.count` is used whenever the exact count is needed. + +Legacy `?variant=` URLs are resolved through a small `node(id:)` query that returns the variant's selected options, then feed the same selection operation. + ## Error handling The client handles errors at two levels: @@ -184,10 +206,11 @@ Set `DEBUG_SHOPIFY=true` to log every request with timing and a variable preview Every operation converts the Shopify response to a domain type before returning. Transforms live in `lib/shopify/transforms/`: -| Transform | Input → Output | -| -------------------------------- | ----------------------------------- | -| `transformShopifyProductDetails` | `ShopifyProduct` → `ProductDetails` | -| `transformShopifyCart` | `ShopifyCart` → `Cart` | +| Transform | Input → Output | +| ---------------------------------- | -------------------------------------------------- | +| `transformShopifyProductDetails` | `ShopifyProduct` → `ProductDetails` | +| `transformShopifyProductSelection` | `ShopifyProductSelection` → `ProductSelectionData` | +| `transformShopifyCart` | `ShopifyCart` → `Cart` | Domain types are defined in `lib/types.ts` and are provider-agnostic. Components import these types, never the Shopify-specific response shapes. This keeps the UI decoupled from the API layer. diff --git a/apps/docs/content/docs/reference/troubleshooting.mdx b/apps/docs/content/docs/reference/troubleshooting.mdx index b86f58cd..8f5cf807 100644 --- a/apps/docs/content/docs/reference/troubleshooting.mdx +++ b/apps/docs/content/docs/reference/troubleshooting.mdx @@ -19,9 +19,11 @@ The cart ID is stored in a `shopify_cartId` HTTP-only cookie with a 7-day expiry ## Variant selection not working -Variant selection uses Shopify's standard `?variant=123` query parameter. If option links change the URL but the page does not update, check that `app/products/[handle]/page.tsx` reads `searchParams.variant` and passes it to the product detail components. +PDP option links use option-name query parameters such as `?Color=Black&Size=Medium`. If option links change the URL but the page does not update, check that `app/products/[handle]/page.tsx` passes those values to `getProductSelection()` and that the query includes `selectedOrFirstAvailableVariant`, `adjacentVariants`, and the encoded variant fields. -Also confirm variant links are generated with `getVariantUrl()` from `lib/product.ts`, which should return `/products/:handle?variant=:variantId` for variant-specific links. +Legacy `?variant=123` links should still work through `getProductVariantSelectedOptions()`. For Combined Listings, confirm the generated option link can change the product handle to the selected variant's owning product. + +If every option appears unavailable, verify the local encoded-variant decoder still matches Shopify's current `v1_` encoding and that `options.optionValues` are queried in Shopify's option order. ## Images not loading diff --git a/apps/docs/content/docs/shopify/index.mdx b/apps/docs/content/docs/shopify/index.mdx index a035641a..0e0cb56d 100644 --- a/apps/docs/content/docs/shopify/index.mdx +++ b/apps/docs/content/docs/shopify/index.mdx @@ -14,14 +14,14 @@ For the API client and query patterns, see [Storefront API](/docs/reference/stor ## Concept mapping -| Shopify concept | Template route | What renders it | -| --------------- | ----------------------- | ----------------------------------------------------------------- | -| Products | `/products/[handle]` | Product detail page with variants, gallery, buy section | -| Collections | `/collections/[handle]` | Product grid with filtering, sorting, pagination | -| Menus | Navigation, footer | Quick links bar (with hover panels), mobile sheet, footer columns | -| Pages | `/pages/[slug]` | Marketing page renderer (requires CMS setup) | -| Search | `/search` | Same grid as collections, driven by query param | -| Cart | `/cart` + overlay | Cart context with optimistic updates | +| Shopify concept | Template route | What renders it | +| --------------- | ----------------------- | ------------------------------------------------------------------ | +| Products | `/products/[handle]` | High-variant and Combined Listing PDP with gallery and buy section | +| Collections | `/collections/[handle]` | Product grid with filtering, sorting, pagination | +| Menus | Navigation, footer | Quick links bar (with hover panels), mobile sheet, footer columns | +| Pages | `/pages/[slug]` | Marketing page renderer (requires CMS setup) | +| Search | `/search` | Same grid as collections, driven by query param | +| Cart | `/cart` + overlay | Optimistic cart with componentized bundle lines | ## Required menus diff --git a/apps/docs/content/docs/shopify/pdp.mdx b/apps/docs/content/docs/shopify/pdp.mdx index 8e48a05f..de60bbf7 100644 --- a/apps/docs/content/docs/shopify/pdp.mdx +++ b/apps/docs/content/docs/shopify/pdp.mdx @@ -16,7 +16,29 @@ The PDP renders any product that has a handle (URL slug) in Shopify. At minimum, - At least one **variant** (even single-option products have a default variant) - A **featured image** and optionally up to 10 media items (images or videos) -The template fetches up to 50 variants per product and 10 media items. +The template fetches up to 10 media items. It does not fetch a capped `variants(first: ...)` connection. + +## Modern product model + +The PDP uses Shopify's Storefront API 2026-04 product-selection fields rather than downloading every variant: + +- `variantsCount` reports the exact number of variants. +- `options.optionValues` provides option values, swatches, and a first selectable variant. +- `encodedVariantExistence` and `encodedVariantAvailability` determine whether each option combination exists and is available. +- `selectedOrFirstAvailableVariant(selectedOptions:)` resolves the selected purchasable variant. +- `adjacentVariants(selectedOptions:)` provides the nearby variants needed to update option state. + +This model supports Shopify's 2,048-variant product limit without truncating the picker at 50 variants. It also supports Combined Listings: an option value can resolve to a variant owned by another product, and the generated option link uses that product's handle. + +## Bundles + +For the selected variant, the PDP queries Shopify's bundle relationships: + +- `components` lists the items in a fixed bundle. +- `groupedBy` lists up to 10 bundles that contain the selected component. +- `requiresComponents` identifies customized bundle parents that cannot be purchased alone. + +Fixed bundles render their components and can be added normally. A customized bundle that requires shopper-selected components is disabled by the generic buy buttons until you add the app-specific bundle configuration UI and send the component lines through `CartLineInput.parent`. ## Metafields @@ -74,9 +96,9 @@ Product recommendations on the PDP are powered by Shopify's recommendation API. ## Key files -| File | Purpose | -| ------------------------------------ | --------------------------------------------------------------- | -| `lib/shopify/fragments.ts` | `PRODUCT_FRAGMENT` and `METAFIELD_FRAGMENT` | -| `lib/shopify/transforms/product.ts` | Metafield label mapping and product transform | -| `lib/shopify/operations/products.ts` | Product fetch operations with caching | -| `lib/product.ts` | Variant resolution, swatch detection, and option URL generation | +| File | Purpose | +| ------------------------------------ | -------------------------------------------------- | +| `lib/shopify/fragments.ts` | `PRODUCT_FRAGMENT` and `METAFIELD_FRAGMENT` | +| `lib/shopify/transforms/product.ts` | Metafield label mapping and product transform | +| `lib/shopify/operations/products.ts` | Product fetch operations with caching | +| `lib/product.ts` | Selection composition and color-image partitioning | diff --git a/apps/docs/content/docs/skills/enable-shopify-markets.mdx b/apps/docs/content/docs/skills/enable-shopify-markets.mdx index c84ab92f..e5cc7750 100644 --- a/apps/docs/content/docs/skills/enable-shopify-markets.mdx +++ b/apps/docs/content/docs/skills/enable-shopify-markets.mdx @@ -358,7 +358,7 @@ export function proxy(request: NextRequest) { } ``` -> **Note:** Product variant selection stays on Shopify's standard `?variant=` query parameter. The built-in content negotiation rewrite in `next.config.ts` handles markdown negotiation automatically — no proxy.ts changes needed. +> **Note:** Product selection stays in query parameters: option-name params are canonical and legacy `?variant=` links remain supported. The built-in content negotiation rewrite in `next.config.ts` handles markdown negotiation automatically — no proxy.ts changes needed. --- @@ -576,7 +576,7 @@ After completing all steps, verify the implementation: - Locale-prefixed URL works (e.g., `http://localhost:3000/de-DE/products/technest-smart-speaker-pro-jk0c`) - Product prices render in the correct currency for each locale 3. **Locale selector**: Confirm the selector appears in the megamenu and switching locales changes the URL + cart currency -4. **Variants**: Confirm product variant links preserve `?variant=` across locale-prefixed URLs +4. **Variants**: Confirm option-name query params and legacy `?variant=` links survive locale-prefixed navigation 5. **SEO**: Check that page metadata includes `hreflang` alternates for all enabled locales 6. **Sitemap**: Visit `/sitemap.xml` and confirm per-locale entries diff --git a/apps/template/app/products/[handle]/page.tsx b/apps/template/app/products/[handle]/page.tsx index 3ebc8e6e..e825bdad 100644 --- a/apps/template/app/products/[handle]/page.tsx +++ b/apps/template/app/products/[handle]/page.tsx @@ -7,9 +7,14 @@ import { Container } from "@/components/ui/container"; import { Page } from "@/components/ui/page"; import { Sections } from "@/components/ui/sections"; import { getLocale } from "@/lib/params"; -import { computeSelection } from "@/lib/product"; +import { computeSelection, getSelectedOptionsFromSearchParams } from "@/lib/product"; import { buildAlternates, buildOpenGraph } from "@/lib/seo"; -import { getCatalogProducts, getProduct } from "@/lib/shopify/operations/products"; +import { + getCatalogProducts, + getProduct, + getProductSelection, + getProductVariantSelectedOptions, +} from "@/lib/shopify/operations/products"; const PLACEHOLDER_HANDLE = "__placeholder__"; @@ -91,10 +96,26 @@ export default async function ProductPage({ return product; }); - const variantIdPromise = searchParams.then((sp) => sp?.variant as string | undefined); + const selectedOptionsPromise = Promise.all([productPromise, searchParams]).then( + async ([product, sp]) => { + const selectedOptions = getSelectedOptionsFromSearchParams(product.options, sp); + if (selectedOptions.length > 0) return selectedOptions; - const selectionPromise = Promise.all([productPromise, variantIdPromise]).then( - ([product, variantId]) => computeSelection(product, variantId), + const rawVariantId = sp.variant; + const variantId = Array.isArray(rawVariantId) ? rawVariantId[0] : rawVariantId; + return variantId ? getProductVariantSelectedOptions({ variantId, locale }) : []; + }, + ); + + const selectionDataPromise = Promise.all([handlePromise, selectedOptionsPromise]).then( + ([handle, selectedOptions]) => + selectedOptions.length > 0 + ? getProductSelection({ handle, selectedOptions, locale }) + : undefined, + ); + + const selectionPromise = Promise.all([productPromise, selectionDataPromise]).then( + ([product, selectionData]) => computeSelection(product, selectionData), ); return ( diff --git a/apps/template/components/agent/agent-panel.tsx b/apps/template/components/agent/agent-panel.tsx index 262dabdc..7348725d 100644 --- a/apps/template/components/agent/agent-panel.tsx +++ b/apps/template/components/agent/agent-panel.tsx @@ -145,6 +145,7 @@ function getToolStepStatus(state: string): "complete" | "active" | "pending" { const TOOL_METADATA: Record = { searchProducts: { label: "Searching products", icon: SearchIcon }, getProductDetails: { label: "Looking up product details", icon: PackageIcon }, + resolveProductVariant: { label: "Resolving product options", icon: PackageIcon }, getProductRecommendations: { label: "Finding recommendations", icon: SparklesIcon, diff --git a/apps/template/components/agent/registry.tsx b/apps/template/components/agent/registry.tsx index 71a06b7a..7dcb0a29 100644 --- a/apps/template/components/agent/registry.tsx +++ b/apps/template/components/agent/registry.tsx @@ -80,7 +80,6 @@ export const { registry } = defineRegistry(catalog, { const locale = useLocale(); const tCart = useTranslations("cart"); const subtotal = parsePriceString(props.subtotal); - const tax = parsePriceString(props.tax); const total = parsePriceString(props.total); return ( @@ -119,6 +118,21 @@ export const { registry } = defineRegistry(catalog, { {item.options} )} Qty: {item.quantity} + {item.components.length > 0 && ( +
+ + {tCart("bundleIncludes")} + + {item.components.map((component) => ( + + {component.productTitle} x{component.quantity} + + ))} +
+ )} -
- Tax - -
Total
+

{tCart("taxesAndShippingNote")}

({ id: `optimistic-${variantId}`, quantity: qty, + canRemove: true, + canUpdateQuantity: true, cost: { totalAmount: { amount: (parseFloat(info.price.amount) * qty).toString(), currencyCode: info.price.currencyCode, }, }, + components: [], merchandise: { id: variantId, title: info.variantTitle, diff --git a/apps/template/components/cart/overlay-item.tsx b/apps/template/components/cart/overlay-item.tsx index c8339a35..f7ab46fa 100644 --- a/apps/template/components/cart/overlay-item.tsx +++ b/apps/template/components/cart/overlay-item.tsx @@ -63,6 +63,23 @@ export function OverlayItem({ item, locale }: OverlayItemProps) { {item.merchandise.selectedOptions.map((option) => option.value).join(" / ")}

)} + + {item.components.length > 0 && ( +
+

{t("bundleIncludes")}

+
    + {item.components.map((component) => ( +
  • + {component.merchandise.product.title} + x{component.quantity} +
  • + ))} +
+
+ )}
@@ -72,7 +89,7 @@ export function OverlayItem({ item, locale }: OverlayItemProps) { size="icon" className="size-7 rounded-full" onClick={() => updateItemOptimistic(item.id || "", quantity - 1)} - disabled={quantity === 1} + disabled={!item.canUpdateQuantity || quantity === 1} aria-label={t("decreaseQuantity")} > @@ -88,7 +105,7 @@ export function OverlayItem({ item, locale }: OverlayItemProps) { size="icon" className="size-7 rounded-full" onClick={() => updateItemOptimistic(item.id || "", quantity + 1)} - disabled={quantity === 99} + disabled={!item.canUpdateQuantity || quantity === 99} aria-label={t("increaseQuantity")} > @@ -100,6 +117,7 @@ export function OverlayItem({ item, locale }: OverlayItemProps) { size="icon" className="size-7 text-muted-foreground hover:text-foreground" onClick={() => updateItemOptimistic(item.id || "", 0)} + disabled={!item.canRemove} aria-label={t("removeItem")} > diff --git a/apps/template/components/product-detail/bundle-components.tsx b/apps/template/components/product-detail/bundle-components.tsx new file mode 100644 index 00000000..a64018f5 --- /dev/null +++ b/apps/template/components/product-detail/bundle-components.tsx @@ -0,0 +1,78 @@ +import Image from "next/image"; +import Link from "next/link"; + +import type { ProductVariantComponent, ProductVariantReference } from "@/lib/types"; + +interface BundleComponentsProps { + components: ProductVariantComponent[]; + title: string; +} + +export function BundleComponents({ components, title }: BundleComponentsProps) { + if (components.length === 0) return null; + + return ( + ({ quantity, variant }))} + title={title} + /> + ); +} + +interface BundleParentsProps { + variants: ProductVariantReference[]; + title: string; +} + +export function BundleParents({ variants, title }: BundleParentsProps) { + if (variants.length === 0) return null; + + return ({ variant }))} title={title} />; +} + +interface BundleProductListProps { + items: Array<{ quantity?: number; variant: ProductVariantReference }>; + title: string; +} + +function BundleProductList({ items, title }: BundleProductListProps) { + return ( +
+

{title}

+
    + {items.map(({ quantity, variant }) => { + const image = variant.image ?? variant.product.featuredImage; + return ( +
  • + + {image ? ( + {image.altText + ) : null} + + + {variant.product.title} + + + {variant.title} + + + {quantity ? ( + x{quantity} + ) : null} + +
  • + ); + })} +
+
+ ); +} diff --git a/apps/template/components/product-detail/buy-buttons.tsx b/apps/template/components/product-detail/buy-buttons.tsx index 02820779..5f16adfa 100644 --- a/apps/template/components/product-detail/buy-buttons.tsx +++ b/apps/template/components/product-detail/buy-buttons.tsx @@ -77,11 +77,14 @@ export function BuyButtons({ return null; } + const requiresBundleConfiguration = + selectedVariant.requiresComponents && selectedVariant.components.length === 0; const isOutOfStock = !selectedVariant.availableForSale; const getButtonText = () => { if (pendingQuantity > 0) return t("addingQuantity", { quantity: String(pendingQuantity) }); if (isAddingToCart) return t("addingToCart"); + if (requiresBundleConfiguration) return t("bundleConfigurationRequired"); if (isOutOfStock) return t("outOfStock"); return t("addToCart"); }; @@ -94,7 +97,7 @@ export function BuyButtons({ "flex flex-1 items-center justify-center gap-1.5 rounded-lg h-12 bg-shop text-white transition-all hover:bg-shop/85 disabled:pointer-events-none disabled:opacity-50", !availableForSale && "invisible", )} - disabled={isOutOfStock || isBuyingNow} + disabled={isOutOfStock || isBuyingNow || requiresBundleConfiguration} onClick={handleBuyNow} > {isBuyingNow ? ( @@ -108,7 +111,7 @@ export function BuyButtons({