diff --git a/app/components/AddToCartButton.tsx b/app/components/AddToCartButton.tsx index ed7bcaf..453bab3 100644 --- a/app/components/AddToCartButton.tsx +++ b/app/components/AddToCartButton.tsx @@ -1,5 +1,4 @@ -import type {CartLineInput} from '@shopify/hydrogen/storefront-api-types'; -import {CartForm} from '@shopify/hydrogen'; +import {CartForm, type OptimisticCartLineInput} from '@shopify/hydrogen'; import type {FetcherWithComponents} from '@remix-run/react'; import {Button} from '~/components/Button'; @@ -14,7 +13,7 @@ export function AddToCartButton({ ...props }: { children: React.ReactNode; - lines: CartLineInput[]; + lines: Array; className?: string; variant?: 'primary' | 'secondary' | 'inline'; width?: 'auto' | 'full'; diff --git a/app/lib/seo.server.ts b/app/lib/seo.server.ts index 6a6c73f..c9a627c 100644 --- a/app/lib/seo.server.ts +++ b/app/lib/seo.server.ts @@ -87,14 +87,12 @@ type ProductRequiredFields = Pick< Product, 'title' | 'description' | 'vendor' | 'seo' > & { - variants: { - nodes: Array< - Pick< - ProductVariant, - 'sku' | 'price' | 'selectedOptions' | 'availableForSale' - > - >; - }; + variants: Array< + Pick< + ProductVariant, + 'sku' | 'price' | 'selectedOptions' | 'availableForSale' + > + >; }; function productJsonLd({ @@ -107,7 +105,7 @@ function productJsonLd({ url: Request['url']; }): SeoConfig['jsonLd'] { const origin = new URL(url).origin; - const variants = product.variants.nodes; + const variants = product.variants; const description = truncate( product?.seo?.description ?? product?.description, ); diff --git a/app/routes/($locale).cart.tsx b/app/routes/($locale).cart.tsx index 0e4ec26..0ed311f 100644 --- a/app/routes/($locale).cart.tsx +++ b/app/routes/($locale).cart.tsx @@ -1,4 +1,4 @@ -import {Await, useRouteLoaderData} from '@remix-run/react'; +import {useLoaderData} from '@remix-run/react'; import invariant from 'tiny-invariant'; import { type LoaderFunctionArgs, @@ -9,7 +9,6 @@ import {CartForm, type CartQueryDataReturn, Analytics} from '@shopify/hydrogen'; import {isLocalPath} from '~/lib/utils'; import {Cart} from '~/components/Cart'; -import type {RootLoader} from '~/root'; export async function action({request, context}: ActionFunctionArgs) { const {cart} = context; @@ -84,18 +83,13 @@ export async function loader({context}: LoaderFunctionArgs) { } export default function CartRoute() { - const rootData = useRouteLoaderData('root'); - if (!rootData) return null; + const cart = useLoaderData(); - // @todo: finish on a separate PR return ( - <> -
- - {(cart) => } - -
+
+

Cart

+ - +
); } diff --git a/app/routes/($locale).products.$productHandle.tsx b/app/routes/($locale).products.$productHandle.tsx index 1f8a5de..09b003b 100644 --- a/app/routes/($locale).products.$productHandle.tsx +++ b/app/routes/($locale).products.$productHandle.tsx @@ -3,25 +3,29 @@ import {Disclosure, Listbox} from '@headlessui/react'; import { defer, type MetaArgs, - redirect, type LoaderFunctionArgs, } from '@shopify/remix-oxygen'; -import {useLoaderData, Await, useNavigate} from '@remix-run/react'; +import {useLoaderData, Await} from '@remix-run/react'; import { getSeoMeta, Money, ShopPayButton, - VariantSelector, getSelectedProductOptions, Analytics, + useOptimisticVariant, + getAdjacentAndFirstAvailableVariants, + useSelectedOptionInUrlParam, + getProductOptions, + type MappedProductOptions, } from '@shopify/hydrogen'; import invariant from 'tiny-invariant'; import clsx from 'clsx'; - import type { - ProductQuery, - ProductVariantFragmentFragment, -} from 'storefrontapi.generated'; + Maybe, + ProductOptionValueSwatch, +} from '@shopify/hydrogen/storefront-api-types'; + +import type {ProductFragment} from 'storefrontapi.generated'; import {Heading, Section, Text} from '~/components/Text'; import {Link} from '~/components/Link'; import {Button} from '~/components/Button'; @@ -81,25 +85,19 @@ async function loadCriticalData({ throw new Response('product', {status: 404}); } - if (!product.selectedVariant) { - throw redirectToFirstVariant({product, request}); - } - const recommended = getRecommendedProducts(context.storefront, product.id); - - // TODO: firstVariant is never used because we will always have a selectedVariant due to redirect - // Investigate if we can avoid the redirect for product pages with no search params for first variant - const firstVariant = product.variants.nodes[0]; - const selectedVariant = product.selectedVariant ?? firstVariant; + const selectedVariant = product.selectedOrFirstAvailableVariant ?? {}; + const variants = getAdjacentAndFirstAvailableVariants(product); const seo = seoPayload.product({ - product, + product: {...product, variants}, selectedVariant, url: request.url, }); return { product, + variants, shop, storeDomain: shop.primaryDomain.url, recommended, @@ -112,55 +110,39 @@ async function loadCriticalData({ * fetched after the initial page load. If it's unavailable, the page should still 200. * Make sure to not throw any errors here, as it will cause the page to 500. */ -function loadDeferredData({params, context}: LoaderFunctionArgs) { - const {productHandle} = params; - invariant(productHandle, 'Missing productHandle param, check route filename'); - - // In order to show which variants are available in the UI, we need to query - // all of them. But there might be a *lot*, so instead separate the variants - // into it's own separate query that is deferred. So there's a brief moment - // where variant options might show as available when they're not, but after - // this deferred query resolves, the UI will update. - const variants = context.storefront.query(VARIANTS_QUERY, { - variables: { - handle: productHandle, - country: context.storefront.i18n.country, - language: context.storefront.i18n.language, - }, - }); +function loadDeferredData(args: LoaderFunctionArgs) { + // Put any API calls that are not critical to be available on first page render + // For example: product reviews, product recommendations, social feeds. - return {variants}; + return {}; } export const meta = ({matches}: MetaArgs) => { return getSeoMeta(...matches.map((match) => (match.data as any).seo)); }; -function redirectToFirstVariant({ - product, - request, -}: { - product: ProductQuery['product']; - request: Request; -}) { - const url = new URL(request.url); - const searchParams = new URLSearchParams(url.search); - - const firstVariant = product!.variants.nodes[0]; - for (const option of firstVariant.selectedOptions) { - searchParams.set(option.name, option.value); - } - - url.search = searchParams.toString(); - - return redirect(url.href.replace(url.origin, ''), 302); -} - export default function Product() { - const {product, shop, recommended, variants} = useLoaderData(); + const {product, shop, recommended, variants, storeDomain} = + useLoaderData(); const {media, title, vendor, descriptionHtml} = product; const {shippingPolicy, refundPolicy} = shop; + // Optimistically selects a variant with given available variant information + const selectedVariant = useOptimisticVariant( + product.selectedOrFirstAvailableVariant, + variants, + ); + + // Sets the search param to the selected variant without navigation + // only when no search params are set in the url + useSelectedOptionInUrlParam(selectedVariant.selectedOptions); + + // Get the product options array + const productOptions = getProductOptions({ + ...product, + selectedOrFirstAvailableVariant: selectedVariant, + }); + return ( <>
@@ -179,18 +161,11 @@ export default function Product() { {vendor} )} - }> - - {(resp) => ( - - )} - - +
{descriptionHtml && ( (); - const closeRef = useRef(null); - /** - * Likewise, we're defaulting to the first variant for purposes - * of add to cart if there is none returned from the loader. - * A developer can opt out of this, too. - */ - const selectedVariant = product.selectedVariant!; const isOutOfStock = !selectedVariant?.availableForSale; const isOnSale = @@ -268,119 +239,122 @@ export function ProductForm({ selectedVariant?.compareAtPrice?.amount && selectedVariant?.price?.amount < selectedVariant?.compareAtPrice?.amount; - const navigate = useNavigate(); - return (
- option.optionValues.length > 1, - )} - variants={variants} - > - {({option}) => { - return ( -
- - {option.name} - -
- {option.values.length > 7 ? ( -
- { - const value = option.values.find( - (v) => v.value === selectedOption, - ); - - if (value) { - navigate(value.to); - } - }} - > - {({open}) => ( - <> - - {option.value} - - - - {option.values - .filter((value) => value.isAvailable) - .map(({value, to, isActive}) => ( - ( +
+ + {option.name} + +
+ {option.optionValues.length > 7 ? ( +
+ + {({open}) => ( + <> + + + { + selectedVariant?.selectedOptions[optionIndex] + .value + } + + + + + {option.optionValues + .filter((value) => value.available) + .map( + ({ + isDifferentProduct, + name, + variantUriQuery, + handle, + selected, + }) => ( + + { + if (!closeRef?.current) return; + closeRef.current.click(); + }} > - {({active}) => ( - { - if (!closeRef?.current) return; - closeRef.current.click(); - }} - > - {value} - {isActive && ( - - - - )} - + {name} + {selected && ( + + + )} - - ))} - - - )} - -
- ) : ( - option.values.map(({value, isAvailable, isActive, to}) => ( - - {value} - - )) - )} + + + ), + )} + + + )} +
-
- ); - }} - + ) : ( + option.optionValues.map( + ({ + isDifferentProduct, + name, + variantUriQuery, + handle, + selected, + available, + swatch, + }) => ( + + + + ), + ) + )} +
+
+ ))} {selectedVariant && (
{isOutOfStock ? ( @@ -434,6 +408,31 @@ export function ProductForm({ ); } +function ProductOptionSwatch({ + swatch, + name, +}: { + swatch?: Maybe | undefined; + name: string; +}) { + const image = swatch?.image?.previewImage?.url; + const color = swatch?.color; + + if (!image && !color) return name; + + return ( +
+ {!!image && {name}} +
+ ); +} + function ProductDetail({ title, content, @@ -484,7 +483,7 @@ function ProductDetail({ } const PRODUCT_VARIANT_FRAGMENT = `#graphql - fragment ProductVariantFragment on ProductVariant { + fragment ProductVariant on ProductVariant { id availableForSale selectedOptions { @@ -519,6 +518,52 @@ const PRODUCT_VARIANT_FRAGMENT = `#graphql } `; +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 + } + } + } + } + } + selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) { + ...ProductVariant + } + adjacentVariants (selectedOptions: $selectedOptions) { + ...ProductVariant + } + seo { + description + title + } + media(first: 7) { + nodes { + ...Media + } + } + } + ${PRODUCT_VARIANT_FRAGMENT} +` as const; + const PRODUCT_QUERY = `#graphql query Product( $country: CountryCode @@ -527,35 +572,7 @@ const PRODUCT_QUERY = `#graphql $selectedOptions: [SelectedOptionInput!]! ) @inContext(country: $country, language: $language) { product(handle: $handle) { - id - title - vendor - handle - descriptionHtml - description - options { - name - optionValues { - name - } - } - selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) { - ...ProductVariantFragment - } - media(first: 7) { - nodes { - ...Media - } - } - variants(first: 1) { - nodes { - ...ProductVariantFragment - } - } - seo { - description - title - } + ...Product } shop { name @@ -573,24 +590,7 @@ const PRODUCT_QUERY = `#graphql } } ${MEDIA_FRAGMENT} - ${PRODUCT_VARIANT_FRAGMENT} -` as const; - -const VARIANTS_QUERY = `#graphql - query variants( - $country: CountryCode - $language: LanguageCode - $handle: String! - ) @inContext(country: $country, language: $language) { - product(handle: $handle) { - variants(first: 250) { - nodes { - ...ProductVariantFragment - } - } - } - } - ${PRODUCT_VARIANT_FRAGMENT} + ${PRODUCT_FRAGMENT} ` as const; const RECOMMENDED_PRODUCTS_QUERY = `#graphql diff --git a/package-lock.json b/package-lock.json index 7362033..e00bd25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,8 @@ "@remix-run/node": "^2.13.1", "@remix-run/react": "^2.13.1", "@remix-run/server-runtime": "^2.13.1", - "@shopify/cli": "^3.69.4", - "@shopify/hydrogen": "^2024.10.0", + "@shopify/cli": "^3.72.1", + "@shopify/hydrogen": "^2024.10.1", "@shopify/remix-oxygen": "^2.0.9", "clsx": "^1.2.1", "cross-env": "^7.0.3", @@ -261,6 +261,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@ast-grep/napi/-/napi-0.11.0.tgz", "integrity": "sha512-b+R8h20+ClsYZBJqcyguLy4THfGmg2a54HgfZ0a1vdCkfe9ftjblALiZf2DsOc0+Si8BDWd09TMNn2psUuibJA==", + "license": "MIT", "engines": { "node": ">= 10" }, @@ -280,6 +281,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -295,6 +297,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -310,6 +313,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -325,6 +329,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -340,6 +345,7 @@ "cpu": [ "ia32" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -355,6 +361,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -2789,6 +2796,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/@google/model-viewer/-/model-viewer-1.12.1.tgz", "integrity": "sha512-GOf/By81rbxSmwWRVxBtlY5b3050msJ+BDWqonPj7M0/I7rNS/vVNjbLxTofbGjZObS3n0ELHj8TZ47UtkZbtg==", + "license": "Apache-2.0", "dependencies": { "lit": "^2.2.3", "three": "^0.139.2" @@ -3950,12 +3958,14 @@ "node_modules/@lit-labs/ssr-dom-shim": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.1.tgz", - "integrity": "sha512-wx4aBmgeGvFmOKucFKY+8VFJSYZxs9poN3SDNQFF6lT6NrQUnHiPB2PWz2sc4ieEcAaYYzN+1uWahEeTq2aRIQ==" + "integrity": "sha512-wx4aBmgeGvFmOKucFKY+8VFJSYZxs9poN3SDNQFF6lT6NrQUnHiPB2PWz2sc4ieEcAaYYzN+1uWahEeTq2aRIQ==", + "license": "BSD-3-Clause" }, "node_modules/@lit/reactive-element": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz", "integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==", + "license": "BSD-3-Clause", "dependencies": { "@lit-labs/ssr-dom-shim": "^1.0.0" } @@ -5196,9 +5206,10 @@ "dev": true }, "node_modules/@shopify/cli": { - "version": "3.69.4", - "resolved": "https://registry.npmjs.org/@shopify/cli/-/cli-3.69.4.tgz", - "integrity": "sha512-6DDztLJ/RSwsTiyLa4wXVcsvbGB+bOCub8o5vA5ZRyN7OWUIuS2AFeqZINRX6K6WmdXsHfG6k5ohufYEC81dxw==", + "version": "3.72.1", + "resolved": "https://registry.npmjs.org/@shopify/cli/-/cli-3.72.1.tgz", + "integrity": "sha512-oz/Qadwk1HKaVUGAhnmkQUF/HEjfD9l3WObnh4j+hoqCvrDXuJt9iEl1gq0IrNd4sf6kFA/x+GjhUXXoMlgwFg==", + "license": "MIT", "os": [ "darwin", "linux", @@ -5222,6 +5233,7 @@ "cpu": [ "arm" ], + "license": "MIT", "optional": true, "os": [ "android" @@ -5237,6 +5249,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "android" @@ -5252,6 +5265,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "android" @@ -5267,6 +5281,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -5282,6 +5297,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -5297,6 +5313,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -5312,6 +5329,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -5327,6 +5345,7 @@ "cpu": [ "arm" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -5342,6 +5361,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -5357,6 +5377,7 @@ "cpu": [ "ia32" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -5372,6 +5393,7 @@ "cpu": [ "loong64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -5387,6 +5409,7 @@ "cpu": [ "mips64el" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -5402,6 +5425,7 @@ "cpu": [ "ppc64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -5417,6 +5441,7 @@ "cpu": [ "riscv64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -5432,6 +5457,7 @@ "cpu": [ "s390x" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -5447,6 +5473,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -5462,6 +5489,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -5477,6 +5505,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -5492,6 +5521,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "sunos" @@ -5507,6 +5537,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -5522,6 +5553,7 @@ "cpu": [ "ia32" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -5537,6 +5569,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -5550,6 +5583,7 @@ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.8.tgz", "integrity": "sha512-l7iffQpT2OrZfH2rXIp7/FkmaeZM0vxbxN9KfiCwGYuZqzMg/JdvX26R31Zxn/Pxvsrg3Y9N6XTcnknqDyyv4w==", "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -5658,11 +5692,12 @@ } }, "node_modules/@shopify/hydrogen": { - "version": "2024.10.0", - "resolved": "https://registry.npmjs.org/@shopify/hydrogen/-/hydrogen-2024.10.0.tgz", - "integrity": "sha512-VPl2l7ScBeH0jTNgboFR+997J8rf/nGs01tas/SEgpeBwtB0aJjWkugKjvB8VXx+SU7TPoRGY089pzkQDh2SbQ==", + "version": "2024.10.1", + "resolved": "https://registry.npmjs.org/@shopify/hydrogen/-/hydrogen-2024.10.1.tgz", + "integrity": "sha512-v7aO41t0020cV6dKbfjYCUcyH9LYIqZpuPq1oURW0MElIZJM7o962PkN4z6U4BpDXx0lK+LSpnqHCtuezbRyOQ==", + "license": "MIT", "dependencies": { - "@shopify/hydrogen-react": "2024.10.0", + "@shopify/hydrogen-react": "2024.10.1", "content-security-policy-builder": "^2.2.0", "source-map-support": "^0.5.21", "type-fest": "^4.26.1", @@ -5691,9 +5726,10 @@ } }, "node_modules/@shopify/hydrogen-react": { - "version": "2024.10.0", - "resolved": "https://registry.npmjs.org/@shopify/hydrogen-react/-/hydrogen-react-2024.10.0.tgz", - "integrity": "sha512-iU1nLChpIqaIP/ldmkj5Ra1BkFoSC3wP8ARYAZekWjnPowTMKjf/YxNHMrt1i2zE+h6WxvYKMM3E/yB7/KJFrw==", + "version": "2024.10.1", + "resolved": "https://registry.npmjs.org/@shopify/hydrogen-react/-/hydrogen-react-2024.10.1.tgz", + "integrity": "sha512-7tNdany/ueQqqeOqJ/gewoNg6mM6J5KxcSGeYDBQWHOjQdVpWNW+T2SzaNtXobtW/TB6gQ/6S8mSstQSyr4kyA==", + "license": "MIT", "dependencies": { "@google/model-viewer": "^1.12.1", "@xstate/fsm": "2.0.0", @@ -5773,6 +5809,7 @@ "version": "2.0.9", "resolved": "https://registry.npmjs.org/@shopify/remix-oxygen/-/remix-oxygen-2.0.9.tgz", "integrity": "sha512-TNQbR5ZPqnlwuaSoNp2irGIoTs/bksDnL4oYyEwusEVpIJh5XX/xc1NdKQerLWLZBhWOdHOTwgKM0fSe4L78CA==", + "license": "MIT", "peerDependencies": { "@remix-run/server-runtime": "^2.1.0", "@shopify/oxygen-workers-types": "^3.17.3 || ^4.1.2" @@ -6014,7 +6051,8 @@ "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" }, "node_modules/@types/unist": { "version": "2.0.10", @@ -6453,12 +6491,14 @@ "node_modules/@xstate/fsm": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-2.0.0.tgz", - "integrity": "sha512-p/zcvBMoU2ap5byMefLkR+AM+Eh99CU/SDEQeccgKlmFNOMDwphaRGqdk+emvel/SaGZ7Rf9sDvzAplLzLdEVQ==" + "integrity": "sha512-p/zcvBMoU2ap5byMefLkR+AM+Eh99CU/SDEQeccgKlmFNOMDwphaRGqdk+emvel/SaGZ7Rf9sDvzAplLzLdEVQ==", + "license": "MIT" }, "node_modules/@xstate/react": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@xstate/react/-/react-3.2.1.tgz", "integrity": "sha512-L/mqYRxyBWVdIdSaXBHacfvS8NKn3sTKbPb31aRADbE9spsJ1p+tXil0GVQHPlzrmjGeozquLrxuYGiXsFNU7g==", + "license": "MIT", "dependencies": { "use-isomorphic-layout-effect": "^1.0.0", "use-sync-external-store": "^1.0.0" @@ -12144,6 +12184,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz", "integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==", + "license": "BSD-3-Clause", "dependencies": { "@lit/reactive-element": "^1.6.0", "lit-element": "^3.3.0", @@ -12154,6 +12195,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz", "integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==", + "license": "BSD-3-Clause", "dependencies": { "@lit-labs/ssr-dom-shim": "^1.1.0", "@lit/reactive-element": "^1.3.0", @@ -12164,6 +12206,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz", "integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==", + "license": "BSD-3-Clause", "dependencies": { "@types/trusted-types": "^2.0.2" } @@ -16190,6 +16233,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-2.0.2.tgz", "integrity": "sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==", + "license": "MIT", "engines": { "node": ">=8" } @@ -17617,7 +17661,8 @@ "node_modules/three": { "version": "0.139.2", "resolved": "https://registry.npmjs.org/three/-/three-0.139.2.tgz", - "integrity": "sha512-gV7q7QY8rogu7HLFZR9cWnOQAUedUhu2WXAnpr2kdXZP9YDKsG/0ychwQvWkZN5PlNw9mv5MoCTin6zNTXoONg==" + "integrity": "sha512-gV7q7QY8rogu7HLFZR9cWnOQAUedUhu2WXAnpr2kdXZP9YDKsG/0ychwQvWkZN5PlNw9mv5MoCTin6zNTXoONg==", + "license": "MIT" }, "node_modules/throttle-debounce": { "version": "3.0.1", @@ -18419,11 +18464,12 @@ "dev": true }, "node_modules/use-isomorphic-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", - "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz", + "integrity": "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==", + "license": "MIT", "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -18444,11 +18490,12 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz", - "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", + "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "license": "MIT", "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/util": { @@ -19238,6 +19285,7 @@ "version": "0.7.3", "resolved": "https://registry.npmjs.org/worktop/-/worktop-0.7.3.tgz", "integrity": "sha512-WBHP1hk8pLP7ahAw13fugDWcO0SUAOiCD6DHT/bfLWoCIA/PL9u7GKdudT2nGZ8EGR1APbGCAI6ZzKG1+X+PnQ==", + "license": "MIT", "dependencies": { "regexparam": "^2.0.0" }, diff --git a/package.json b/package.json index 1e0e538..8eed895 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,8 @@ "@remix-run/node": "^2.13.1", "@remix-run/react": "^2.13.1", "@remix-run/server-runtime": "^2.13.1", - "@shopify/cli": "^3.69.4", - "@shopify/hydrogen": "^2024.10.0", + "@shopify/cli": "^3.72.1", + "@shopify/hydrogen": "^2024.10.1", "@shopify/remix-oxygen": "^2.0.9", "clsx": "^1.2.1", "cross-env": "^7.0.3", @@ -71,6 +71,6 @@ "vite-tsconfig-paths": "^4.3.1" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } } diff --git a/storefrontapi.generated.d.ts b/storefrontapi.generated.d.ts index 2699089..82a5798 100644 --- a/storefrontapi.generated.d.ts +++ b/storefrontapi.generated.d.ts @@ -869,7 +869,7 @@ export type PoliciesIndexQuery = { }; }; -export type ProductVariantFragmentFragment = Pick< +export type ProductVariantFragment = Pick< StorefrontAPI.ProductVariant, 'id' | 'availableForSale' | 'sku' | 'title' > & { @@ -887,6 +887,146 @@ export type ProductVariantFragmentFragment = Pick< product: Pick; }; +export type ProductFragment = Pick< + StorefrontAPI.Product, + | 'id' + | 'title' + | 'vendor' + | 'handle' + | 'descriptionHtml' + | 'description' + | 'encodedVariantExistence' + | 'encodedVariantAvailability' +> & { + options: Array< + Pick & { + optionValues: Array< + Pick & { + firstSelectableVariant?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.ProductVariant, + 'id' | 'availableForSale' | 'sku' | 'title' + > & { + selectedOptions: Array< + Pick + >; + image?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.Image, + 'id' | 'url' | 'altText' | 'width' | 'height' + > + >; + price: Pick; + compareAtPrice?: StorefrontAPI.Maybe< + Pick + >; + unitPrice?: StorefrontAPI.Maybe< + Pick + >; + product: Pick; + } + >; + swatch?: StorefrontAPI.Maybe< + Pick & { + image?: StorefrontAPI.Maybe<{ + previewImage?: StorefrontAPI.Maybe< + Pick + >; + }>; + } + >; + } + >; + } + >; + selectedOrFirstAvailableVariant?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.ProductVariant, + 'id' | 'availableForSale' | 'sku' | 'title' + > & { + selectedOptions: Array< + Pick + >; + image?: StorefrontAPI.Maybe< + Pick + >; + price: Pick; + compareAtPrice?: StorefrontAPI.Maybe< + Pick + >; + unitPrice?: StorefrontAPI.Maybe< + Pick + >; + product: Pick; + } + >; + adjacentVariants: Array< + Pick< + StorefrontAPI.ProductVariant, + 'id' | 'availableForSale' | 'sku' | 'title' + > & { + selectedOptions: Array< + Pick + >; + image?: StorefrontAPI.Maybe< + Pick + >; + price: Pick; + compareAtPrice?: StorefrontAPI.Maybe< + Pick + >; + unitPrice?: StorefrontAPI.Maybe< + Pick + >; + product: Pick; + } + >; + seo: Pick; + media: { + nodes: Array< + | ({__typename: 'ExternalVideo'} & Pick< + StorefrontAPI.ExternalVideo, + 'id' | 'embedUrl' | 'host' | 'mediaContentType' | 'alt' + > & { + previewImage?: StorefrontAPI.Maybe< + Pick + >; + }) + | ({__typename: 'MediaImage'} & Pick< + StorefrontAPI.MediaImage, + 'id' | 'mediaContentType' | 'alt' + > & { + image?: StorefrontAPI.Maybe< + Pick + >; + previewImage?: StorefrontAPI.Maybe< + Pick + >; + }) + | ({__typename: 'Model3d'} & Pick< + StorefrontAPI.Model3d, + 'id' | 'mediaContentType' | 'alt' + > & { + sources: Array< + Pick + >; + previewImage?: StorefrontAPI.Maybe< + Pick + >; + }) + | ({__typename: 'Video'} & Pick< + StorefrontAPI.Video, + 'id' | 'mediaContentType' | 'alt' + > & { + sources: Array>; + previewImage?: StorefrontAPI.Maybe< + Pick + >; + }) + >; + }; +}; + export type ProductQueryVariables = StorefrontAPI.Exact<{ country?: StorefrontAPI.InputMaybe; language?: StorefrontAPI.InputMaybe; @@ -900,14 +1040,81 @@ export type ProductQuery = { product?: StorefrontAPI.Maybe< Pick< StorefrontAPI.Product, - 'id' | 'title' | 'vendor' | 'handle' | 'descriptionHtml' | 'description' + | 'id' + | 'title' + | 'vendor' + | 'handle' + | 'descriptionHtml' + | 'description' + | 'encodedVariantExistence' + | 'encodedVariantAvailability' > & { options: Array< Pick & { - optionValues: Array>; + optionValues: Array< + Pick & { + firstSelectableVariant?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.ProductVariant, + 'id' | 'availableForSale' | 'sku' | 'title' + > & { + selectedOptions: Array< + Pick + >; + image?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.Image, + 'id' | 'url' | 'altText' | 'width' | 'height' + > + >; + price: Pick; + compareAtPrice?: StorefrontAPI.Maybe< + Pick + >; + unitPrice?: StorefrontAPI.Maybe< + Pick + >; + product: Pick; + } + >; + swatch?: StorefrontAPI.Maybe< + Pick & { + image?: StorefrontAPI.Maybe<{ + previewImage?: StorefrontAPI.Maybe< + Pick + >; + }>; + } + >; + } + >; + } + >; + selectedOrFirstAvailableVariant?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.ProductVariant, + 'id' | 'availableForSale' | 'sku' | 'title' + > & { + selectedOptions: Array< + Pick + >; + image?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.Image, + 'id' | 'url' | 'altText' | 'width' | 'height' + > + >; + price: Pick; + compareAtPrice?: StorefrontAPI.Maybe< + Pick + >; + unitPrice?: StorefrontAPI.Maybe< + Pick + >; + product: Pick; } >; - selectedVariant?: StorefrontAPI.Maybe< + adjacentVariants: Array< Pick< StorefrontAPI.ProductVariant, 'id' | 'availableForSale' | 'sku' | 'title' @@ -931,6 +1138,7 @@ export type ProductQuery = { product: Pick; } >; + seo: Pick; media: { nodes: Array< | ({__typename: 'ExternalVideo'} & Pick< @@ -976,33 +1184,6 @@ export type ProductQuery = { }) >; }; - variants: { - nodes: Array< - Pick< - StorefrontAPI.ProductVariant, - 'id' | 'availableForSale' | 'sku' | 'title' - > & { - selectedOptions: Array< - Pick - >; - image?: StorefrontAPI.Maybe< - Pick< - StorefrontAPI.Image, - 'id' | 'url' | 'altText' | 'width' | 'height' - > - >; - price: Pick; - compareAtPrice?: StorefrontAPI.Maybe< - Pick - >; - unitPrice?: StorefrontAPI.Maybe< - Pick - >; - product: Pick; - } - >; - }; - seo: Pick; } >; shop: Pick & { @@ -1016,43 +1197,6 @@ export type ProductQuery = { }; }; -export type VariantsQueryVariables = StorefrontAPI.Exact<{ - country?: StorefrontAPI.InputMaybe; - language?: StorefrontAPI.InputMaybe; - handle: StorefrontAPI.Scalars['String']['input']; -}>; - -export type VariantsQuery = { - product?: StorefrontAPI.Maybe<{ - variants: { - nodes: Array< - Pick< - StorefrontAPI.ProductVariant, - 'id' | 'availableForSale' | 'sku' | 'title' - > & { - selectedOptions: Array< - Pick - >; - image?: StorefrontAPI.Maybe< - Pick< - StorefrontAPI.Image, - 'id' | 'url' | 'altText' | 'width' | 'height' - > - >; - price: Pick; - compareAtPrice?: StorefrontAPI.Maybe< - Pick - >; - unitPrice?: StorefrontAPI.Maybe< - Pick - >; - product: Pick; - } - >; - }; - }>; -}; - export type ProductRecommendationsQueryVariables = StorefrontAPI.Exact<{ productId: StorefrontAPI.Scalars['ID']['input']; count?: StorefrontAPI.InputMaybe; @@ -1283,14 +1427,10 @@ interface GeneratedQueryTypes { return: PoliciesIndexQuery; variables: PoliciesIndexQueryVariables; }; - '#graphql\n query Product(\n $country: CountryCode\n $language: LanguageCode\n $handle: String!\n $selectedOptions: [SelectedOptionInput!]!\n ) @inContext(country: $country, language: $language) {\n product(handle: $handle) {\n id\n title\n vendor\n handle\n descriptionHtml\n description\n options {\n name\n optionValues {\n name\n }\n }\n selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) {\n ...ProductVariantFragment\n }\n media(first: 7) {\n nodes {\n ...Media\n }\n }\n variants(first: 1) {\n nodes {\n ...ProductVariantFragment\n }\n }\n seo {\n description\n title\n }\n }\n shop {\n name\n primaryDomain {\n url\n }\n shippingPolicy {\n body\n handle\n }\n refundPolicy {\n body\n handle\n }\n }\n }\n #graphql\n fragment Media on Media {\n __typename\n mediaContentType\n alt\n previewImage {\n url\n }\n ... on MediaImage {\n id\n image {\n id\n url\n width\n height\n }\n }\n ... on Video {\n id\n sources {\n mimeType\n url\n }\n }\n ... on Model3d {\n id\n sources {\n mimeType\n url\n }\n }\n ... on ExternalVideo {\n id\n embedUrl\n host\n }\n }\n\n #graphql\n fragment ProductVariantFragment on ProductVariant {\n id\n availableForSale\n selectedOptions {\n name\n value\n }\n image {\n id\n url\n altText\n width\n height\n }\n price {\n amount\n currencyCode\n }\n compareAtPrice {\n amount\n currencyCode\n }\n sku\n title\n unitPrice {\n amount\n currencyCode\n }\n product {\n title\n handle\n }\n }\n\n': { + '#graphql\n query Product(\n $country: CountryCode\n $language: LanguageCode\n $handle: String!\n $selectedOptions: [SelectedOptionInput!]!\n ) @inContext(country: $country, language: $language) {\n product(handle: $handle) {\n ...Product\n }\n shop {\n name\n primaryDomain {\n url\n }\n shippingPolicy {\n body\n handle\n }\n refundPolicy {\n body\n handle\n }\n }\n }\n #graphql\n fragment Media on Media {\n __typename\n mediaContentType\n alt\n previewImage {\n url\n }\n ... on MediaImage {\n id\n image {\n id\n url\n width\n height\n }\n }\n ... on Video {\n id\n sources {\n mimeType\n url\n }\n }\n ... on Model3d {\n id\n sources {\n mimeType\n url\n }\n }\n ... on ExternalVideo {\n id\n embedUrl\n host\n }\n }\n\n #graphql\n fragment Product on Product {\n id\n title\n vendor\n handle\n descriptionHtml\n description\n encodedVariantExistence\n encodedVariantAvailability\n options {\n name\n optionValues {\n name\n firstSelectableVariant {\n ...ProductVariant\n }\n swatch {\n color\n image {\n previewImage {\n url\n }\n }\n }\n }\n }\n selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) {\n ...ProductVariant\n }\n adjacentVariants (selectedOptions: $selectedOptions) {\n ...ProductVariant\n }\n seo {\n description\n title\n }\n media(first: 7) {\n nodes {\n ...Media\n }\n }\n }\n #graphql\n fragment ProductVariant on ProductVariant {\n id\n availableForSale\n selectedOptions {\n name\n value\n }\n image {\n id\n url\n altText\n width\n height\n }\n price {\n amount\n currencyCode\n }\n compareAtPrice {\n amount\n currencyCode\n }\n sku\n title\n unitPrice {\n amount\n currencyCode\n }\n product {\n title\n handle\n }\n }\n\n\n': { return: ProductQuery; variables: ProductQueryVariables; }; - '#graphql\n query variants(\n $country: CountryCode\n $language: LanguageCode\n $handle: String!\n ) @inContext(country: $country, language: $language) {\n product(handle: $handle) {\n variants(first: 250) {\n nodes {\n ...ProductVariantFragment\n }\n }\n }\n }\n #graphql\n fragment ProductVariantFragment on ProductVariant {\n id\n availableForSale\n selectedOptions {\n name\n value\n }\n image {\n id\n url\n altText\n width\n height\n }\n price {\n amount\n currencyCode\n }\n compareAtPrice {\n amount\n currencyCode\n }\n sku\n title\n unitPrice {\n amount\n currencyCode\n }\n product {\n title\n handle\n }\n }\n\n': { - return: VariantsQuery; - variables: VariantsQueryVariables; - }; '#graphql\n query productRecommendations(\n $productId: ID!\n $count: Int\n $country: CountryCode\n $language: LanguageCode\n ) @inContext(country: $country, language: $language) {\n recommended: productRecommendations(productId: $productId) {\n ...ProductCard\n }\n additional: products(first: $count, sortKey: BEST_SELLING) {\n nodes {\n ...ProductCard\n }\n }\n }\n #graphql\n fragment ProductCard on Product {\n id\n title\n publishedAt\n handle\n vendor\n variants(first: 1) {\n nodes {\n id\n availableForSale\n image {\n url\n altText\n width\n height\n }\n price {\n amount\n currencyCode\n }\n compareAtPrice {\n amount\n currencyCode\n }\n selectedOptions {\n name\n value\n }\n product {\n handle\n title\n }\n }\n }\n }\n\n': { return: ProductRecommendationsQuery; variables: ProductRecommendationsQueryVariables;