Optional updates for the product route and product form to handle combined listing and 2000 variant limit. (#2659) by @wizardlyhel
- Update your SFAPI product query to bring in the new query fields:
const PRODUCT_FRAGMENT = `#graphql
fragment Product on Product {
id
title
vendor
handle
descriptionHtml
description
+ encodedVariantExistence
+ encodedVariantAvailability
options {
name
optionValues {
name
+ firstSelectableVariant {
+ ...ProductVariant
+ }
+ swatch {
+ color
+ image {
+ previewImage {
+ url
+ }
+ }
+ }
}
}
- selectedVariant: selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) {
+ selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) {
+ ...ProductVariant
+ }
+ adjacentVariants (selectedOptions: $selectedOptions) {
+ ...ProductVariant
+ }
- variants(first: 1) {
- nodes {
- ...ProductVariant
- }
- }
seo {
description
title
}
}
${PRODUCT_VARIANT_FRAGMENT}
` as const;
- Update
loadDeferredData
function. We no longer need to load in all the variants. You can also remove VARIANTS_QUERY
variable.
function loadDeferredData({context, params}: LoaderFunctionArgs) {
+
+
-
-
-
-
-
- const variants = context.storefront
- .query(VARIANTS_QUERY, {
- variables: {handle: params.handle!},
- })
- .catch((error) => {
-
- console.error(error);
- return null;
- });
+ return {}
- return {
- variants,
- };
}
- Remove the redirect logic in the
loadCriticalData
function and completely remove redirectToFirstVariant
function
async function loadCriticalData({
context,
params,
request,
}: LoaderFunctionArgs) {
const {handle} = params;
const {storefront} = context;
if (!handle) {
throw new Error('Expected product handle to be defined');
}
const [{product}] = await Promise.all([
storefront.query(PRODUCT_QUERY, {
variables: {handle, selectedOptions: getSelectedProductOptions(request)},
}),
]);
if (!product?.id) {
throw new Response(null, {status: 404});
}
- const firstVariant = product.variants.nodes[0];
- const firstVariantIsDefault = Boolean(
- firstVariant.selectedOptions.find(
- (option: SelectedOption) =>
- option.name === 'Title' && option.value === 'Default Title',
- ),
- );
- if (firstVariantIsDefault) {
- product.selectedVariant = firstVariant;
- } else {
-
-
- if (!product.selectedVariant) {
- throw redirectToFirstVariant({product, request});
- }
- }
return {
product,
};
}
...
- function redirectToFirstVariant({
- product,
- request,
- }: {
- product: ProductFragment;
- request: Request;
- }) {
- ...
- }
- Update the
Product
component to use the new data fields.
import {
getSelectedProductOptions,
Analytics,
useOptimisticVariant,
+ getAdjacentAndFirstAvailableVariants,
} from '@shopify/hydrogen';
export default function Product() {
+ const {product} = useLoaderData<typeof loader>();
- const {product, variants} = useLoaderData<typeof loader>();
+
+ const selectedVariant = useOptimisticVariant(
+ product.selectedOrFirstAvailableVariant,
+ getAdjacentAndFirstAvailableVariants(product),
+ );
- const selectedVariant = useOptimisticVariant(
- product.selectedVariant,
- variants,
- );
- Handle missing search query param in url from selecting a first variant
import {
getSelectedProductOptions,
Analytics,
useOptimisticVariant,
getAdjacentAndFirstAvailableVariants,
+ useSelectedOptionInUrlParam,
} from '@shopify/hydrogen';
export default function Product() {
const {product} = useLoaderData<typeof loader>();
const selectedVariant = useOptimisticVariant(
product.selectedOrFirstAvailableVariant,
getAdjacentAndFirstAvailableVariants(product),
);
+
+
+ useSelectedOptionInUrlParam(selectedVariant.selectedOptions);
- Get the product options array using
getProductOptions
import {
getSelectedProductOptions,
Analytics,
useOptimisticVariant,
+ getProductOptions,
getAdjacentAndFirstAvailableVariants,
useSelectedOptionInUrlParam,
} from '@shopify/hydrogen';
export default function Product() {
const {product} = useLoaderData<typeof loader>();
const selectedVariant = useOptimisticVariant(
product.selectedOrFirstAvailableVariant,
getAdjacentAndFirstAvailableVariants(product),
);
useSelectedOptionInUrlParam(selectedVariant.selectedOptions);
+
+ const productOptions = getProductOptions({
+ ...product,
+ selectedOrFirstAvailableVariant: selectedVariant,
+ });
- Remove the
Await
and Suspense
from the ProductForm
. We no longer have any queries that we need to wait for.
export default function Product() {
...
return (
...
+ <ProductForm
+ productOptions={productOptions}
+ selectedVariant={selectedVariant}
+ />
- <Suspense
- fallback={
- <ProductForm
- product={product}
- selectedVariant={selectedVariant}
- variants={[]}
- />
- }
- >
- <Await
- errorElement="There was a problem loading product variants"
- resolve={variants}
- >
- {(data) => (
- <ProductForm
- product={product}
- selectedVariant={selectedVariant}
- variants={data?.product?.variants.nodes || []}
- />
- )}
- </Await>
- </Suspense>
- Update the
ProductForm
component.
import { Link, useNavigate } from "@remix-run/react";
import { type MappedProductOptions } from "@shopify/hydrogen";
import type {
Maybe,
ProductOptionValueSwatch,
} from "@shopify/hydrogen/storefront-api-types";
import { AddToCartButton } from "./AddToCartButton";
import { useAside } from "./Aside";
import type { ProductFragment } from "storefrontapi.generated";
export function ProductForm({
productOptions,
selectedVariant,
}: {
productOptions: MappedProductOptions[];
selectedVariant: ProductFragment["selectedOrFirstAvailableVariant"];
}) {
const navigate = useNavigate();
const { open } = useAside();
return (
<div className="product-form">
{productOptions.map((option) => (
<div className="product-options" key={option.name}>
<h5>{option.name}</h5>
<div className="product-options-grid">
{option.optionValues.map((value) => {
const {
name,
handle,
variantUriQuery,
selected,
available,
exists,
isDifferentProduct,
swatch,
} = value;
if (isDifferentProduct) {
// SEO
// When the variant is a combined listing child product
// that leads to a different url, we need to render it
// as an anchor tag
return (
<Link
className="product-options-item"
key={option.name + name}
prefetch="intent"
preventScrollReset
replace
to={`/products/${handle}?${variantUriQuery}`}
style={{
border: selected
? "1px solid black"
: "1px solid transparent",
opacity: available ? 1 : 0.3,
}}
>
<ProductOptionSwatch swatch={swatch} name={name} />
</Link>
);
} else {
// SEO
// When the variant is an update to the search param,
// render it as a button with javascript navigating to
// the variant so that SEO bots do not index these as
// duplicated links
return (
<button
type="button"
className={`product-options-item${
exists && !selected ? " link" : ""
}`}
key={option.name + name}
style={{
border: selected
? "1px solid black"
: "1px solid transparent",
opacity: available ? 1 : 0.3,
}}
disabled={!exists}
onClick={() => {
if (!selected) {
navigate(`?${variantUriQuery}`, {
replace: true,
});
}
}}
>
<ProductOptionSwatch swatch={swatch} name={name} />
</button>
);
}
})}
</div>
<br />
</div>
))}
<AddToCartButton
disabled={!selectedVariant || !selectedVariant.availableForSale}
onClick={() => {
open("cart");
}}
lines={
selectedVariant
? [
{
merchandiseId: selectedVariant.id,
quantity: 1,
selectedVariant,
},
]
: []
}
>
{selectedVariant?.availableForSale ? "Add to cart" : "Sold out"}
</AddToCartButton>
</div>
);
}
function ProductOptionSwatch({
swatch,
name,
}: {
swatch?: Maybe<ProductOptionValueSwatch> | undefined;
name: string;
}) {
const image = swatch?.image?.previewImage?.url;
const color = swatch?.color;
if (!image && !color) return name;
return (
<div
aria-label={name}
className="product-option-label-swatch"
style={{
backgroundColor: color || "transparent",
}}
>
{!!image && <img src={image} alt={name} />}
</div>
);
}
- Update
app.css
+ /*
+ * --------------------------------------------------
+ * Non anchor links
+ * --------------------------------------------------
+ */
+ .link:hover {
+ text-decoration: underline;
+ cursor: pointer;
+ }
...
- .product-options-item {
+ .product-options-item,
+ .product-options-item:disabled {
+ padding: 0.25rem 0.5rem;
+ background-color: transparent;
+ font-size: 1rem;
+ font-family: inherit;
+ }
+ .product-option-label-swatch {
+ width: 1.25rem;
+ height: 1.25rem;
+ margin: 0.25rem 0;
+ }
+ .product-option-label-swatch img {
+ width: 100%;
+ }
- Update
lib/variants.ts
Make useVariantUrl
and getVariantUrl
flexible to supplying a selected option param
export function useVariantUrl(
handle: string,
- selectedOptions: SelectedOption[],
+ selectedOptions?: SelectedOption[],
) {
const {pathname} = useLocation();
return useMemo(() => {
return getVariantUrl({
handle,
pathname,
searchParams: new URLSearchParams(),
selectedOptions,
});
}, [handle, selectedOptions, pathname]);
}
export function getVariantUrl({
handle,
pathname,
searchParams,
selectedOptions,
}: {
handle: string;
pathname: string;
searchParams: URLSearchParams;
- selectedOptions: SelectedOption[];
+ selectedOptions?: SelectedOption[],
}) {
const match = /(\/[a-zA-Z]{2}-[a-zA-Z]{2}\/)/g.exec(pathname);
const isLocalePathname = match && match.length > 0;
const path = isLocalePathname
? `${match![0]}products/${handle}`
: `/products/${handle}`;
- selectedOptions.forEach((option) => {
+ selectedOptions?.forEach((option) => {
searchParams.set(option.name, option.value);
});
- Update
routes/collections.$handle.tsx
We no longer need to query for the variants since product route can efficiently
obtain the first available variants. Update the code to reflect that:
const PRODUCT_ITEM_FRAGMENT = `#graphql
fragment MoneyProductItem on MoneyV2 {
amount
currencyCode
}
fragment ProductItem on Product {
id
handle
title
featuredImage {
id
altText
url
width
height
}
priceRange {
minVariantPrice {
...MoneyProductItem
}
maxVariantPrice {
...MoneyProductItem
}
}
- variants(first: 1) {
- nodes {
- selectedOptions {
- name
- value
- }
- }
- }
}
` as const;
and remove the variant reference
function ProductItem({
product,
loading,
}: {
product: ProductItemFragment;
loading?: 'eager' | 'lazy';
}) {
- const variant = product.variants.nodes[0];
- const variantUrl = useVariantUrl(product.handle, variant.selectedOptions);
+ const variantUrl = useVariantUrl(product.handle);
return (
- Update
routes/collections.all.tsx
Same reasoning as collections.$handle.tsx
const PRODUCT_ITEM_FRAGMENT = `#graphql
fragment MoneyProductItem on MoneyV2 {
amount
currencyCode
}
fragment ProductItem on Product {
id
handle
title
featuredImage {
id
altText
url
width
height
}
priceRange {
minVariantPrice {
...MoneyProductItem
}
maxVariantPrice {
...MoneyProductItem
}
}
- variants(first: 1) {
- nodes {
- selectedOptions {
- name
- value
- }
- }
- }
}
` as const;
and remove the variant reference
function ProductItem({
product,
loading,
}: {
product: ProductItemFragment;
loading?: 'eager' | 'lazy';
}) {
- const variant = product.variants.nodes[0];
- const variantUrl = useVariantUrl(product.handle, variant.selectedOptions);
+ const variantUrl = useVariantUrl(product.handle);
return (
- Update
routes/search.tsx
Instead of using the first variant, use selectedOrFirstAvailableVariant
const SEARCH_PRODUCT_FRAGMENT = `#graphql
fragment SearchProduct on Product {
__typename
handle
id
publishedAt
title
trackingParameters
vendor
- variants(first: 1) {
- nodes {
+ selectedOrFirstAvailableVariant(
+ selectedOptions: []
+ ignoreUnknownOptions: true
+ caseInsensitiveMatch: true
+ ) {
id
image {
url
altText
width
height
}
price {
amount
currencyCode
}
compareAtPrice {
amount
currencyCode
}
selectedOptions {
name
value
}
product {
handle
title
}
}
- }
}
` as const;
const PREDICTIVE_SEARCH_PRODUCT_FRAGMENT = `#graphql
fragment PredictiveProduct on Product {
__typename
id
title
handle
trackingParameters
- variants(first: 1) {
- nodes {
+ selectedOrFirstAvailableVariant(
+ selectedOptions: []
+ ignoreUnknownOptions: true
+ caseInsensitiveMatch: true
+ ) {
id
image {
url
altText
width
height
}
price {
amount
currencyCode
}
}
- }
}
- Update
components/SearchResults.tsx
function SearchResultsProducts({
term,
products,
}: PartialSearchResult<'products'>) {
if (!products?.nodes.length) {
return null;
}
return (
<div className="search-result">
<h2>Products</h2>
<Pagination connection={products}>
{({nodes, isLoading, NextLink, PreviousLink}) => {
const ItemsMarkup = nodes.map((product) => {
const productUrl = urlWithTrackingParams({
baseUrl: `/products/${product.handle}`,
trackingParams: product.trackingParameters,
term,
});
+ const price = product?.selectedOrFirstAvailableVariant?.price;
+ const image = product?.selectedOrFirstAvailableVariant?.image;
return (
<div className="search-results-item" key={product.id}>
<Link prefetch="intent" to={productUrl}>
- {product.variants.nodes[0].image && (
+ {image && (
<Image
- data={product.variants.nodes[0].image}
+ data={image}
alt={product.title}
width={50}
/>
)}
<div>
<p>{product.title}</p>
<small>
- <Money data={product.variants.nodes[0].price} />
+ {price &&
+ <Money data={price} />
+ }
</small>
</div>
</Link>
</div>
);
});
- Update
components/SearchResultsPredictive.tsx
function SearchResultsPredictiveProducts({
term,
products,
closeSearch,
}: PartialPredictiveSearchResult<'products'>) {
if (!products.length) return null;
return (
<div className="predictive-search-result" key="products">
<h5>Products</h5>
<ul>
{products.map((product) => {
const productUrl = urlWithTrackingParams({
baseUrl: `/products/${product.handle}`,
trackingParams: product.trackingParameters,
term: term.current,
});
+ const price = product?.selectedOrFirstAvailableVariant?.price;
- const image = product?.variants?.nodes?.[0].image;
+ const image = product?.selectedOrFirstAvailableVariant?.image;
return (
<li className="predictive-search-result-item" key={product.id}>
<Link to={productUrl} onClick={closeSearch}>
{image && (
<Image
alt={image.altText ?? ''}
src={image.url}
width={50}
height={50}
/>
)}
<div>
<p>{product.title}</p>
<small>
- {product?.variants?.nodes?.[0].price && (
+ {price && (
- <Money data={product.variants.nodes[0].price} />
+ <Money data={price} />
)}
</small>
</div>
</Link>
</li>
);
})}
</ul>
</div>
);
}