Skip to content

Commit 1991dbb

Browse files
committed
fix: add back eager pricing and variants
1 parent d3d86b1 commit 1991dbb

8 files changed

Lines changed: 450 additions & 45 deletions

File tree

apps/docs/content/docs/anatomy/pages/pdp.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ The PDP runs under Next.js 16 Cache Components. Base product data is fetched thr
1414

1515
The dynamic input is the selected option values in `searchParams`. The route starts the cached base-product read and a compact `getProductSelection()` request in parallel. Selection responses use `cache: "no-store"` so variant combinations do not consume persistent runtime-cache entries. `ProductDetailSection` receives both as promises and keeps variant-dependent regions in Suspense boundaries without creating a request waterfall.
1616

17+
Suspense streaming is reserved for regions a selection can actually change. The base product carries exact signals that let everything else render in the prerendered shell: when `priceRange` and `compareAtPriceRange` bounds are equal (`hasUniformPricing`) the price renders eagerly, when `variantsCount` is `1` the option pickers and buy buttons render eagerly, and when `encodedVariantExistence` equals `encodedVariantAvailability` (`allVariantsInStock`) the buy-button fallback renders with real labels instead of placeholders. A simple product therefore ships its price and buy controls in the static HTML; only products with genuinely variant-dependent prices or stock stream those regions.
18+
1719
## Variant selection
1820

1921
Variants are selected via option-name query parameters:

apps/template/components/product-detail/product-detail-section.tsx

Lines changed: 111 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,19 @@ import {
1515
} from "@/components/product-detail/product-media";
1616
import { ProductPrice } from "@/components/product-detail/product-price";
1717
import { ProductSchema } from "@/components/product-detail/schema";
18+
import { ShopLogo } from "@/components/product-detail/shop-logo";
1819
import { BreadcrumbSchema } from "@/components/schema/breadcrumb-schema";
1920
import { Skeleton } from "@/components/ui/skeleton";
2021
import { siteConfig } from "@/lib/config";
2122
import type { Locale } from "@/lib/i18n";
22-
import { getSharedImages, hasColorImagePartitioning, type ProductSelection } from "@/lib/product";
23-
import type { ProductDetails } from "@/lib/types";
23+
import {
24+
computeSelection,
25+
getSharedImages,
26+
hasColorImagePartitioning,
27+
type ProductSelection,
28+
} from "@/lib/product";
29+
import type { ProductDetails, ProductVariant } from "@/lib/types";
30+
import { cn } from "@/lib/utils";
2431

2532
export function ProductDetailSection({
2633
productPromise,
@@ -161,39 +168,86 @@ async function ProductInfoArea({
161168
locale: Locale;
162169
}) {
163170
const { options, handle, title, featuredImage, descriptionHtml, availableForSale } = product;
171+
// Selection can only change these regions when the data proves it can — otherwise
172+
// they render eagerly so price and buy controls stay in the prerendered shell.
173+
const singleVariant = product.variantsCount === 1;
174+
const eagerSelection = singleVariant ? computeSelection(product) : null;
175+
const allInStock = product.allVariantsInStock && availableForSale;
176+
const uniformStock = product.allVariantsInStock || !availableForSale;
177+
const t = uniformStock && !singleVariant ? await getTranslations("product") : null;
164178

165179
return (
166180
<div className="grid gap-10 lg:sticky lg:top-20 lg:col-span-4">
167181
<div data-slot="product-info-header">
168182
<h1 className="font-semibold text-foreground tracking-tight text-3xl">{title}</h1>
169-
<Suspense fallback={<div className="h-6" aria-hidden />}>
170-
<ResolvedProductPrice selectionPromise={selectionPromise} locale={locale} />
171-
</Suspense>
183+
{product.hasUniformPricing ? (
184+
<ProductPrice
185+
amount={product.price.amount}
186+
currencyCode={product.price.currencyCode}
187+
compareAtAmount={product.compareAtPrice?.amount}
188+
locale={locale}
189+
/>
190+
) : (
191+
<Suspense fallback={<div className="h-6" aria-hidden />}>
192+
<ResolvedProductPrice selectionPromise={selectionPromise} locale={locale} />
193+
</Suspense>
194+
)}
172195
</div>
173196

174-
<Suspense fallback={<ProductInfoOptions options={options} hideImages />}>
175-
<ResolvedProductInfoOptions selectionPromise={selectionPromise} />
176-
</Suspense>
197+
{eagerSelection ? (
198+
<ProductInfoOptions options={eagerSelection.options} />
199+
) : (
200+
<Suspense fallback={<ProductInfoOptions options={options} hideImages />}>
201+
<ResolvedProductInfoOptions selectionPromise={selectionPromise} />
202+
</Suspense>
203+
)}
177204

178-
<Suspense fallback={null}>
179-
<ResolvedBundleRelationships selectionPromise={selectionPromise} />
180-
</Suspense>
205+
{eagerSelection ? (
206+
<BundleRelationships selectedVariant={eagerSelection.selectedVariant} />
207+
) : (
208+
<Suspense fallback={null}>
209+
<ResolvedBundleRelationships selectionPromise={selectionPromise} />
210+
</Suspense>
211+
)}
181212

182-
<Suspense fallback={<BuyButtonsFallback />}>
183-
<ResolvedBuyButtons
213+
{eagerSelection ? (
214+
<BuyButtons
215+
selectedVariant={toBuyButtonVariant(eagerSelection.selectedVariant)}
184216
title={title}
185217
handle={handle}
186218
featuredImage={featuredImage}
187219
availableForSale={availableForSale}
188-
selectionPromise={selectionPromise}
189220
/>
190-
</Suspense>
221+
) : (
222+
<Suspense fallback={<BuyButtonsFallback t={t} allInStock={allInStock} />}>
223+
<ResolvedBuyButtons
224+
title={title}
225+
handle={handle}
226+
featuredImage={featuredImage}
227+
availableForSale={availableForSale}
228+
selectionPromise={selectionPromise}
229+
/>
230+
</Suspense>
231+
)}
191232

192233
<ProductInfoDescription descriptionHtml={descriptionHtml} />
193234
</div>
194235
);
195236
}
196237

238+
function toBuyButtonVariant(variant: ProductVariant | undefined): BuyButtonVariant | undefined {
239+
if (!variant) return undefined;
240+
return {
241+
id: variant.id,
242+
title: variant.title,
243+
availableForSale: variant.availableForSale,
244+
image: variant.image,
245+
price: variant.price,
246+
requiresBundleConfiguration: variant.requiresComponents && variant.components.length === 0,
247+
selectedOptions: variant.selectedOptions,
248+
};
249+
}
250+
197251
async function ResolvedProductPrice({
198252
selectionPromise,
199253
locale,
@@ -227,11 +281,20 @@ async function ResolvedBundleRelationships({
227281
}: {
228282
selectionPromise: Promise<ProductSelection>;
229283
}) {
230-
const [{ selectedVariant }, t] = await Promise.all([
231-
selectionPromise,
232-
getTranslations("product"),
233-
]);
284+
const { selectedVariant } = await selectionPromise;
285+
return <BundleRelationships selectedVariant={selectedVariant} />;
286+
}
287+
288+
async function BundleRelationships({
289+
selectedVariant,
290+
}: {
291+
selectedVariant: ProductVariant | undefined;
292+
}) {
234293
if (!selectedVariant) return null;
294+
if (selectedVariant.components.length === 0 && selectedVariant.bundleParents.length === 0) {
295+
return null;
296+
}
297+
const t = await getTranslations("product");
235298
return (
236299
<>
237300
<BundleComponents components={selectedVariant.components} title={t("bundleIncludes")} />
@@ -254,22 +317,10 @@ async function ResolvedBuyButtons({
254317
selectionPromise: Promise<ProductSelection>;
255318
}) {
256319
const { selectedVariant } = await selectionPromise;
257-
const buyButtonVariant: BuyButtonVariant | undefined = selectedVariant
258-
? {
259-
id: selectedVariant.id,
260-
title: selectedVariant.title,
261-
availableForSale: selectedVariant.availableForSale,
262-
image: selectedVariant.image,
263-
price: selectedVariant.price,
264-
requiresBundleConfiguration:
265-
selectedVariant.requiresComponents && selectedVariant.components.length === 0,
266-
selectedOptions: selectedVariant.selectedOptions,
267-
}
268-
: undefined;
269320

270321
return (
271322
<BuyButtons
272-
selectedVariant={buyButtonVariant}
323+
selectedVariant={toBuyButtonVariant(selectedVariant)}
273324
title={title}
274325
handle={handle}
275326
featuredImage={featuredImage}
@@ -278,11 +329,35 @@ async function ResolvedBuyButtons({
278329
);
279330
}
280331

281-
function BuyButtonsFallback() {
332+
function BuyButtonsFallback({
333+
t,
334+
allInStock,
335+
}: {
336+
t: Awaited<ReturnType<typeof getTranslations<"product">>> | null;
337+
allInStock: boolean;
338+
}) {
339+
if (!t) {
340+
return (
341+
<div className="grid grid-cols-2 gap-2.5">
342+
<div className="h-12 rounded-lg bg-shop" />
343+
<div className="h-12 rounded-lg bg-primary" />
344+
</div>
345+
);
346+
}
282347
return (
283-
<div className="grid grid-cols-2 gap-2">
284-
<div className="h-12 rounded-lg bg-shop" />
285-
<div className="h-12 rounded-lg bg-primary" />
348+
<div className="grid grid-cols-2 gap-2.5">
349+
<div
350+
className={cn(
351+
"flex items-center justify-center gap-1.5 rounded-lg h-12 bg-shop text-white",
352+
!allInStock && "invisible",
353+
)}
354+
>
355+
<span className="text-sm font-medium">{t("buyWithShop")}</span>
356+
<ShopLogo className="h-4 w-auto" />
357+
</div>
358+
<div className="flex items-center justify-center rounded-lg h-12 bg-primary text-primary-foreground text-sm font-medium">
359+
{allInStock ? t("addToCart") : t("outOfStock")}
360+
</div>
286361
</div>
287362
);
288363
}

apps/template/lib/shopify/transforms/product.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type {
1515
Video,
1616
} from "@/lib/types";
1717

18-
import { encodedVariantSet } from "../variant-encoding";
18+
import { allEncodedVariantsAvailable, encodedVariantSet } from "../variant-encoding";
1919

2020
interface ShopifyImage {
2121
url: string;
@@ -501,6 +501,13 @@ export function transformShopifyProductCard(product: ShopifyProductCard): Produc
501501
};
502502
}
503503

504+
function hasUniformPriceRange(product: ShopifyProduct): boolean {
505+
const { compareAtPriceRange, priceRange } = product;
506+
if (priceRange.minVariantPrice.amount !== priceRange.maxVariantPrice.amount) return false;
507+
if (!compareAtPriceRange) return true;
508+
return compareAtPriceRange.minVariantPrice.amount === compareAtPriceRange.maxVariantPrice.amount;
509+
}
510+
504511
export function transformShopifyProductDetails(product: ShopifyProduct): ProductDetails {
505512
const selection = transformProductSelectionOrFallback(product);
506513
const defaultVariant = selection.selectedVariant;
@@ -520,6 +527,11 @@ export function transformShopifyProductDetails(product: ShopifyProduct): Product
520527
...extractMediaFromProduct(product),
521528
variants: selection.variants,
522529
variantsCount: product.variantsCount.count,
530+
allVariantsInStock: allEncodedVariantsAvailable(
531+
product.encodedVariantExistence,
532+
product.encodedVariantAvailability,
533+
),
534+
hasUniformPricing: hasUniformPriceRange(product),
523535
options: selection.options,
524536
tags: product.tags,
525537
seo: {

apps/template/lib/shopify/variant-encoding.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import assert from "node:assert/strict";
22
import test from "node:test";
33

4-
import { decodeEncodedVariant, encodedVariantSet } from "./variant-encoding.ts";
4+
import {
5+
allEncodedVariantsAvailable,
6+
decodeEncodedVariant,
7+
encodedVariantSet,
8+
} from "./variant-encoding.ts";
59

610
test("decodes nested option combinations", () => {
711
assert.deepEqual(decodeEncodedVariant("v1_0:0:0,1:0-1,,1:0:0-1,1:1,,2:0:1,1:0,,"), [
@@ -36,3 +40,11 @@ test("adds prefixes used for partial option availability", () => {
3640
test("rejects unsupported encoding versions", () => {
3741
assert.throws(() => decodeEncodedVariant("v2_0"), /Unsupported option value encoding/);
3842
});
43+
44+
test("reports every existing variant available when the encodings match", () => {
45+
assert.equal(allEncodedVariantsAvailable("v1_0:0-1,1:0,", "v1_0:0-1,1:0,"), true);
46+
});
47+
48+
test("reports unavailable variants when availability is a subset of existence", () => {
49+
assert.equal(allEncodedVariantsAvailable("v1_0:0-1,1:0,", "v1_0:0,1:0,"), false);
50+
});

apps/template/lib/shopify/variant-encoding.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,12 @@ export function encodedVariantSet(encodedVariantField: string): Set<string> {
7171

7272
return combinations;
7373
}
74+
75+
export function allEncodedVariantsAvailable(
76+
encodedVariantExistence: string,
77+
encodedVariantAvailability: string,
78+
): boolean {
79+
const existence = encodedVariantSet(encodedVariantExistence);
80+
const availability = encodedVariantSet(encodedVariantAvailability);
81+
return existence.size === availability.size && [...existence].every((c) => availability.has(c));
82+
}

apps/template/lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,14 @@ export interface ProductCard {
5555
}
5656

5757
export interface ProductDetails extends ProductCard {
58+
allVariantsInStock: boolean;
5859
category?: Category | null;
5960
categoryId?: string;
6061
collectionHandles: string[];
6162
currencyCode: string;
6263
description: string;
6364
descriptionHtml: string;
65+
hasUniformPricing: boolean;
6466
images: Image[];
6567
manufacturerName: string;
6668
metafields?: Metafield[];

packages/plugin/template-rollout-log/2026-06-05-modern-shopify-product-model.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ Shopify's bundle model adds relationships at both product-variant and cart-line
7171
## Adoption notes
7272

7373
- `ProductDetails.variants` is now a representative selectable set, not an exhaustive export. Use `ProductDetails.variantsCount` for the exact count and `getProductSelection()` to resolve a choice.
74-
- Do not infer uniform pricing, uniform stock, or single-variant status from `ProductDetails.variants`; the old `hasUniformPricing()` and `hasUniformStock()` helpers are removed.
74+
- Do not infer uniform pricing, uniform stock, or single-variant status from `ProductDetails.variants`. Use the exact signals instead: `ProductDetails.hasUniformPricing` (equal `priceRange` and `compareAtPriceRange` bounds), `ProductDetails.allVariantsInStock` (`encodedVariantExistence` equals `encodedVariantAvailability`), and `ProductDetails.variantsCount === 1`.
75+
- Keep the eager PDP paths these signals enable: uniform-price products render the price in the prerendered shell, single-variant products render options and buy buttons eagerly, and uniform-stock products render a labeled buy-button fallback. Selection-dependent Suspense streaming is only for regions selection can actually change.
7576
- `ProductVariant` gains `productHandle`, `requiresComponents`, `components`, and `bundleParents`.
7677
- `CartLine` gains nested `components`, `canRemove`, and `canUpdateQuantity`.
7778
- `Cart.cost.totalTaxAmount` is removed because Shopify deprecated it in Storefront API 2025-01.
@@ -89,9 +90,10 @@ Shopify's bundle model adds relationships at both product-variant and cart-line
8990
3. Change a Combined Listing option and confirm the URL can move to the selected child product handle.
9091
4. Open a Liquid `/products/:handle?variant=:id` link and confirm it permanently redirects to the matching option-name URL.
9192
5. Confirm a selected-option PDP starts the base-product and selection operations in parallel and that only the base product uses persistent caching.
92-
6. Open a fixed bundle PDP and confirm its component products render and the bundle can be added.
93-
7. Open a component product and confirm bundles returned by `groupedBy` render.
94-
8. Confirm bundle components remain grouped in the cart and line controls honor Shopify's instructions.
95-
9. Confirm a customized bundle parent without selected components cannot be added directly.
96-
10. Ask the shopping agent to select options on a high-variant product and confirm it calls `resolveProductVariant` before `addToCart`.
97-
11. Run `pnpm --filter template lint`, `pnpm --filter template test`, `pnpm --filter template build`, `pnpm --filter docs lint`, and `pnpm --filter docs build`.
93+
6. Confirm a uniform-price product renders its price in the prerendered HTML, and a single-variant product renders its buy buttons there.
94+
7. Open a fixed bundle PDP and confirm its component products render and the bundle can be added.
95+
8. Open a component product and confirm bundles returned by `groupedBy` render.
96+
9. Confirm bundle components remain grouped in the cart and line controls honor Shopify's instructions.
97+
10. Confirm a customized bundle parent without selected components cannot be added directly.
98+
11. Ask the shopping agent to select options on a high-variant product and confirm it calls `resolveProductVariant` before `addToCart`.
99+
12. Run `pnpm --filter template lint`, `pnpm --filter template test`, `pnpm --filter template build`, `pnpm --filter docs lint`, and `pnpm --filter docs build`.

0 commit comments

Comments
 (0)