From ae262b616127a7173d23a1a38a6e658af3105ce8 Mon Sep 17 00:00:00 2001 From: Dustin Firman Date: Tue, 7 May 2024 16:27:14 -0400 Subject: [PATCH] Adding an example for B2B (#1886) * b2b example start working queries contextualized queries quantity and volume prices added header selector login redirect handle single location buyers convert location selector to form convert location dropdown to form update input type * remove getBuyer and move it into context automatically move token exchange and add get set buyer move set buyer into customerAccount remove buyer expire remove locations and use cart instead automatically get buyeridentity from customerAccount * add type, dont merge this thou, its based on unstable * dont force login added example comments removed comment added README type updates fixed tests added more tests updated types to 2024-04 simplified example doc update updated props to unstable * fixes after rebase * regenerate graphql types * fixing types * more type fixes * add dts file * fix more types * add generated types * cart handles quantity rules * catch error on cart * check for b2b customer using a provider * fixed types * cart update * convert location selector to list of forms * decouple caapi from storefront * finished addressing feedback * fixed types and linting * removed unused test * readme update * addressed comments * Clean up cart changes * clean up buyer identity update * more clean up * removed unused imports * update type import * fixed typecheck * reconstruct buyer * add example fix for CLI * sync server file with skeleton more closetly * addressed comments * update cart on logout and changeset * added redirect issue doc * updated changeset summary --------- Co-authored-by: Michelle Chen Co-authored-by: Helen Lin --- .changeset/rich-rivers-nail.md | 5 + .graphqlrc.yml | 2 + examples/README.md | 1 + examples/b2b/.gitignore | 1 + examples/b2b/README.md | 45 + .../app/components/B2BLocationProvider.tsx | 51 + .../app/components/B2BLocationSelector.tsx | 68 + examples/b2b/app/components/Cart.tsx | 357 +++++ examples/b2b/app/components/Header.tsx | 225 +++ examples/b2b/app/components/PriceBreaks.tsx | 39 + examples/b2b/app/components/QuantityRules.tsx | 44 + .../CustomerLocationsQuery.ts | 34 + examples/b2b/app/lib/fragments.ts | 118 ++ examples/b2b/app/remix.env.d.ts | 53 + examples/b2b/app/root.tsx | 277 ++++ examples/b2b/app/routes/account_.logout.tsx | 18 + examples/b2b/app/routes/b2blocations.tsx | 47 + examples/b2b/app/routes/products.$handle.tsx | 519 +++++++ examples/b2b/app/styles/app.css | 537 +++++++ .../b2b/customer-accountapi.generated.d.ts | 550 +++++++ examples/b2b/env.d.ts | 53 + examples/b2b/package.json | 16 + examples/b2b/server.ts | 120 ++ examples/b2b/storefrontapi.generated.d.ts | 1310 +++++++++++++++++ examples/b2b/tsconfig.json | 11 + package-lock.json | 17 + package.json | 1 + .../hydrogen/src/cart/cart-test-helper.ts | 17 + .../src/cart/createCartHandler.test.ts | 7 +- .../hydrogen/src/cart/createCartHandler.ts | 1 + .../hydrogen/src/cart/queries/cart-types.ts | 5 + .../queries/cartBuyerIdentityUpdateDefault.ts | 15 +- .../src/cart/queries/cartCreateDefault.ts | 12 +- packages/hydrogen/src/constants.ts | 10 + .../src/customer/auth.helpers.test.ts | 11 +- .../hydrogen/src/customer/auth.helpers.ts | 11 +- .../hydrogen/src/customer/customer.test.ts | 108 +- packages/hydrogen/src/customer/customer.ts | 119 +- packages/hydrogen/src/customer/types.ts | 15 +- packages/hydrogen/src/hydrogen.d.ts | 6 +- packages/hydrogen/src/storefront.test.ts | 7 +- 41 files changed, 4822 insertions(+), 41 deletions(-) create mode 100644 .changeset/rich-rivers-nail.md create mode 100644 examples/b2b/.gitignore create mode 100644 examples/b2b/README.md create mode 100644 examples/b2b/app/components/B2BLocationProvider.tsx create mode 100644 examples/b2b/app/components/B2BLocationSelector.tsx create mode 100644 examples/b2b/app/components/Cart.tsx create mode 100644 examples/b2b/app/components/Header.tsx create mode 100644 examples/b2b/app/components/PriceBreaks.tsx create mode 100644 examples/b2b/app/components/QuantityRules.tsx create mode 100644 examples/b2b/app/graphql/customer-account/CustomerLocationsQuery.ts create mode 100644 examples/b2b/app/lib/fragments.ts create mode 100644 examples/b2b/app/remix.env.d.ts create mode 100644 examples/b2b/app/root.tsx create mode 100644 examples/b2b/app/routes/account_.logout.tsx create mode 100644 examples/b2b/app/routes/b2blocations.tsx create mode 100644 examples/b2b/app/routes/products.$handle.tsx create mode 100644 examples/b2b/app/styles/app.css create mode 100644 examples/b2b/customer-accountapi.generated.d.ts create mode 100644 examples/b2b/env.d.ts create mode 100644 examples/b2b/package.json create mode 100644 examples/b2b/server.ts create mode 100644 examples/b2b/storefrontapi.generated.d.ts create mode 100644 examples/b2b/tsconfig.json diff --git a/.changeset/rich-rivers-nail.md b/.changeset/rich-rivers-nail.md new file mode 100644 index 0000000000..2ea8876c8c --- /dev/null +++ b/.changeset/rich-rivers-nail.md @@ -0,0 +1,5 @@ +--- +'@shopify/hydrogen': patch +--- + +Adding support for B2B to the customer account client and cart handler to store and manage [buyer context](https://shopify.dev/docs/api/storefront/2024-04/input-objects/BuyerInput). Currently Unstable. diff --git a/.graphqlrc.yml b/.graphqlrc.yml index 7c7141644c..b336eb9d5a 100644 --- a/.graphqlrc.yml +++ b/.graphqlrc.yml @@ -8,11 +8,13 @@ projects: - 'examples/**/app/**/*.{graphql,js,ts,jsx,tsx}' - '!templates/**/app/graphql/**/*.{graphql,js,ts,jsx,tsx}' - '!examples/**/app/graphql/**/*.{graphql,js,ts,jsx,tsx}' + - '!packages/hydrogen/src/customer/**/*.{graphql,js,ts,jsx,tsx}' customer-account: schema: 'packages/hydrogen-react/customer-account.schema.json' documents: - 'templates/**/app/graphql/customer-account/**/*.{graphql,js,ts,jsx,tsx}' - 'examples/**/app/graphql/customer-account/**/*.{graphql,js,ts,jsx,tsx}' + - 'packages/hydrogen/src/customer/**/*.{graphql,js,ts,jsx,tsx}' admin: schema: 'packages/cli/admin.schema.json' documents: 'packages/cli/src/**/graphql/admin/**/*.ts' diff --git a/examples/README.md b/examples/README.md index 20a03f3345..6a9cea1bc5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,6 +16,7 @@ These are some of the most commonly used Hydrogen examples. Browse the folders i | Example | Details | | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [B2B](/examples/b2b/) | Hydrogen example of a headless B2B store front | | [Custom Cart Method](/examples/custom-cart-method/) | How to implementation custom cart method by showing in-line product option edit in cart. | | [Express](/examples/express/) | Hydrogen example using NodeJS [Express](https://expressjs.com/). | | [Infinite Scroll](/examples/infinite-scroll/) | [Infinite scroll](https://shopify.dev/docs/custom-storefronts/hydrogen/data-fetching/pagination#automatically-load-pages-on-scroll) within a product collection page using the [Pagination component](https://shopify.dev/docs/api/hydrogen/2024-01/components/pagination). | diff --git a/examples/b2b/.gitignore b/examples/b2b/.gitignore new file mode 100644 index 0000000000..ad5f2cad45 --- /dev/null +++ b/examples/b2b/.gitignore @@ -0,0 +1 @@ +.shopify diff --git a/examples/b2b/README.md b/examples/b2b/README.md new file mode 100644 index 0000000000..daed7a866c --- /dev/null +++ b/examples/b2b/README.md @@ -0,0 +1,45 @@ +# Hydrogen example: B2B + +> [!NOTE] +> This example is currently Unstable. There is a known issue where setting too many [Customer Account API callback URIs](https://shopify.dev/docs/custom-storefronts/building-with-the-customer-account-api/hydrogen#update-the-application-setup) will cause the hydrogen session to exceed the browsers maximum cookie length. This is because our current implementation relies on encoding redirect URIs in the token. We are aware of this issue and are actively working towards a future where this is not a problem. As a workaround you can remove unneeded callback URIs or use a different storefront. + +This is an example implementation of a B2B storefront using Hydrogen. It includes the following high level changes. + +1. Retrieving company location data from a logged in customer using the [Customer Account API](https://shopify.dev/docs/api/customer/2024-04/queries/customer) +2. Displaying a list of company locations and setting a `companyLocationId` in session +3. Using a storefront `customerAccessToken` and `companyLocationId` to update cart and get B2B specific rules and pricing such as [volume pricing and quantity rules](https://help.shopify.com/en/manual/b2b/catalogs/quantity-pricing) +4. Using a storefront `customerAccessToken` and `companyLocationId` to [contextualize queries](https://shopify.dev/docs/api/storefront#directives) using the `buyer` argument on the product display page + +> [!NOTE] +> Only queries on the product display page, `app/routes/products.$handle.tsx`, were contextualized in this example. For a production storefront, all queries for product data should be contextualized. + +## Install + +Setup a new project with this example: + +```bash +npm create @shopify/hydrogen@latest -- --template b2b +``` + +## Requirements + +- Your store is on a [Shopify Plus plan](https://help.shopify.com/manual/intro-to-shopify/pricing-plans/plans-features/shopify-plus-plan). +- Your store is using [new customer accounts](https://help.shopify.com/en/manual/customers/customer-accounts/new-customer-accounts). +- You have access to a customer which has permission to order for a [B2B company](https://help.shopify.com/en/manual/b2b). + +## Key files + +This folder contains the minimal set of files needed to showcase the implementation. +Not all queries where contextualized for B2B. `app/routes/products.$handle.tsx` provides +reference on how to contextualize storefront queries. Files that aren’t included by default +with Hydrogen and that you’ll need to create are labeled with πŸ†•. + +| File | Description | +| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| [`app/routes/b2blocations.tsx`](app/routes/b2blocations.tsx) | Includes a customer query to get B2B data. Set `companyLocationId` in session if there is only one location available to buy for the customer | +| [`app/components/B2BLocationProvider.tsx`](app/components/B2BLocationProvider.tsx) | Provides context on if the current logged in customer is a B2B customer and keeping track of the location modal open status. | +| πŸ†• [`app/graphql/CustomerLocationsQuery.ts`](app/graphql/CustomerLocationsQuery.ts) | Customer query to fetch company locations | +| πŸ†• [`app/components/B2BLocationSelector.tsx`](app/components/B2BLocationSelector.tsx) | Component to choose a Company location to buy for. Rendered if there is no `companyLocationId` set in session | +| [`app/routes/products.$handle.tsx`](app/routes/products.$handle.tsx) | Added buyer context to the product and product varient queries. Includes logic and components to display quantity rules and quantity price breaks | +| πŸ†• [`app/components/PriceBreaks.tsx`](app/components/PriceBreaks.tsx) | Component rendered on the product page to highlight quantity price breaks | +| πŸ†• [`app/components/QuantityRules.tsx`](app/components/QuantityRules.tsx) | Component rendered on the product page to highlight quantity rules | diff --git a/examples/b2b/app/components/B2BLocationProvider.tsx b/examples/b2b/app/components/B2BLocationProvider.tsx new file mode 100644 index 0000000000..8fbfb1a7f8 --- /dev/null +++ b/examples/b2b/app/components/B2BLocationProvider.tsx @@ -0,0 +1,51 @@ +import {createContext, useContext, useEffect, useState, useMemo} from 'react'; +import {useFetcher} from '@remix-run/react'; +import {CustomerCompany} from '../root'; + +export type B2BLocationContextValue = { + company?: CustomerCompany; + companyLocationId?: string; + modalOpen?: boolean; + setModalOpen: (b: boolean) => void; +}; + +const defaultB2BLocationContextValue = { + company: undefined, + companyLocationId: undefined, + modalOpen: undefined, + setModalOpen: () => {}, +}; + +const B2BLocationContext = createContext( + defaultB2BLocationContextValue, +); + +export function B2BLocationProvider({children}: {children: React.ReactNode}) { + const fetcher = useFetcher(); + const [modalOpen, setModalOpen] = useState(fetcher?.data?.modalOpen); + + useEffect(() => { + if (fetcher.data || fetcher.state === 'loading') return; + + fetcher.load('/b2blocations'); + }, [fetcher]); + + const value = useMemo(() => { + return { + ...defaultB2BLocationContextValue, + ...fetcher.data, + modalOpen: modalOpen ?? fetcher?.data?.modalOpen, + setModalOpen, + }; + }, [fetcher, modalOpen]); + + return ( + + {children} + + ); +} + +export function useB2BLocation(): B2BLocationContextValue { + return useContext(B2BLocationContext); +} diff --git a/examples/b2b/app/components/B2BLocationSelector.tsx b/examples/b2b/app/components/B2BLocationSelector.tsx new file mode 100644 index 0000000000..8dbf467bc7 --- /dev/null +++ b/examples/b2b/app/components/B2BLocationSelector.tsx @@ -0,0 +1,68 @@ +import {CartForm} from '@shopify/hydrogen'; +import type { + CustomerCompanyLocation, + CustomerCompanyLocationConnection, +} from '~/root'; +import {useB2BLocation} from './B2BLocationProvider'; + +export function B2BLocationSelector() { + const {company, modalOpen, setModalOpen} = useB2BLocation(); + + const locations = company?.locations?.edges + ? company.locations.edges.map( + (location: CustomerCompanyLocationConnection) => { + return {...location.node}; + }, + ) + : []; + + if (!company || !modalOpen) return null; + + return ( +
+
+

Logged in for {company.name}

+ Choose a location: +
+ {locations.map((location: CustomerCompanyLocation) => { + const addressLines = + location?.shippingAddress?.formattedAddress ?? []; + return ( + + {(fetcher) => ( + + )} + + ); + })} +
+
+
+ ); +} diff --git a/examples/b2b/app/components/Cart.tsx b/examples/b2b/app/components/Cart.tsx new file mode 100644 index 0000000000..bd4d4beab2 --- /dev/null +++ b/examples/b2b/app/components/Cart.tsx @@ -0,0 +1,357 @@ +import {CartForm, Image, Money} from '@shopify/hydrogen'; +import type {CartLineUpdateInput} from '@shopify/hydrogen/storefront-api-types'; +import {Link} from '@remix-run/react'; +import type {CartApiQueryFragment} from 'storefrontapi.generated'; +import {useVariantUrl} from '~/lib/variants'; + +type CartLine = CartApiQueryFragment['lines']['nodes'][0]; + +type CartMainProps = { + cart: CartApiQueryFragment | null; + layout: 'page' | 'aside'; +}; + +export function CartMain({layout, cart}: CartMainProps) { + const linesCount = Boolean(cart?.lines?.nodes?.length || 0); + const withDiscount = + cart && + Boolean(cart?.discountCodes?.filter((code) => code.applicable)?.length); + const className = `cart-main ${withDiscount ? 'with-discount' : ''}`; + + return ( +
+
+ ); +} + +function CartDetails({layout, cart}: CartMainProps) { + const cartHasItems = !!cart && cart.totalQuantity > 0; + + return ( +
+ + {cartHasItems && ( + + + + + )} +
+ ); +} + +function CartLines({ + lines, + layout, +}: { + layout: CartMainProps['layout']; + lines: CartApiQueryFragment['lines'] | undefined; +}) { + if (!lines) return null; + + return ( +
+
    + {lines.nodes.map((line) => ( + + ))} +
+
+ ); +} + +function CartLineItem({ + layout, + line, +}: { + layout: CartMainProps['layout']; + line: CartLine; +}) { + const {id, merchandise} = line; + const {product, title, image, selectedOptions} = merchandise; + const lineItemUrl = useVariantUrl(product.handle, selectedOptions); + + return ( +
  • + {image && ( + {title} + )} + +
    + { + if (layout === 'aside') { + // close the drawer + window.location.href = lineItemUrl; + } + }} + > +

    + {product.title} +

    + + +
      + {selectedOptions.map((option) => ( +
    • + + {option.name}: {option.value} + +
    • + ))} +
    + +
    +
  • + ); +} + +function CartCheckoutActions({checkoutUrl}: {checkoutUrl: string}) { + if (!checkoutUrl) return null; + + return ( + + ); +} + +export function CartSummary({ + cost, + layout, + children = null, +}: { + children?: React.ReactNode; + cost: CartApiQueryFragment['cost']; + layout: CartMainProps['layout']; +}) { + const className = + layout === 'page' ? 'cart-summary-page' : 'cart-summary-aside'; + + return ( +
    +

    Totals

    +
    +
    Subtotal
    +
    + {cost?.subtotalAmount?.amount ? ( + + ) : ( + '-' + )} +
    +
    + {children} +
    + ); +} + +function CartLineRemoveButton({lineIds}: {lineIds: string[]}) { + return ( + + + + ); +} + +function CartLineQuantity({line}: {line: CartLine}) { + if (!line || typeof line?.quantity === 'undefined') return null; + const {id: lineId, quantity} = line; + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + const {increment, minimum, maximum} = line.merchandise.quantityRule; + const nextIncrement = increment - (quantity % increment); + const prevIncrement = + quantity % increment === 0 ? increment : quantity % increment; + const prevQuantity = Number(Math.max(0, quantity - prevIncrement).toFixed(0)); + const nextQuantity = Number((quantity + nextIncrement).toFixed(0)); + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ + + return ( +
    + Quantity: {quantity}    + + + +   + + + +   + +
    + ); +} + +function CartLinePrice({ + line, + priceType = 'regular', + ...passthroughProps +}: { + line: CartLine; + priceType?: 'regular' | 'compareAt'; + [key: string]: any; +}) { + if (!line?.cost?.amountPerQuantity || !line?.cost?.totalAmount) return null; + + const moneyV2 = + priceType === 'regular' + ? line.cost.totalAmount + : line.cost.compareAtAmountPerQuantity; + + if (moneyV2 == null) { + return null; + } + + return ( +
    + +
    + ); +} + +export function CartEmpty({ + hidden = false, + layout = 'aside', +}: { + hidden: boolean; + layout?: CartMainProps['layout']; +}) { + return ( + + ); +} + +function CartDiscounts({ + discountCodes, +}: { + discountCodes: CartApiQueryFragment['discountCodes']; +}) { + const codes: string[] = + discountCodes + ?.filter((discount) => discount.applicable) + ?.map(({code}) => code) || []; + + return ( +
    + {/* Have existing discount, display it with a remove option */} + + + {/* Show an input to apply a discount */} + +
    + +   + +
    +
    +
    + ); +} + +function UpdateDiscountForm({ + discountCodes, + children, +}: { + discountCodes?: string[]; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +function CartLineUpdateButton({ + children, + lines, +}: { + children: React.ReactNode; + lines: CartLineUpdateInput[]; +}) { + return ( + + {children} + + ); +} diff --git a/examples/b2b/app/components/Header.tsx b/examples/b2b/app/components/Header.tsx new file mode 100644 index 0000000000..9f285b2f30 --- /dev/null +++ b/examples/b2b/app/components/Header.tsx @@ -0,0 +1,225 @@ +import {Await, NavLink} from '@remix-run/react'; +import {Suspense} from 'react'; +import type {HeaderQuery} from 'storefrontapi.generated'; +import type {LayoutProps} from '~/components/Layout'; +import { + type CustomerCompanyLocationConnection, + useRootLoaderData, +} from '~/root'; +import {useB2BLocation} from './B2BLocationProvider'; + +type HeaderProps = Pick; + +type Viewport = 'desktop' | 'mobile'; + +export function Header({header, isLoggedIn, cart}: HeaderProps) { + const {shop, menu} = header; + return ( +
    + + {shop.name} + + + +
    + ); +} + +export function HeaderMenu({ + menu, + primaryDomainUrl, + viewport, +}: { + menu: HeaderProps['header']['menu']; + primaryDomainUrl: HeaderQuery['shop']['primaryDomain']['url']; + viewport: Viewport; +}) { + const {publicStoreDomain} = useRootLoaderData(); + const className = `header-menu-${viewport}`; + + function closeAside(event: React.MouseEvent) { + if (viewport === 'mobile') { + event.preventDefault(); + window.location.href = event.currentTarget.href; + } + } + + return ( + + ); +} + +function HeaderCtas({ + isLoggedIn, + cart, +}: Pick) { + return ( + + ); +} + +function HeaderMenuMobileToggle() { + return ( + +

    ☰

    +
    + ); +} + +function SearchToggle() { + return Search; +} + +function CartBadge({count}: {count: number}) { + return Cart {count}; +} + +function CartToggle({cart}: Pick) { + return ( + }> + + {(cart) => { + if (!cart) return ; + return ; + }} + + + ); +} + +/***********************************************/ +/********** EXAMPLE UPDATE STARTS ************/ +function ChangeLocation() { + const {company, companyLocationId, setModalOpen} = useB2BLocation(); + + const locations = company?.locations?.edges + ? company.locations.edges.map( + (location: CustomerCompanyLocationConnection) => { + return {...location.node}; + }, + ) + : []; + + if (locations.length <= 1 || !company) return null; + + return ( + + ); +} +/********** EXAMPLE UPDATE END ************/ +/***********************************************/ + +const FALLBACK_HEADER_MENU = { + id: 'gid://shopify/Menu/199655587896', + items: [ + { + id: 'gid://shopify/MenuItem/461609500728', + resourceId: null, + tags: [], + title: 'Collections', + type: 'HTTP', + url: '/collections', + items: [], + }, + { + id: 'gid://shopify/MenuItem/461609533496', + resourceId: null, + tags: [], + title: 'Blog', + type: 'HTTP', + url: '/blogs/journal', + items: [], + }, + { + id: 'gid://shopify/MenuItem/461609566264', + resourceId: null, + tags: [], + title: 'Policies', + type: 'HTTP', + url: '/policies', + items: [], + }, + { + id: 'gid://shopify/MenuItem/461609599032', + resourceId: 'gid://shopify/Page/92591030328', + tags: [], + title: 'About', + type: 'PAGE', + url: '/pages/about', + items: [], + }, + ], +}; + +function activeLinkStyle({ + isActive, + isPending, +}: { + isActive: boolean; + isPending: boolean; +}) { + return { + fontWeight: isActive ? 'bold' : undefined, + color: isPending ? 'grey' : 'black', + }; +} diff --git a/examples/b2b/app/components/PriceBreaks.tsx b/examples/b2b/app/components/PriceBreaks.tsx new file mode 100644 index 0000000000..3e4085dfe5 --- /dev/null +++ b/examples/b2b/app/components/PriceBreaks.tsx @@ -0,0 +1,39 @@ +import {Money} from '@shopify/hydrogen'; +import {MoneyV2} from '@shopify/hydrogen/storefront-api-types'; + +type PriceBreak = { + minimumQuantity: number; + price: MoneyV2; +}; + +export type PriceBreaksProps = { + priceBreaks: PriceBreak[]; +}; + +export function PriceBreaks({priceBreaks}: PriceBreaksProps) { + return ( + <> +

    Volume Pricing

    + + + + + + + + + {priceBreaks.map((priceBreak, index) => { + return ( + + + + + ); + })} + +
    Minimum QuantityUnit Price
    {priceBreak.minimumQuantity} + +
    + + ); +} diff --git a/examples/b2b/app/components/QuantityRules.tsx b/examples/b2b/app/components/QuantityRules.tsx new file mode 100644 index 0000000000..616e24d7bb --- /dev/null +++ b/examples/b2b/app/components/QuantityRules.tsx @@ -0,0 +1,44 @@ +import type {Maybe} from '@shopify/hydrogen/customer-account-api-types'; + +export type QuantityRulesProps = { + maximum?: Maybe | undefined; + minimum?: Maybe | undefined; + increment?: Maybe | undefined; +}; + +export const hasQuantityRules = (quantityRule?: QuantityRulesProps) => { + return ( + quantityRule && + (quantityRule?.increment != 1 || + quantityRule?.minimum != 1 || + quantityRule?.maximum) + ); +}; + +export function QuantityRules({ + maximum, + minimum, + increment, +}: QuantityRulesProps) { + return ( + <> +

    Quantity Rules

    + + + + + + + + + + + + + + + +
    IncrementMinimumMaximum
    {increment}{minimum}{maximum}
    + + ); +} diff --git a/examples/b2b/app/graphql/customer-account/CustomerLocationsQuery.ts b/examples/b2b/app/graphql/customer-account/CustomerLocationsQuery.ts new file mode 100644 index 0000000000..d4e3f143a3 --- /dev/null +++ b/examples/b2b/app/graphql/customer-account/CustomerLocationsQuery.ts @@ -0,0 +1,34 @@ +// NOTE: https://shopify.dev/docs/api/customer/latest/objects/Customer +export const CUSTOMER_LOCATIONS_QUERY = `#graphql + query CustomerLocations { + customer { + id + emailAddress { + emailAddress + } + companyContacts(first: 1){ + edges{ + node{ + company{ + id + name + locations(first: 10){ + edges{ + node{ + id + name + shippingAddress { + countryCode + formattedAddress + } + } + } + } + } + } + } + } + } + } +` as const; + diff --git a/examples/b2b/app/lib/fragments.ts b/examples/b2b/app/lib/fragments.ts new file mode 100644 index 0000000000..c5412d9a68 --- /dev/null +++ b/examples/b2b/app/lib/fragments.ts @@ -0,0 +1,118 @@ +// NOTE: https://shopify.dev/docs/api/storefront/latest/queries/cart +export const CART_QUERY_FRAGMENT = `#graphql + fragment Money on MoneyV2 { + currencyCode + amount + } + fragment CartLine on CartLine { + id + quantity + attributes { + key + value + } + cost { + totalAmount { + ...Money + } + amountPerQuantity { + ...Money + } + compareAtAmountPerQuantity { + ...Money + } + } + merchandise { + ... on ProductVariant { + id + availableForSale + compareAtPrice { + ...Money + } + price { + ...Money + } + requiresShipping + title + image { + id + url + altText + width + height + + } + product { + handle + title + id + vendor + } + selectedOptions { + name + value + } + quantityRule { + maximum + minimum + increment + } + quantityPriceBreaks(first: 5) { + nodes { + minimumQuantity + price { + amount + currencyCode + } + } + } + } + } + } + fragment CartApiQuery on Cart { + updatedAt + id + checkoutUrl + totalQuantity + buyerIdentity { + countryCode + customer { + id + email + firstName + lastName + displayName + } + email + phone + } + lines(first: $numCartLines) { + nodes { + ...CartLine + } + } + cost { + subtotalAmount { + ...Money + } + totalAmount { + ...Money + } + totalDutyAmount { + ...Money + } + totalTaxAmount { + ...Money + } + } + note + attributes { + key + value + } + discountCodes { + code + applicable + } + } +` as const; diff --git a/examples/b2b/app/remix.env.d.ts b/examples/b2b/app/remix.env.d.ts new file mode 100644 index 0000000000..b030748cbf --- /dev/null +++ b/examples/b2b/app/remix.env.d.ts @@ -0,0 +1,53 @@ +/// +/// +/// + +// Enhance TypeScript's built-in typings. +import '@total-typescript/ts-reset'; + +import type { + Storefront, + CustomerAccount, + HydrogenCart, + HydrogenSessionData, +} from '@shopify/hydrogen'; +import type {AppSession} from '~/lib/session'; + +declare global { + /** + * A global `process` object is only available during build to access NODE_ENV. + */ + const process: {env: {NODE_ENV: 'production' | 'development'}}; + + /** + * Declare expected Env parameter in fetch handler. + */ + interface Env { + SESSION_SECRET: string; + PUBLIC_STOREFRONT_API_TOKEN: string; + PRIVATE_STOREFRONT_API_TOKEN: string; + PUBLIC_STORE_DOMAIN: string; + PUBLIC_STOREFRONT_ID: string; + PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; + PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; + } +} + +declare module '@shopify/remix-oxygen' { + /** + * Declare local additions to the Remix loader context. + */ + interface AppLoadContext { + env: Env; + cart: HydrogenCart; + storefront: Storefront; + customerAccount: CustomerAccount; + session: AppSession; + waitUntil: ExecutionContext['waitUntil']; + } + + /** + * Declare local additions to the Remix session data. + */ + interface SessionData extends HydrogenSessionData {} +} diff --git a/examples/b2b/app/root.tsx b/examples/b2b/app/root.tsx new file mode 100644 index 0000000000..e6e6a25af4 --- /dev/null +++ b/examples/b2b/app/root.tsx @@ -0,0 +1,277 @@ +import {useNonce} from '@shopify/hydrogen'; +import { + defer, + type SerializeFrom, + type LoaderFunctionArgs, +} from '@shopify/remix-oxygen'; +import { + Links, + Meta, + Outlet, + Scripts, + useMatches, + useRouteError, + useLoaderData, + ScrollRestoration, + isRouteErrorResponse, + type ShouldRevalidateFunction, +} from '@remix-run/react'; +import favicon from './assets/favicon.svg'; +import resetStyles from './styles/reset.css?url'; +import appStyles from './styles/app.css?url'; +import {Layout} from '~/components/Layout'; +import {B2BLocationProvider} from '~/components/B2BLocationProvider'; +import {B2BLocationSelector} from '~/components/B2BLocationSelector'; +import type { + Company, + CompanyAddress, + CompanyLocation, + Maybe, +} from '@shopify/hydrogen/customer-account-api-types'; + +/** + * This is important to avoid re-fetching root queries on sub-navigations + */ +export const shouldRevalidate: ShouldRevalidateFunction = ({ + formMethod, + currentUrl, + nextUrl, +}) => { + // revalidate when a mutation is performed e.g add to cart, login... + if (formMethod && formMethod !== 'GET') { + return true; + } + + // revalidate when manually revalidating via useRevalidator + if (currentUrl.toString() === nextUrl.toString()) { + return true; + } + + return false; +}; + +export function links() { + return [ + {rel: 'stylesheet', href: resetStyles}, + {rel: 'stylesheet', href: appStyles}, + { + rel: 'preconnect', + href: 'https://cdn.shopify.com', + }, + { + rel: 'preconnect', + href: 'https://shop.app', + }, + {rel: 'icon', type: 'image/svg+xml', href: favicon}, + ]; +} + +export type CustomerCompanyLocation = Pick & { + shippingAddress?: + | Maybe> + | undefined; +}; + +export type CustomerCompanyLocationConnection = { + node: CustomerCompanyLocation; +}; + +export type CustomerCompany = + | Maybe< + Pick & { + locations: { + edges: CustomerCompanyLocationConnection[]; + }; + } + > + | undefined; + +/** + * Access the result of the root loader from a React component. + */ +export const useRootLoaderData = () => { + const [root] = useMatches(); + return root?.data as SerializeFrom; +}; + +export async function loader({context}: LoaderFunctionArgs) { + const {storefront, customerAccount, cart} = context; + const publicStoreDomain = context.env.PUBLIC_STORE_DOMAIN; + + const isLoggedInPromise = customerAccount.isLoggedIn(); + const cartPromise = cart.get(); + + // defer the footer query (below the fold) + const footerPromise = storefront.query(FOOTER_QUERY, { + cache: storefront.CacheLong(), + variables: { + footerMenuHandle: 'footer', // Adjust to your footer menu handle + }, + }); + + // await the header query (above the fold) + const headerPromise = storefront.query(HEADER_QUERY, { + cache: storefront.CacheLong(), + variables: { + headerMenuHandle: 'main-menu', // Adjust to your header menu handle + }, + }); + + return defer( + { + cart: cartPromise, + footer: footerPromise, + header: await headerPromise, + isLoggedIn: isLoggedInPromise, + publicStoreDomain, + }, + { + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }, + ); +} + +export default function App() { + const nonce = useNonce(); + const data = useLoaderData(); + + return ( + + + + + + + + + { + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + + + + + + + + + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ + } + + + ); +} + +export function ErrorBoundary() { + const error = useRouteError(); + const rootData = useRootLoaderData(); + const nonce = useNonce(); + let errorMessage = 'Unknown error'; + let errorStatus = 500; + + if (isRouteErrorResponse(error)) { + errorMessage = error?.data?.message ?? error.data; + errorStatus = error.status; + } else if (error instanceof Error) { + errorMessage = error.message; + } + + return ( + + + + + + + + + +
    +

    Oops

    +

    {errorStatus}

    + {errorMessage && ( +
    +
    {errorMessage}
    +
    + )} +
    +
    + + + + + ); +} + +const MENU_FRAGMENT = `#graphql + fragment MenuItem on MenuItem { + id + resourceId + tags + title + type + url + } + fragment ChildMenuItem on MenuItem { + ...MenuItem + } + fragment ParentMenuItem on MenuItem { + ...MenuItem + items { + ...ChildMenuItem + } + } + fragment Menu on Menu { + id + items { + ...ParentMenuItem + } + } +` as const; + +const HEADER_QUERY = `#graphql + fragment Shop on Shop { + id + name + description + primaryDomain { + url + } + brand { + logo { + image { + url + } + } + } + } + query Header( + $country: CountryCode + $headerMenuHandle: String! + $language: LanguageCode + ) @inContext(language: $language, country: $country) { + shop { + ...Shop + } + menu(handle: $headerMenuHandle) { + ...Menu + } + } + ${MENU_FRAGMENT} +` as const; + +const FOOTER_QUERY = `#graphql + query Footer( + $country: CountryCode + $footerMenuHandle: String! + $language: LanguageCode + ) @inContext(language: $language, country: $country) { + menu(handle: $footerMenuHandle) { + ...Menu + } + } + ${MENU_FRAGMENT} +` as const; diff --git a/examples/b2b/app/routes/account_.logout.tsx b/examples/b2b/app/routes/account_.logout.tsx new file mode 100644 index 0000000000..45e2710c0f --- /dev/null +++ b/examples/b2b/app/routes/account_.logout.tsx @@ -0,0 +1,18 @@ +import {redirect, type ActionFunctionArgs} from '@shopify/remix-oxygen'; + +// if we dont implement this, /account/logout will get caught by account.$.tsx to do login +export async function loader() { + return redirect('/'); +} + +export async function action({context}: ActionFunctionArgs) { + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + await context.cart.updateBuyerIdentity({ + companyLocationId: null, + customerAccessToken: null, + }); + /********** EXAMPLE UPDATE END *************/ + /***********************************************/ + return context.customerAccount.logout(); +} diff --git a/examples/b2b/app/routes/b2blocations.tsx b/examples/b2b/app/routes/b2blocations.tsx new file mode 100644 index 0000000000..3189765692 --- /dev/null +++ b/examples/b2b/app/routes/b2blocations.tsx @@ -0,0 +1,47 @@ +import {defer, type LoaderFunctionArgs} from '@shopify/remix-oxygen'; +import {useLoaderData} from '@remix-run/react'; +import {B2BLocationSelector} from '../components/B2BLocationSelector'; +import {CUSTOMER_LOCATIONS_QUERY} from '~/graphql/customer-account/CustomerLocationsQuery'; + +export async function loader({context}: LoaderFunctionArgs) { + const {customerAccount} = context; + + const buyer = await customerAccount.UNSTABLE_getBuyer(); + + let companyLocationId = buyer?.companyLocationId || null; + let company = null; + + // Check if logged in customer is a b2b customer + if (buyer) { + const customer = await customerAccount.query(CUSTOMER_LOCATIONS_QUERY); + company = + customer?.data?.customer?.companyContacts?.edges?.[0]?.node?.company || + null; + } + + // If there is only 1 company location, set it in session + if (!companyLocationId && company?.locations?.edges?.length === 1) { + companyLocationId = company.locations.edges[0].node.id; + + customerAccount.UNSTABLE_setBuyer({ + companyLocationId, + }); + } + + const modalOpen = Boolean(company) && !companyLocationId; + + return defer( + {company, companyLocationId, modalOpen}, + { + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }, + ); +} + +export default function CartRoute() { + const {company} = useLoaderData(); + + return ; +} diff --git a/examples/b2b/app/routes/products.$handle.tsx b/examples/b2b/app/routes/products.$handle.tsx new file mode 100644 index 0000000000..83d502eddf --- /dev/null +++ b/examples/b2b/app/routes/products.$handle.tsx @@ -0,0 +1,519 @@ +import {Suspense} from 'react'; +import {defer, redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen'; +import { + Await, + Link, + useLoaderData, + type MetaFunction, + type FetcherWithComponents, +} from '@remix-run/react'; +import type { + ProductFragment, + ProductVariantsQuery, + ProductVariantFragment, +} from 'storefrontapi.generated'; + +import { + Image, + Money, + VariantSelector, + type VariantOption, + getSelectedProductOptions, + CartForm, +} from '@shopify/hydrogen'; +import type { + CartLineInput, + SelectedOption, +} from '@shopify/hydrogen/storefront-api-types'; +import {getVariantUrl} from '~/lib/variants'; +import {QuantityRules, hasQuantityRules} from '~/components/QuantityRules'; +import {PriceBreaks} from '~/components/PriceBreaks'; + +export const meta: MetaFunction = ({data}) => { + return [{title: `Hydrogen | ${data?.product.title ?? ''}`}]; +}; + +export async function loader({params, request, context}: LoaderFunctionArgs) { + const {handle} = params; + const {storefront, customerAccount} = context; + + const selectedOptions = getSelectedProductOptions(request).filter( + (option) => + // Filter out Shopify predictive search query params + !option.name.startsWith('_sid') && + !option.name.startsWith('_pos') && + !option.name.startsWith('_psq') && + !option.name.startsWith('_ss') && + !option.name.startsWith('_v') && + // Filter out third party tracking params + !option.name.startsWith('fbclid'), + ); + + if (!handle) { + throw new Error('Expected product handle to be defined'); + } + + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + const buyer = await customerAccount.UNSTABLE_getBuyer(); + + const buyerVariables = + buyer?.companyLocationId && buyer?.customerAccessToken + ? { + buyer: { + companyLocationId: buyer.companyLocationId, + customerAccessToken: buyer.customerAccessToken, + }, + } + : {}; + + // await the query for the critical product data + const {product} = await storefront.query(PRODUCT_QUERY, { + variables: {handle, selectedOptions, ...buyerVariables}, + cache: storefront.CacheNone(), + }); + /********** EXAMPLE UPDATE END *************/ + /***********************************************/ + + 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 no selected variant was returned from the selected options, + // we redirect to the first variant's url with it's selected options applied + if (!product.selectedVariant) { + throw redirectToFirstVariant({product, request}); + } + } + + // 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 deffered query resolves, the UI will update. + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + const variants = storefront.query(VARIANTS_QUERY, { + variables: {handle, ...buyerVariables}, + cache: storefront.CacheNone(), + }); + /********** EXAMPLE UPDATE END *************/ + /***********************************************/ + + return defer({product, variants}); +} + +function redirectToFirstVariant({ + product, + request, +}: { + product: ProductFragment; + request: Request; +}) { + const url = new URL(request.url); + const firstVariant = product.variants.nodes[0]; + + return redirect( + getVariantUrl({ + pathname: url.pathname, + handle: product.handle, + selectedOptions: firstVariant.selectedOptions, + searchParams: new URLSearchParams(url.search), + }), + { + status: 302, + }, + ); +} + +export default function Product() { + const {product, variants} = useLoaderData(); + const {selectedVariant} = product; + return ( +
    + + +
    + ); +} + +function ProductImage({image}: {image: ProductVariantFragment['image']}) { + if (!image) { + return
    ; + } + return ( +
    + {image.altText +
    + ); +} + +function ProductMain({ + selectedVariant, + product, + variants, +}: { + product: ProductFragment; + selectedVariant: ProductFragment['selectedVariant']; + variants: Promise; +}) { + const {title, descriptionHtml} = product; + + return ( +
    +

    {title}

    + +
    + + } + > + + {(data) => ( + + )} + + +
    + { + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + hasQuantityRules(selectedVariant?.quantityRule) ? ( + + ) : null + } +
    + { + selectedVariant?.quantityPriceBreaks?.nodes && + selectedVariant?.quantityPriceBreaks?.nodes?.length > 0 ? ( + + ) : null + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ + } +
    +

    + Description +

    +
    +
    +
    +
    + ); +} + +function ProductPrice({ + selectedVariant, +}: { + selectedVariant: ProductFragment['selectedVariant']; +}) { + return ( +
    + {selectedVariant?.compareAtPrice ? ( + <> +

    Sale

    +
    +
    + {selectedVariant ? : null} + + + +
    + + ) : ( + selectedVariant?.price && + )} +
    + ); +} + +/***********************************************/ +/********** EXAMPLE UPDATE STARTS ************/ +function ProductForm({ + product, + selectedVariant, + variants, + quantity, +}: { + product: ProductFragment; + selectedVariant: ProductFragment['selectedVariant']; + variants: Array; + quantity: number; +}) { + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ + return ( +
    + + {({option}) => } + +
    + { + window.location.href = window.location.href + '#cart-aside'; + }} + lines={ + selectedVariant + ? [ + { + merchandiseId: selectedVariant.id, + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + quantity, + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ + }, + ] + : [] + } + > + {selectedVariant?.availableForSale ? 'Add to cart' : 'Sold out'} + +
    + ); +} + +function ProductOptions({option}: {option: VariantOption}) { + return ( +
    +
    {option.name}
    +
    + {option.values.map(({value, isAvailable, isActive, to}) => { + return ( + + {value} + + ); + })} +
    +
    +
    + ); +} + +function AddToCartButton({ + analytics, + children, + disabled, + lines, + onClick, +}: { + analytics?: unknown; + children: React.ReactNode; + disabled?: boolean; + lines: CartLineInput[]; + onClick?: () => void; +}) { + return ( + + {(fetcher: FetcherWithComponents) => ( + <> + + + + )} + + ); +} + +/***********************************************/ +/********** EXAMPLE UPDATE STARTS ************/ +const PRODUCT_VARIANT_FRAGMENT = `#graphql + fragment ProductVariant on ProductVariant { + availableForSale + compareAtPrice { + amount + currencyCode + } + id + image { + __typename + id + url + altText + width + height + } + price { + amount + currencyCode + } + product { + title + handle + } + selectedOptions { + name + value + } + quantityRule { + maximum + minimum + increment + } + quantityPriceBreaks(first: 5) { + nodes { + minimumQuantity + price { + amount + currencyCode + } + } + } + sku + title + unitPrice { + amount + currencyCode + } + } +` as const; +/********** EXAMPLE UPDATE END ************/ +/***********************************************/ + +const PRODUCT_FRAGMENT = `#graphql + fragment Product on Product { + id + title + vendor + handle + descriptionHtml + description + options { + name + values + } + selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) { + ...ProductVariant + } + variants(first: 1) { + nodes { + ...ProductVariant + } + } + seo { + description + title + } + } + ${PRODUCT_VARIANT_FRAGMENT} +` as const; + +/***********************************************/ +/********** EXAMPLE UPDATE STARTS ************/ +const PRODUCT_QUERY = `#graphql + query Product( + $country: CountryCode + $buyer: BuyerInput + $handle: String! + $language: LanguageCode + $selectedOptions: [SelectedOptionInput!]! + ) @inContext(country: $country, language: $language, buyer: $buyer) { + product(handle: $handle) { + ...Product + } + } + ${PRODUCT_FRAGMENT} +` as const; +/********** EXAMPLE UPDATE END ************/ +/***********************************************/ + +const PRODUCT_VARIANTS_FRAGMENT = `#graphql + fragment ProductVariants on Product { + variants(first: 250) { + nodes { + ...ProductVariant + } + } + } + ${PRODUCT_VARIANT_FRAGMENT} +` as const; + +/***********************************************/ +/********** EXAMPLE UPDATE STARTS ************/ +const VARIANTS_QUERY = `#graphql + ${PRODUCT_VARIANTS_FRAGMENT} + query ProductVariants( + $country: CountryCode + $buyer: BuyerInput + $language: LanguageCode + $handle: String! + ) @inContext(country: $country, language: $language, buyer: $buyer) { + product(handle: $handle) { + ...ProductVariants + } + } +` as const; +/********** EXAMPLE UPDATE END ************/ +/***********************************************/ diff --git a/examples/b2b/app/styles/app.css b/examples/b2b/app/styles/app.css new file mode 100644 index 0000000000..ef3672ee0f --- /dev/null +++ b/examples/b2b/app/styles/app.css @@ -0,0 +1,537 @@ +:root { + --aside-width: 400px; + --cart-aside-summary-height-with-discount: 300px; + --cart-aside-summary-height: 250px; + --grid-item-width: 355px; + --header-height: 64px; + --color-dark: #000; + --color-light: #fff; +} + +img { + border-radius: 4px; +} + +/* +* -------------------------------------------------- +* components/Aside +* -------------------------------------------------- +*/ +aside { + background: var(--color-light); + box-shadow: 0 0 50px rgba(0, 0, 0, 0.3); + height: 100vh; + max-width: var(--aside-width); + min-width: var(--aside-width); + position: fixed; + right: calc(-1 * var(--aside-width)); + top: 0; + transition: transform 200ms ease-in-out; +} + +aside header { + align-items: center; + border-bottom: 1px solid var(--color-dark); + display: flex; + height: var(--header-height); + justify-content: space-between; + padding: 0 20px; +} + +aside header h3 { + margin: 0; +} + +aside header .close { + font-weight: bold; + opacity: 0.8; + text-decoration: none; + transition: all 200ms; + width: 20px; +} + +aside header .close:hover { + opacity: 1; +} + +aside header h2 { + margin-bottom: 0.6rem; + margin-top: 0; +} + +aside main { + margin: 1rem; +} + +aside p { + margin: 0 0 0.25rem; +} + +aside p:last-child { + margin: 0; +} + +aside li { + margin-bottom: 0.125rem; +} + +.overlay { + background: rgba(0, 0, 0, 0.2); + bottom: 0; + left: 0; + opacity: 0; + pointer-events: none; + position: fixed; + right: 0; + top: 0; + transition: opacity 400ms ease-in-out; + transition: opacity 400ms; + visibility: hidden; + z-index: 10; +} + +.overlay .close-outside { + background: transparent; + border: none; + color: transparent; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: calc(100% - var(--aside-width)); +} + +.overlay .light { + background: rgba(255, 255, 255, 0.5); +} + +.overlay .cancel { + cursor: default; + height: 100%; + position: absolute; + width: 100%; +} + +.overlay:target { + opacity: 1; + pointer-events: auto; + visibility: visible; +} +/* reveal aside */ +.overlay:target aside { + transform: translateX(calc(var(--aside-width) * -1)); +} + +/* +* -------------------------------------------------- +* components/Header +* -------------------------------------------------- +*/ +.header { + align-items: center; + background: #fff; + display: flex; + height: var(--header-height); + padding: 0 1rem; + position: sticky; + top: 0; + z-index: 1; +} + +.header-menu-mobile-toggle { + @media (min-width: 48em) { + display: none; + } +} + +.header-menu-mobile { + display: flex; + flex-direction: column; + grid-gap: 1rem; +} + +.header-menu-desktop { + display: none; + grid-gap: 1rem; + @media (min-width: 45em) { + display: flex; + grid-gap: 1rem; + margin-left: 3rem; + } +} + +.header-menu-item { + cursor: pointer; +} + +.header-ctas { + align-items: center; + display: flex; + grid-gap: 1rem; + margin-left: auto; +} + +/* +* -------------------------------------------------- +* components/Footer +* -------------------------------------------------- +*/ +.footer { + background: var(--color-dark); + margin-top: auto; +} + +.footer-menu { + align-items: center; + display: flex; + grid-gap: 1rem; + padding: 1rem; +} + +.footer-menu a { + color: var(--color-light); +} + +/* +* -------------------------------------------------- +* components/Cart +* -------------------------------------------------- +*/ +.cart-main { + height: 100%; + max-height: calc(100vh - var(--cart-aside-summary-height)); + overflow-y: auto; + width: auto; +} + +.cart-main.with-discount { + max-height: calc(100vh - var(--cart-aside-summary-height-with-discount)); +} + +.cart-line { + display: flex; + padding: 0.75rem 0; +} + +.cart-line img { + height: 100%; + display: block; + margin-right: 0.75rem; +} + +.cart-summary-page { + position: relative; +} + +.cart-summary-aside { + background: white; + border-top: 1px solid var(--color-dark); + bottom: 0; + padding-top: 0.75rem; + position: absolute; + width: calc(var(--aside-width) - 40px); +} + +.cart-line-quantity { + display: flex; +} + +.cart-discount { + align-items: center; + display: flex; + margin-top: 0.25rem; +} + +.cart-subtotal { + align-items: center; + display: flex; +} +/* +* -------------------------------------------------- +* components/Search +* -------------------------------------------------- +*/ +.predictive-search { + height: calc(100vh - var(--header-height) - 40px); + overflow-y: auto; +} + +.predictive-search-form { + background: var(--color-light); + position: sticky; + top: 0; +} + +.predictive-search-result { + margin-bottom: 2rem; +} + +.predictive-search-result h5 { + text-transform: uppercase; +} + +.predictive-search-result-item { + margin-bottom: 0.5rem; +} + +.predictive-search-result-item a { + align-items: center; + display: flex; +} + +.predictive-search-result-item a img { + margin-right: 0.75rem; + height: 100%; +} + +.search-result { + margin-bottom: 1.5rem; +} + +.search-results-item { + margin-bottom: 0.5rem; +} + +.search-results-item a { + display: flex; + flex: row; + align-items: center; + gap: 1rem; +} + + + +/* +* -------------------------------------------------- +* routes/__index +* -------------------------------------------------- +*/ +.featured-collection { + display: block; + margin-bottom: 2rem; + position: relative; +} + +.featured-collection-image { + aspect-ratio: 1 / 1; + @media (min-width: 45em) { + aspect-ratio: 16 / 9; + } +} + +.featured-collection img { + height: auto; + max-height: 100%; + object-fit: cover; +} + +.recommended-products-grid { + display: grid; + grid-gap: 1.5rem; + grid-template-columns: repeat(2, 1fr); + @media (min-width: 45em) { + grid-template-columns: repeat(4, 1fr); + } +} + +.recommended-product img { + height: auto; +} + +/* +* -------------------------------------------------- +* routes/collections._index.tsx +* -------------------------------------------------- +*/ +.collections-grid { + display: grid; + grid-gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(var(--grid-item-width), 1fr)); + margin-bottom: 2rem; +} + +.collection-item img { + height: auto; +} + +/* +* -------------------------------------------------- +* routes/collections.$handle.tsx +* -------------------------------------------------- +*/ +.collection-description { + margin-bottom: 1rem; + max-width: 95%; + @media (min-width: 45em) { + max-width: 600px; + } +} + +.products-grid { + display: grid; + grid-gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(var(--grid-item-width), 1fr)); + margin-bottom: 2rem; +} + +.product-item img { + height: auto; + width: 100%; +} + +/* +* -------------------------------------------------- +* routes/products.$handle.tsx +* -------------------------------------------------- +*/ +.product { + display: grid; + @media (min-width: 45em) { + grid-template-columns: 1fr 1fr; + grid-gap: 4rem; + } +} + +.product h1 { + margin-top: 0; +} + +.product-image img { + height: auto; + width: 100%; +} + +.product-main { + align-self: start; + position: sticky; + top: 6rem; +} + +.product-price-on-sale { + display: flex; + grid-gap: 0.5rem; +} + +.product-price-on-sale s { + opacity: 0.5; +} + +.product-options-grid { + display: flex; + flex-wrap: wrap; + grid-gap: 0.75rem; +} + +.product-options-item { + padding: 0.25rem 0.5rem; +} + +/* +* -------------------------------------------------- +* routes/blog._index.tsx +* -------------------------------------------------- +*/ +.blog-grid { + display: grid; + grid-gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(var(--grid-item-width), 1fr)); + margin-bottom: 2rem; +} + +.blog-article-image { + aspect-ratio: 3/2; + display: block; +} + +.blog-article-image img { + height: 100%; +} + +/* +* -------------------------------------------------- +* routes/blog.$articlehandle.tsx +* -------------------------------------------------- +*/ +.article img { + height: auto; + width: 100%; +} + +/* +* -------------------------------------------------- +* routes/account +* -------------------------------------------------- +*/ + +.account-logout { + display: inline-block; +} + +/***********************************************/ +/********** EXAMPLE UPDATE STARTS ************/ +/* +* -------------------------------------------------- +* Quantity Rules and price breaks +* -------------------------------------------------- +*/ + +.rule-table { + border-spacing: 0; + border: 0.5px darkgrey solid; +} + +.table-haeading { + padding: 8px; + background: #eee; + border: 0.5px darkgrey solid; +} + +.table-item { + padding: 8px; + border: 0.5px darkgrey solid; + font-weight: 400; +} + +/* +* -------------------------------------------------- +* Location Selector Modal +* -------------------------------------------------- +*/ +.modal { + position: fixed; + display: flex; + z-index: 1; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.4); +} + +.modal-content { + background-color: #fefefe; + overflow: auto; + margin: auto; + border: 1px solid #888; + border-radius: 4px; + width: fit-content; + max-height: 80%; + padding: 20px +} + +.location-list { + display: grid; + grid-template-columns: auto; + gap: 8px; +} + +.location-item { + background: none; + border: 1px solid; + width: 100%; + display: flex; + text-align: left; + cursor: pointer; +} +/********** EXAMPLE UPDATE END ************/ +/***********************************************/ \ No newline at end of file diff --git a/examples/b2b/customer-accountapi.generated.d.ts b/examples/b2b/customer-accountapi.generated.d.ts new file mode 100644 index 0000000000..94b32b2ae0 --- /dev/null +++ b/examples/b2b/customer-accountapi.generated.d.ts @@ -0,0 +1,550 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ +import * as CustomerAccountAPI from '@shopify/hydrogen/customer-account-api-types'; + +export type CustomerAddressUpdateMutationVariables = CustomerAccountAPI.Exact<{ + address: CustomerAccountAPI.CustomerAddressInput; + addressId: CustomerAccountAPI.Scalars['ID']['input']; + defaultAddress?: CustomerAccountAPI.InputMaybe< + CustomerAccountAPI.Scalars['Boolean']['input'] + >; +}>; + +export type CustomerAddressUpdateMutation = { + customerAddressUpdate?: CustomerAccountAPI.Maybe<{ + customerAddress?: CustomerAccountAPI.Maybe< + Pick + >; + userErrors: Array< + Pick< + CustomerAccountAPI.UserErrorsCustomerAddressUserErrors, + 'code' | 'field' | 'message' + > + >; + }>; +}; + +export type CustomerAddressDeleteMutationVariables = CustomerAccountAPI.Exact<{ + addressId: CustomerAccountAPI.Scalars['ID']['input']; +}>; + +export type CustomerAddressDeleteMutation = { + customerAddressDelete?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.CustomerAddressDeletePayload, + 'deletedAddressId' + > & { + userErrors: Array< + Pick< + CustomerAccountAPI.UserErrorsCustomerAddressUserErrors, + 'code' | 'field' | 'message' + > + >; + } + >; +}; + +export type CustomerAddressCreateMutationVariables = CustomerAccountAPI.Exact<{ + address: CustomerAccountAPI.CustomerAddressInput; + defaultAddress?: CustomerAccountAPI.InputMaybe< + CustomerAccountAPI.Scalars['Boolean']['input'] + >; +}>; + +export type CustomerAddressCreateMutation = { + customerAddressCreate?: CustomerAccountAPI.Maybe<{ + customerAddress?: CustomerAccountAPI.Maybe< + Pick + >; + userErrors: Array< + Pick< + CustomerAccountAPI.UserErrorsCustomerAddressUserErrors, + 'code' | 'field' | 'message' + > + >; + }>; +}; + +export type CustomerFragment = Pick< + CustomerAccountAPI.Customer, + 'id' | 'firstName' | 'lastName' +> & { + defaultAddress?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.CustomerAddress, + | 'id' + | 'formatted' + | 'firstName' + | 'lastName' + | 'company' + | 'address1' + | 'address2' + | 'territoryCode' + | 'zoneCode' + | 'city' + | 'zip' + | 'phoneNumber' + > + >; + addresses: { + nodes: Array< + Pick< + CustomerAccountAPI.CustomerAddress, + | 'id' + | 'formatted' + | 'firstName' + | 'lastName' + | 'company' + | 'address1' + | 'address2' + | 'territoryCode' + | 'zoneCode' + | 'city' + | 'zip' + | 'phoneNumber' + > + >; + }; +}; + +export type AddressFragment = Pick< + CustomerAccountAPI.CustomerAddress, + | 'id' + | 'formatted' + | 'firstName' + | 'lastName' + | 'company' + | 'address1' + | 'address2' + | 'territoryCode' + | 'zoneCode' + | 'city' + | 'zip' + | 'phoneNumber' +>; + +export type CustomerDetailsQueryVariables = CustomerAccountAPI.Exact<{ + [key: string]: never; +}>; + +export type CustomerDetailsQuery = { + customer: Pick< + CustomerAccountAPI.Customer, + 'id' | 'firstName' | 'lastName' + > & { + defaultAddress?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.CustomerAddress, + | 'id' + | 'formatted' + | 'firstName' + | 'lastName' + | 'company' + | 'address1' + | 'address2' + | 'territoryCode' + | 'zoneCode' + | 'city' + | 'zip' + | 'phoneNumber' + > + >; + addresses: { + nodes: Array< + Pick< + CustomerAccountAPI.CustomerAddress, + | 'id' + | 'formatted' + | 'firstName' + | 'lastName' + | 'company' + | 'address1' + | 'address2' + | 'territoryCode' + | 'zoneCode' + | 'city' + | 'zip' + | 'phoneNumber' + > + >; + }; + }; +}; + +export type CustomerLocationsQueryVariables = CustomerAccountAPI.Exact<{ + [key: string]: never; +}>; + +export type CustomerLocationsQuery = { + customer: Pick & { + emailAddress?: CustomerAccountAPI.Maybe< + Pick + >; + companyContacts: { + edges: Array<{ + node: { + company?: CustomerAccountAPI.Maybe< + Pick & { + locations: { + edges: Array<{ + node: Pick< + CustomerAccountAPI.CompanyLocation, + 'id' | 'name' + > & { + shippingAddress?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.CompanyAddress, + 'countryCode' | 'formattedAddress' + > + >; + }; + }>; + }; + } + >; + }; + }>; + }; + }; +}; + +export type OrderMoneyFragment = Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' +>; + +export type DiscountApplicationFragment = { + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); +}; + +export type OrderLineItemFullFragment = Pick< + CustomerAccountAPI.LineItem, + 'id' | 'title' | 'quantity' | 'variantTitle' +> & { + price?: CustomerAccountAPI.Maybe< + Pick + >; + discountAllocations: Array<{ + allocatedAmount: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + discountApplication: { + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); + }; + }>; + totalDiscount: Pick; + image?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.Image, + 'altText' | 'height' | 'url' | 'id' | 'width' + > + >; +}; + +export type OrderFragment = Pick< + CustomerAccountAPI.Order, + 'id' | 'name' | 'statusPageUrl' | 'processedAt' +> & { + fulfillments: {nodes: Array>}; + totalTax?: CustomerAccountAPI.Maybe< + Pick + >; + totalPrice: Pick; + subtotal?: CustomerAccountAPI.Maybe< + Pick + >; + shippingAddress?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.CustomerAddress, + 'name' | 'formatted' | 'formattedArea' + > + >; + discountApplications: { + nodes: Array<{ + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); + }>; + }; + lineItems: { + nodes: Array< + Pick< + CustomerAccountAPI.LineItem, + 'id' | 'title' | 'quantity' | 'variantTitle' + > & { + price?: CustomerAccountAPI.Maybe< + Pick + >; + discountAllocations: Array<{ + allocatedAmount: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + discountApplication: { + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); + }; + }>; + totalDiscount: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + image?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.Image, + 'altText' | 'height' | 'url' | 'id' | 'width' + > + >; + } + >; + }; +}; + +export type OrderQueryVariables = CustomerAccountAPI.Exact<{ + orderId: CustomerAccountAPI.Scalars['ID']['input']; +}>; + +export type OrderQuery = { + order?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.Order, + 'id' | 'name' | 'statusPageUrl' | 'processedAt' + > & { + fulfillments: { + nodes: Array>; + }; + totalTax?: CustomerAccountAPI.Maybe< + Pick + >; + totalPrice: Pick; + subtotal?: CustomerAccountAPI.Maybe< + Pick + >; + shippingAddress?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.CustomerAddress, + 'name' | 'formatted' | 'formattedArea' + > + >; + discountApplications: { + nodes: Array<{ + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); + }>; + }; + lineItems: { + nodes: Array< + Pick< + CustomerAccountAPI.LineItem, + 'id' | 'title' | 'quantity' | 'variantTitle' + > & { + price?: CustomerAccountAPI.Maybe< + Pick + >; + discountAllocations: Array<{ + allocatedAmount: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + discountApplication: { + value: + | ({__typename: 'MoneyV2'} & Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >) + | ({__typename: 'PricingPercentageValue'} & Pick< + CustomerAccountAPI.PricingPercentageValue, + 'percentage' + >); + }; + }>; + totalDiscount: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + image?: CustomerAccountAPI.Maybe< + Pick< + CustomerAccountAPI.Image, + 'altText' | 'height' | 'url' | 'id' | 'width' + > + >; + } + >; + }; + } + >; +}; + +export type OrderItemFragment = Pick< + CustomerAccountAPI.Order, + 'financialStatus' | 'id' | 'number' | 'processedAt' +> & { + totalPrice: Pick; + fulfillments: {nodes: Array>}; +}; + +export type CustomerOrdersFragment = { + orders: { + nodes: Array< + Pick< + CustomerAccountAPI.Order, + 'financialStatus' | 'id' | 'number' | 'processedAt' + > & { + totalPrice: Pick; + fulfillments: { + nodes: Array>; + }; + } + >; + pageInfo: Pick< + CustomerAccountAPI.PageInfo, + 'hasPreviousPage' | 'hasNextPage' | 'endCursor' | 'startCursor' + >; + }; +}; + +export type CustomerOrdersQueryVariables = CustomerAccountAPI.Exact<{ + endCursor?: CustomerAccountAPI.InputMaybe< + CustomerAccountAPI.Scalars['String']['input'] + >; + first?: CustomerAccountAPI.InputMaybe< + CustomerAccountAPI.Scalars['Int']['input'] + >; + last?: CustomerAccountAPI.InputMaybe< + CustomerAccountAPI.Scalars['Int']['input'] + >; + startCursor?: CustomerAccountAPI.InputMaybe< + CustomerAccountAPI.Scalars['String']['input'] + >; +}>; + +export type CustomerOrdersQuery = { + customer: { + orders: { + nodes: Array< + Pick< + CustomerAccountAPI.Order, + 'financialStatus' | 'id' | 'number' | 'processedAt' + > & { + totalPrice: Pick< + CustomerAccountAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + fulfillments: { + nodes: Array>; + }; + } + >; + pageInfo: Pick< + CustomerAccountAPI.PageInfo, + 'hasPreviousPage' | 'hasNextPage' | 'endCursor' | 'startCursor' + >; + }; + }; +}; + +export type CustomerUpdateMutationVariables = CustomerAccountAPI.Exact<{ + customer: CustomerAccountAPI.CustomerUpdateInput; +}>; + +export type CustomerUpdateMutation = { + customerUpdate?: CustomerAccountAPI.Maybe<{ + customer?: CustomerAccountAPI.Maybe< + Pick & { + emailAddress?: CustomerAccountAPI.Maybe< + Pick + >; + phoneNumber?: CustomerAccountAPI.Maybe< + Pick + >; + } + >; + userErrors: Array< + Pick< + CustomerAccountAPI.UserErrorsCustomerUserErrors, + 'code' | 'field' | 'message' + > + >; + }>; +}; + +interface GeneratedQueryTypes { + '#graphql\n query CustomerDetails {\n customer {\n ...Customer\n }\n }\n #graphql\n fragment Customer on Customer {\n id\n firstName\n lastName\n defaultAddress {\n ...Address\n }\n addresses(first: 6) {\n nodes {\n ...Address\n }\n }\n }\n fragment Address on CustomerAddress {\n id\n formatted\n firstName\n lastName\n company\n address1\n address2\n territoryCode\n zoneCode\n city\n zip\n phoneNumber\n }\n\n': { + return: CustomerDetailsQuery; + variables: CustomerDetailsQueryVariables; + }; + '#graphql\n query CustomerLocations {\n customer {\n id\n emailAddress {\n emailAddress\n }\n companyContacts(first: 1){\n edges{\n node{\n company{\n id\n name\n locations(first: 10){\n edges{\n node{\n id\n name\n shippingAddress {\n countryCode\n formattedAddress\n }\n }\n }\n }\n }\n }\n }\n }\n }\n }\n': { + return: CustomerLocationsQuery; + variables: CustomerLocationsQueryVariables; + }; + '#graphql\n fragment OrderMoney on MoneyV2 {\n amount\n currencyCode\n }\n fragment DiscountApplication on DiscountApplication {\n value {\n __typename\n ... on MoneyV2 {\n ...OrderMoney\n }\n ... on PricingPercentageValue {\n percentage\n }\n }\n }\n fragment OrderLineItemFull on LineItem {\n id\n title\n quantity\n price {\n ...OrderMoney\n }\n discountAllocations {\n allocatedAmount {\n ...OrderMoney\n }\n discountApplication {\n ...DiscountApplication\n }\n }\n totalDiscount {\n ...OrderMoney\n }\n image {\n altText\n height\n url\n id\n width\n }\n variantTitle\n }\n fragment Order on Order {\n id\n name\n statusPageUrl\n processedAt\n fulfillments(first: 1) {\n nodes {\n status\n }\n }\n totalTax {\n ...OrderMoney\n }\n totalPrice {\n ...OrderMoney\n }\n subtotal {\n ...OrderMoney\n }\n shippingAddress {\n name\n formatted(withName: true)\n formattedArea\n }\n discountApplications(first: 100) {\n nodes {\n ...DiscountApplication\n }\n }\n lineItems(first: 100) {\n nodes {\n ...OrderLineItemFull\n }\n }\n }\n query Order($orderId: ID!) {\n order(id: $orderId) {\n ... on Order {\n ...Order\n }\n }\n }\n': { + return: OrderQuery; + variables: OrderQueryVariables; + }; + '#graphql\n #graphql\n fragment CustomerOrders on Customer {\n orders(\n sortKey: PROCESSED_AT,\n reverse: true,\n first: $first,\n last: $last,\n before: $startCursor,\n after: $endCursor\n ) {\n nodes {\n ...OrderItem\n }\n pageInfo {\n hasPreviousPage\n hasNextPage\n endCursor\n startCursor\n }\n }\n }\n #graphql\n fragment OrderItem on Order {\n totalPrice {\n amount\n currencyCode\n }\n financialStatus\n fulfillments(first: 1) {\n nodes {\n status\n }\n }\n id\n number\n processedAt\n }\n\n\n query CustomerOrders(\n $endCursor: String\n $first: Int\n $last: Int\n $startCursor: String\n ) {\n customer {\n ...CustomerOrders\n }\n }\n': { + return: CustomerOrdersQuery; + variables: CustomerOrdersQueryVariables; + }; +} + +interface GeneratedMutationTypes { + '#graphql\n mutation customerAddressUpdate(\n $address: CustomerAddressInput!\n $addressId: ID!\n $defaultAddress: Boolean\n ) {\n customerAddressUpdate(\n address: $address\n addressId: $addressId\n defaultAddress: $defaultAddress\n ) {\n customerAddress {\n id\n }\n userErrors {\n code\n field\n message\n }\n }\n }\n': { + return: CustomerAddressUpdateMutation; + variables: CustomerAddressUpdateMutationVariables; + }; + '#graphql\n mutation customerAddressDelete(\n $addressId: ID!,\n ) {\n customerAddressDelete(addressId: $addressId) {\n deletedAddressId\n userErrors {\n code\n field\n message\n }\n }\n }\n': { + return: CustomerAddressDeleteMutation; + variables: CustomerAddressDeleteMutationVariables; + }; + '#graphql\n mutation customerAddressCreate(\n $address: CustomerAddressInput!\n $defaultAddress: Boolean\n ) {\n customerAddressCreate(\n address: $address\n defaultAddress: $defaultAddress\n ) {\n customerAddress {\n id\n }\n userErrors {\n code\n field\n message\n }\n }\n }\n': { + return: CustomerAddressCreateMutation; + variables: CustomerAddressCreateMutationVariables; + }; + '#graphql\n # https://shopify.dev/docs/api/customer/latest/mutations/customerUpdate\n mutation customerUpdate(\n $customer: CustomerUpdateInput!\n ){\n customerUpdate(input: $customer) {\n customer {\n firstName\n lastName\n emailAddress {\n emailAddress\n }\n phoneNumber {\n phoneNumber\n }\n }\n userErrors {\n code\n field\n message\n }\n }\n }\n': { + return: CustomerUpdateMutation; + variables: CustomerUpdateMutationVariables; + }; +} + +declare module '@shopify/hydrogen' { + interface CustomerAccountQueries extends GeneratedQueryTypes {} + interface CustomerAccountMutations extends GeneratedMutationTypes {} +} diff --git a/examples/b2b/env.d.ts b/examples/b2b/env.d.ts new file mode 100644 index 0000000000..34a3e371d2 --- /dev/null +++ b/examples/b2b/env.d.ts @@ -0,0 +1,53 @@ +/// +/// +/// + +// Enhance TypeScript's built-in typings. +import '@total-typescript/ts-reset'; + +import type { + Storefront, + CustomerAccount, + HydrogenCart, + HydrogenSessionData, +} from '@shopify/hydrogen'; +import type {AppSession} from '~/lib/session'; + +declare global { + /** + * A global `process` object is only available during build to access NODE_ENV. + */ + const process: {env: {NODE_ENV: 'production' | 'development'}}; + + /** + * Declare expected Env parameter in fetch handler. + */ + interface Env { + SESSION_SECRET: string; + PUBLIC_STOREFRONT_API_TOKEN: string; + PRIVATE_STOREFRONT_API_TOKEN: string; + PUBLIC_STORE_DOMAIN: string; + PUBLIC_STOREFRONT_ID: string; + PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; + PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; + } +} + +declare module '@shopify/remix-oxygen' { + /** + * Declare local additions to the Remix loader context. + */ + interface AppLoadContext { + env: Env; + cart: HydrogenCart; + storefront: Storefront; + customerAccount: CustomerAccount; + session: AppSession; + waitUntil: ExecutionContext['waitUntil']; + } + + /** + * Declare local additions to the Remix session data. + */ + interface SessionData extends HydrogenSessionData {} +} diff --git a/examples/b2b/package.json b/examples/b2b/package.json new file mode 100644 index 0000000000..2c909beb8d --- /dev/null +++ b/examples/b2b/package.json @@ -0,0 +1,16 @@ +{ + "name": "example-b2b", + "private": true, + "prettier": "@shopify/prettier-config", + "scripts": { + "build": "shopify hydrogen build --diff", + "dev": "shopify hydrogen dev --codegen --diff", + "preview": "npm run build && shopify hydrogen preview", + "lint": "eslint --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx .", + "typecheck": "tsc --noEmit", + "codegen": "shopify hydrogen codegen" + }, + "dependencies": { + "@shopify/cli-hydrogen": "*" + } +} diff --git a/examples/b2b/server.ts b/examples/b2b/server.ts new file mode 100644 index 0000000000..84e0c87f47 --- /dev/null +++ b/examples/b2b/server.ts @@ -0,0 +1,120 @@ +// @ts-ignore +// Virtual entry point for the app +import * as remixBuild from 'virtual:remix/server-build'; +import { + cartGetIdDefault, + cartSetIdDefault, + createCartHandler, + createStorefrontClient, + storefrontRedirect, + createCustomerAccountClient, +} from '@shopify/hydrogen'; +import { + createRequestHandler, + getStorefrontHeaders, + type AppLoadContext, +} from '@shopify/remix-oxygen'; +import {AppSession} from '~/lib/session'; +import {CART_QUERY_FRAGMENT} from '~/lib/fragments'; + +/** + * Export a fetch handler in module format. + */ +export default { + async fetch( + request: Request, + env: Env, + executionContext: ExecutionContext, + ): Promise { + try { + /** + * Open a cache instance in the worker and a custom session instance. + */ + if (!env?.SESSION_SECRET) { + throw new Error('SESSION_SECRET environment variable is not set'); + } + + const waitUntil = executionContext.waitUntil.bind(executionContext); + const [cache, session] = await Promise.all([ + caches.open('hydrogen'), + AppSession.init(request, [env.SESSION_SECRET]), + ]); + + /** + * Create Hydrogen's Storefront client. + */ + const {storefront} = createStorefrontClient({ + cache, + waitUntil, + i18n: {language: 'EN', country: 'US'}, + publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN, + privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN, + storeDomain: env.PUBLIC_STORE_DOMAIN, + storefrontId: env.PUBLIC_STOREFRONT_ID, + storefrontHeaders: getStorefrontHeaders(request), + }); + + /** + * Create a client for Customer Account API. + */ + const customerAccount = createCustomerAccountClient({ + waitUntil, + request, + session, + customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID, + customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_API_URL, + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + unstableB2b: true, + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ + }); + + /* + * Create a cart handler that will be used to + * create and update the cart in the session. + */ + const cart = createCartHandler({ + storefront, + customerAccount, + getCartId: cartGetIdDefault(request.headers), + setCartId: cartSetIdDefault(), + cartQueryFragment: CART_QUERY_FRAGMENT, + }); + + /** + * Create a Remix request handler and pass + * Hydrogen's Storefront client to the loader context. + */ + const handleRequest = createRequestHandler({ + build: remixBuild, + mode: process.env.NODE_ENV, + getLoadContext: (): AppLoadContext => ({ + session, + storefront, + customerAccount, + cart, + env, + waitUntil, + }), + }); + + const response = await handleRequest(request); + + if (response.status === 404) { + /** + * Check for redirects only when there's a 404 from the app. + * If the redirect doesn't exist, then `storefrontRedirect` + * will pass through the 404 response. + */ + return storefrontRedirect({request, response, storefront}); + } + + return response; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + return new Response('An unexpected error occurred', {status: 500}); + } + }, +}; diff --git a/examples/b2b/storefrontapi.generated.d.ts b/examples/b2b/storefrontapi.generated.d.ts new file mode 100644 index 0000000000..8ec5d8b283 --- /dev/null +++ b/examples/b2b/storefrontapi.generated.d.ts @@ -0,0 +1,1310 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ +import * as StorefrontAPI from '@shopify/hydrogen/storefront-api-types'; + +export type MoneyFragment = Pick< + StorefrontAPI.MoneyV2, + 'currencyCode' | 'amount' +>; + +export type CartLineFragment = Pick< + StorefrontAPI.CartLine, + 'id' | 'quantity' +> & { + attributes: Array>; + cost: { + totalAmount: Pick; + amountPerQuantity: Pick; + compareAtAmountPerQuantity?: StorefrontAPI.Maybe< + Pick + >; + }; + merchandise: Pick< + StorefrontAPI.ProductVariant, + 'id' | 'availableForSale' | 'requiresShipping' | 'title' + > & { + compareAtPrice?: StorefrontAPI.Maybe< + Pick + >; + price: Pick; + image?: StorefrontAPI.Maybe< + Pick + >; + product: Pick; + selectedOptions: Array< + Pick + >; + quantityRule: Pick< + StorefrontAPI.QuantityRule, + 'maximum' | 'minimum' | 'increment' + >; + quantityPriceBreaks: { + nodes: Array< + Pick & { + price: Pick; + } + >; + }; + }; +}; + +export type CartApiQueryFragment = Pick< + StorefrontAPI.Cart, + 'updatedAt' | 'id' | 'checkoutUrl' | 'totalQuantity' | 'note' +> & { + buyerIdentity: Pick< + StorefrontAPI.CartBuyerIdentity, + 'countryCode' | 'email' | 'phone' + > & { + customer?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.Customer, + 'id' | 'email' | 'firstName' | 'lastName' | 'displayName' + > + >; + }; + lines: { + nodes: Array< + Pick & { + attributes: Array>; + cost: { + totalAmount: Pick; + amountPerQuantity: Pick< + StorefrontAPI.MoneyV2, + 'currencyCode' | 'amount' + >; + compareAtAmountPerQuantity?: StorefrontAPI.Maybe< + Pick + >; + }; + merchandise: Pick< + StorefrontAPI.ProductVariant, + 'id' | 'availableForSale' | 'requiresShipping' | 'title' + > & { + compareAtPrice?: StorefrontAPI.Maybe< + Pick + >; + price: Pick; + image?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.Image, + 'id' | 'url' | 'altText' | 'width' | 'height' + > + >; + product: Pick< + StorefrontAPI.Product, + 'handle' | 'title' | 'id' | 'vendor' + >; + selectedOptions: Array< + Pick + >; + quantityRule: Pick< + StorefrontAPI.QuantityRule, + 'maximum' | 'minimum' | 'increment' + >; + quantityPriceBreaks: { + nodes: Array< + Pick & { + price: Pick; + } + >; + }; + }; + } + >; + }; + cost: { + subtotalAmount: Pick; + totalAmount: Pick; + totalDutyAmount?: StorefrontAPI.Maybe< + Pick + >; + totalTaxAmount?: StorefrontAPI.Maybe< + Pick + >; + }; + attributes: Array>; + discountCodes: Array< + Pick + >; +}; + +export type MenuItemFragment = Pick< + StorefrontAPI.MenuItem, + 'id' | 'resourceId' | 'tags' | 'title' | 'type' | 'url' +>; + +export type ChildMenuItemFragment = Pick< + StorefrontAPI.MenuItem, + 'id' | 'resourceId' | 'tags' | 'title' | 'type' | 'url' +>; + +export type ParentMenuItemFragment = Pick< + StorefrontAPI.MenuItem, + 'id' | 'resourceId' | 'tags' | 'title' | 'type' | 'url' +> & { + items: Array< + Pick< + StorefrontAPI.MenuItem, + 'id' | 'resourceId' | 'tags' | 'title' | 'type' | 'url' + > + >; +}; + +export type MenuFragment = Pick & { + items: Array< + Pick< + StorefrontAPI.MenuItem, + 'id' | 'resourceId' | 'tags' | 'title' | 'type' | 'url' + > & { + items: Array< + Pick< + StorefrontAPI.MenuItem, + 'id' | 'resourceId' | 'tags' | 'title' | 'type' | 'url' + > + >; + } + >; +}; + +export type ShopFragment = Pick< + StorefrontAPI.Shop, + 'id' | 'name' | 'description' +> & { + primaryDomain: Pick; + brand?: StorefrontAPI.Maybe<{ + logo?: StorefrontAPI.Maybe<{ + image?: StorefrontAPI.Maybe>; + }>; + }>; +}; + +export type HeaderQueryVariables = StorefrontAPI.Exact<{ + country?: StorefrontAPI.InputMaybe; + headerMenuHandle: StorefrontAPI.Scalars['String']['input']; + language?: StorefrontAPI.InputMaybe; +}>; + +export type HeaderQuery = { + shop: Pick & { + primaryDomain: Pick; + brand?: StorefrontAPI.Maybe<{ + logo?: StorefrontAPI.Maybe<{ + image?: StorefrontAPI.Maybe>; + }>; + }>; + }; + menu?: StorefrontAPI.Maybe< + Pick & { + items: Array< + Pick< + StorefrontAPI.MenuItem, + 'id' | 'resourceId' | 'tags' | 'title' | 'type' | 'url' + > & { + items: Array< + Pick< + StorefrontAPI.MenuItem, + 'id' | 'resourceId' | 'tags' | 'title' | 'type' | 'url' + > + >; + } + >; + } + >; +}; + +export type FooterQueryVariables = StorefrontAPI.Exact<{ + country?: StorefrontAPI.InputMaybe; + footerMenuHandle: StorefrontAPI.Scalars['String']['input']; + language?: StorefrontAPI.InputMaybe; +}>; + +export type FooterQuery = { + menu?: StorefrontAPI.Maybe< + Pick & { + items: Array< + Pick< + StorefrontAPI.MenuItem, + 'id' | 'resourceId' | 'tags' | 'title' | 'type' | 'url' + > & { + items: Array< + Pick< + StorefrontAPI.MenuItem, + 'id' | 'resourceId' | 'tags' | 'title' | 'type' | 'url' + > + >; + } + >; + } + >; +}; + +export type StoreRobotsQueryVariables = StorefrontAPI.Exact<{ + country?: StorefrontAPI.InputMaybe; + language?: StorefrontAPI.InputMaybe; +}>; + +export type StoreRobotsQuery = {shop: Pick}; + +export type SitemapQueryVariables = StorefrontAPI.Exact<{ + urlLimits?: StorefrontAPI.InputMaybe; + language?: StorefrontAPI.InputMaybe; +}>; + +export type SitemapQuery = { + products: { + nodes: Array< + Pick< + StorefrontAPI.Product, + 'updatedAt' | 'handle' | 'onlineStoreUrl' | 'title' + > & { + featuredImage?: StorefrontAPI.Maybe< + Pick + >; + } + >; + }; + collections: { + nodes: Array< + Pick + >; + }; + pages: { + nodes: Array< + Pick + >; + }; +}; + +export type FeaturedCollectionFragment = Pick< + StorefrontAPI.Collection, + 'id' | 'title' | 'handle' +> & { + image?: StorefrontAPI.Maybe< + Pick + >; +}; + +export type FeaturedCollectionQueryVariables = StorefrontAPI.Exact<{ + country?: StorefrontAPI.InputMaybe; + language?: StorefrontAPI.InputMaybe; +}>; + +export type FeaturedCollectionQuery = { + collections: { + nodes: Array< + Pick & { + image?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.Image, + 'id' | 'url' | 'altText' | 'width' | 'height' + > + >; + } + >; + }; +}; + +export type RecommendedProductFragment = Pick< + StorefrontAPI.Product, + 'id' | 'title' | 'handle' +> & { + priceRange: { + minVariantPrice: Pick; + }; + images: { + nodes: Array< + Pick + >; + }; +}; + +export type RecommendedProductsQueryVariables = StorefrontAPI.Exact<{ + country?: StorefrontAPI.InputMaybe; + language?: StorefrontAPI.InputMaybe; +}>; + +export type RecommendedProductsQuery = { + products: { + nodes: Array< + Pick & { + priceRange: { + minVariantPrice: Pick< + StorefrontAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + }; + images: { + nodes: Array< + Pick< + StorefrontAPI.Image, + 'id' | 'url' | 'altText' | 'width' | 'height' + > + >; + }; + } + >; + }; +}; + +export type PredictiveArticleFragment = {__typename: 'Article'} & Pick< + StorefrontAPI.Article, + 'id' | 'title' | 'handle' | 'trackingParameters' +> & { + image?: StorefrontAPI.Maybe< + Pick + >; + }; + +export type PredictiveCollectionFragment = {__typename: 'Collection'} & Pick< + StorefrontAPI.Collection, + 'id' | 'title' | 'handle' | 'trackingParameters' +> & { + image?: StorefrontAPI.Maybe< + Pick + >; + }; + +export type PredictivePageFragment = {__typename: 'Page'} & Pick< + StorefrontAPI.Page, + 'id' | 'title' | 'handle' | 'trackingParameters' +>; + +export type PredictiveProductFragment = {__typename: 'Product'} & Pick< + StorefrontAPI.Product, + 'id' | 'title' | 'handle' | 'trackingParameters' +> & { + variants: { + nodes: Array< + Pick & { + image?: StorefrontAPI.Maybe< + Pick + >; + price: Pick; + } + >; + }; + }; + +export type PredictiveQueryFragment = { + __typename: 'SearchQuerySuggestion'; +} & Pick< + StorefrontAPI.SearchQuerySuggestion, + 'text' | 'styledText' | 'trackingParameters' +>; + +export type PredictiveSearchQueryVariables = StorefrontAPI.Exact<{ + country?: StorefrontAPI.InputMaybe; + language?: StorefrontAPI.InputMaybe; + limit: StorefrontAPI.Scalars['Int']['input']; + limitScope: StorefrontAPI.PredictiveSearchLimitScope; + searchTerm: StorefrontAPI.Scalars['String']['input']; + types?: StorefrontAPI.InputMaybe< + | Array + | StorefrontAPI.PredictiveSearchType + >; +}>; + +export type PredictiveSearchQuery = { + predictiveSearch?: StorefrontAPI.Maybe<{ + articles: Array< + {__typename: 'Article'} & Pick< + StorefrontAPI.Article, + 'id' | 'title' | 'handle' | 'trackingParameters' + > & { + image?: StorefrontAPI.Maybe< + Pick + >; + } + >; + collections: Array< + {__typename: 'Collection'} & Pick< + StorefrontAPI.Collection, + 'id' | 'title' | 'handle' | 'trackingParameters' + > & { + image?: StorefrontAPI.Maybe< + Pick + >; + } + >; + pages: Array< + {__typename: 'Page'} & Pick< + StorefrontAPI.Page, + 'id' | 'title' | 'handle' | 'trackingParameters' + > + >; + products: Array< + {__typename: 'Product'} & Pick< + StorefrontAPI.Product, + 'id' | 'title' | 'handle' | 'trackingParameters' + > & { + variants: { + nodes: Array< + Pick & { + image?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.Image, + 'url' | 'altText' | 'width' | 'height' + > + >; + price: Pick; + } + >; + }; + } + >; + queries: Array< + {__typename: 'SearchQuerySuggestion'} & Pick< + StorefrontAPI.SearchQuerySuggestion, + 'text' | 'styledText' | 'trackingParameters' + > + >; + }>; +}; + +export type ArticleQueryVariables = StorefrontAPI.Exact<{ + articleHandle: StorefrontAPI.Scalars['String']['input']; + blogHandle: StorefrontAPI.Scalars['String']['input']; + country?: StorefrontAPI.InputMaybe; + language?: StorefrontAPI.InputMaybe; +}>; + +export type ArticleQuery = { + blog?: StorefrontAPI.Maybe<{ + articleByHandle?: StorefrontAPI.Maybe< + Pick & { + author?: StorefrontAPI.Maybe>; + image?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.Image, + 'id' | 'altText' | 'url' | 'width' | 'height' + > + >; + seo?: StorefrontAPI.Maybe< + Pick + >; + } + >; + }>; +}; + +export type BlogQueryVariables = StorefrontAPI.Exact<{ + language?: StorefrontAPI.InputMaybe; + blogHandle: StorefrontAPI.Scalars['String']['input']; + first?: StorefrontAPI.InputMaybe; + last?: StorefrontAPI.InputMaybe; + startCursor?: StorefrontAPI.InputMaybe< + StorefrontAPI.Scalars['String']['input'] + >; + endCursor?: StorefrontAPI.InputMaybe< + StorefrontAPI.Scalars['String']['input'] + >; +}>; + +export type BlogQuery = { + blog?: StorefrontAPI.Maybe< + Pick & { + seo?: StorefrontAPI.Maybe< + Pick + >; + articles: { + nodes: Array< + Pick< + StorefrontAPI.Article, + 'contentHtml' | 'handle' | 'id' | 'publishedAt' | 'title' + > & { + author?: StorefrontAPI.Maybe< + Pick + >; + image?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.Image, + 'id' | 'altText' | 'url' | 'width' | 'height' + > + >; + blog: Pick; + } + >; + pageInfo: Pick< + StorefrontAPI.PageInfo, + 'hasPreviousPage' | 'hasNextPage' | 'endCursor' | 'startCursor' + >; + }; + } + >; +}; + +export type ArticleItemFragment = Pick< + StorefrontAPI.Article, + 'contentHtml' | 'handle' | 'id' | 'publishedAt' | 'title' +> & { + author?: StorefrontAPI.Maybe>; + image?: StorefrontAPI.Maybe< + Pick + >; + blog: Pick; +}; + +export type BlogsQueryVariables = StorefrontAPI.Exact<{ + country?: StorefrontAPI.InputMaybe; + endCursor?: StorefrontAPI.InputMaybe< + StorefrontAPI.Scalars['String']['input'] + >; + first?: StorefrontAPI.InputMaybe; + language?: StorefrontAPI.InputMaybe; + last?: StorefrontAPI.InputMaybe; + startCursor?: StorefrontAPI.InputMaybe< + StorefrontAPI.Scalars['String']['input'] + >; +}>; + +export type BlogsQuery = { + blogs: { + pageInfo: Pick< + StorefrontAPI.PageInfo, + 'hasNextPage' | 'hasPreviousPage' | 'startCursor' | 'endCursor' + >; + nodes: Array< + Pick & { + seo?: StorefrontAPI.Maybe< + Pick + >; + } + >; + }; +}; + +export type MoneyProductItemFragment = Pick< + StorefrontAPI.MoneyV2, + 'amount' | 'currencyCode' +>; + +export type ProductItemFragment = Pick< + StorefrontAPI.Product, + 'id' | 'handle' | 'title' +> & { + featuredImage?: StorefrontAPI.Maybe< + Pick + >; + priceRange: { + minVariantPrice: Pick; + maxVariantPrice: Pick; + }; + variants: { + nodes: Array<{ + selectedOptions: Array< + Pick + >; + }>; + }; +}; + +export type CollectionQueryVariables = StorefrontAPI.Exact<{ + handle: StorefrontAPI.Scalars['String']['input']; + country?: StorefrontAPI.InputMaybe; + language?: StorefrontAPI.InputMaybe; + first?: StorefrontAPI.InputMaybe; + last?: StorefrontAPI.InputMaybe; + startCursor?: StorefrontAPI.InputMaybe< + StorefrontAPI.Scalars['String']['input'] + >; + endCursor?: StorefrontAPI.InputMaybe< + StorefrontAPI.Scalars['String']['input'] + >; +}>; + +export type CollectionQuery = { + collection?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.Collection, + 'id' | 'handle' | 'title' | 'description' + > & { + products: { + nodes: Array< + Pick & { + featuredImage?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.Image, + 'id' | 'altText' | 'url' | 'width' | 'height' + > + >; + priceRange: { + minVariantPrice: Pick< + StorefrontAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + maxVariantPrice: Pick< + StorefrontAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + }; + variants: { + nodes: Array<{ + selectedOptions: Array< + Pick + >; + }>; + }; + } + >; + pageInfo: Pick< + StorefrontAPI.PageInfo, + 'hasPreviousPage' | 'hasNextPage' | 'endCursor' | 'startCursor' + >; + }; + } + >; +}; + +export type CollectionFragment = Pick< + StorefrontAPI.Collection, + 'id' | 'title' | 'handle' +> & { + image?: StorefrontAPI.Maybe< + Pick + >; +}; + +export type StoreCollectionsQueryVariables = StorefrontAPI.Exact<{ + country?: StorefrontAPI.InputMaybe; + endCursor?: StorefrontAPI.InputMaybe< + StorefrontAPI.Scalars['String']['input'] + >; + first?: StorefrontAPI.InputMaybe; + language?: StorefrontAPI.InputMaybe; + last?: StorefrontAPI.InputMaybe; + startCursor?: StorefrontAPI.InputMaybe< + StorefrontAPI.Scalars['String']['input'] + >; +}>; + +export type StoreCollectionsQuery = { + collections: { + nodes: Array< + Pick & { + image?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.Image, + 'id' | 'url' | 'altText' | 'width' | 'height' + > + >; + } + >; + pageInfo: Pick< + StorefrontAPI.PageInfo, + 'hasNextPage' | 'hasPreviousPage' | 'startCursor' | 'endCursor' + >; + }; +}; + +export type CatalogQueryVariables = StorefrontAPI.Exact<{ + country?: StorefrontAPI.InputMaybe; + language?: StorefrontAPI.InputMaybe; + first?: StorefrontAPI.InputMaybe; + last?: StorefrontAPI.InputMaybe; + startCursor?: StorefrontAPI.InputMaybe< + StorefrontAPI.Scalars['String']['input'] + >; + endCursor?: StorefrontAPI.InputMaybe< + StorefrontAPI.Scalars['String']['input'] + >; +}>; + +export type CatalogQuery = { + products: { + nodes: Array< + Pick & { + featuredImage?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.Image, + 'id' | 'altText' | 'url' | 'width' | 'height' + > + >; + priceRange: { + minVariantPrice: Pick< + StorefrontAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + maxVariantPrice: Pick< + StorefrontAPI.MoneyV2, + 'amount' | 'currencyCode' + >; + }; + variants: { + nodes: Array<{ + selectedOptions: Array< + Pick + >; + }>; + }; + } + >; + pageInfo: Pick< + StorefrontAPI.PageInfo, + 'hasPreviousPage' | 'hasNextPage' | 'startCursor' | 'endCursor' + >; + }; +}; + +export type PageQueryVariables = StorefrontAPI.Exact<{ + language?: StorefrontAPI.InputMaybe; + country?: StorefrontAPI.InputMaybe; + handle: StorefrontAPI.Scalars['String']['input']; +}>; + +export type PageQuery = { + page?: StorefrontAPI.Maybe< + Pick & { + seo?: StorefrontAPI.Maybe< + Pick + >; + } + >; +}; + +export type PolicyFragment = Pick< + StorefrontAPI.ShopPolicy, + 'body' | 'handle' | 'id' | 'title' | 'url' +>; + +export type PolicyQueryVariables = StorefrontAPI.Exact<{ + country?: StorefrontAPI.InputMaybe; + language?: StorefrontAPI.InputMaybe; + privacyPolicy: StorefrontAPI.Scalars['Boolean']['input']; + refundPolicy: StorefrontAPI.Scalars['Boolean']['input']; + shippingPolicy: StorefrontAPI.Scalars['Boolean']['input']; + termsOfService: StorefrontAPI.Scalars['Boolean']['input']; +}>; + +export type PolicyQuery = { + shop: { + privacyPolicy?: StorefrontAPI.Maybe< + Pick + >; + shippingPolicy?: StorefrontAPI.Maybe< + Pick + >; + termsOfService?: StorefrontAPI.Maybe< + Pick + >; + refundPolicy?: StorefrontAPI.Maybe< + Pick + >; + }; +}; + +export type PolicyItemFragment = Pick< + StorefrontAPI.ShopPolicy, + 'id' | 'title' | 'handle' +>; + +export type PoliciesQueryVariables = StorefrontAPI.Exact<{ + country?: StorefrontAPI.InputMaybe; + language?: StorefrontAPI.InputMaybe; +}>; + +export type PoliciesQuery = { + shop: { + privacyPolicy?: StorefrontAPI.Maybe< + Pick + >; + shippingPolicy?: StorefrontAPI.Maybe< + Pick + >; + termsOfService?: StorefrontAPI.Maybe< + Pick + >; + refundPolicy?: StorefrontAPI.Maybe< + Pick + >; + subscriptionPolicy?: StorefrontAPI.Maybe< + Pick + >; + }; +}; + +export type ProductVariantFragment = Pick< + StorefrontAPI.ProductVariant, + 'availableForSale' | 'id' | 'sku' | 'title' +> & { + compareAtPrice?: StorefrontAPI.Maybe< + Pick + >; + image?: StorefrontAPI.Maybe< + {__typename: 'Image'} & Pick< + StorefrontAPI.Image, + 'id' | 'url' | 'altText' | 'width' | 'height' + > + >; + price: Pick; + product: Pick; + selectedOptions: Array>; + quantityRule: Pick< + StorefrontAPI.QuantityRule, + 'maximum' | 'minimum' | 'increment' + >; + quantityPriceBreaks: { + nodes: Array< + Pick & { + price: Pick; + } + >; + }; + unitPrice?: StorefrontAPI.Maybe< + Pick + >; +}; + +export type ProductFragment = Pick< + StorefrontAPI.Product, + 'id' | 'title' | 'vendor' | 'handle' | 'descriptionHtml' | 'description' +> & { + options: Array>; + selectedVariant?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.ProductVariant, + 'availableForSale' | 'id' | 'sku' | 'title' + > & { + compareAtPrice?: StorefrontAPI.Maybe< + Pick + >; + image?: StorefrontAPI.Maybe< + {__typename: 'Image'} & Pick< + StorefrontAPI.Image, + 'id' | 'url' | 'altText' | 'width' | 'height' + > + >; + price: Pick; + product: Pick; + selectedOptions: Array< + Pick + >; + quantityRule: Pick< + StorefrontAPI.QuantityRule, + 'maximum' | 'minimum' | 'increment' + >; + quantityPriceBreaks: { + nodes: Array< + Pick & { + price: Pick; + } + >; + }; + unitPrice?: StorefrontAPI.Maybe< + Pick + >; + } + >; + variants: { + nodes: Array< + Pick< + StorefrontAPI.ProductVariant, + 'availableForSale' | 'id' | 'sku' | 'title' + > & { + compareAtPrice?: StorefrontAPI.Maybe< + Pick + >; + image?: StorefrontAPI.Maybe< + {__typename: 'Image'} & Pick< + StorefrontAPI.Image, + 'id' | 'url' | 'altText' | 'width' | 'height' + > + >; + price: Pick; + product: Pick; + selectedOptions: Array< + Pick + >; + quantityRule: Pick< + StorefrontAPI.QuantityRule, + 'maximum' | 'minimum' | 'increment' + >; + quantityPriceBreaks: { + nodes: Array< + Pick & { + price: Pick; + } + >; + }; + unitPrice?: StorefrontAPI.Maybe< + Pick + >; + } + >; + }; + seo: Pick; +}; + +export type ProductQueryVariables = StorefrontAPI.Exact<{ + country?: StorefrontAPI.InputMaybe; + buyer?: StorefrontAPI.InputMaybe; + handle: StorefrontAPI.Scalars['String']['input']; + language?: StorefrontAPI.InputMaybe; + selectedOptions: + | Array + | StorefrontAPI.SelectedOptionInput; +}>; + +export type ProductQuery = { + product?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.Product, + 'id' | 'title' | 'vendor' | 'handle' | 'descriptionHtml' | 'description' + > & { + options: Array>; + selectedVariant?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.ProductVariant, + 'availableForSale' | 'id' | 'sku' | 'title' + > & { + compareAtPrice?: StorefrontAPI.Maybe< + Pick + >; + image?: StorefrontAPI.Maybe< + {__typename: 'Image'} & Pick< + StorefrontAPI.Image, + 'id' | 'url' | 'altText' | 'width' | 'height' + > + >; + price: Pick; + product: Pick; + selectedOptions: Array< + Pick + >; + quantityRule: Pick< + StorefrontAPI.QuantityRule, + 'maximum' | 'minimum' | 'increment' + >; + quantityPriceBreaks: { + nodes: Array< + Pick & { + price: Pick; + } + >; + }; + unitPrice?: StorefrontAPI.Maybe< + Pick + >; + } + >; + variants: { + nodes: Array< + Pick< + StorefrontAPI.ProductVariant, + 'availableForSale' | 'id' | 'sku' | 'title' + > & { + compareAtPrice?: StorefrontAPI.Maybe< + Pick + >; + image?: StorefrontAPI.Maybe< + {__typename: 'Image'} & Pick< + StorefrontAPI.Image, + 'id' | 'url' | 'altText' | 'width' | 'height' + > + >; + price: Pick; + product: Pick; + selectedOptions: Array< + Pick + >; + quantityRule: Pick< + StorefrontAPI.QuantityRule, + 'maximum' | 'minimum' | 'increment' + >; + quantityPriceBreaks: { + nodes: Array< + Pick & { + price: Pick; + } + >; + }; + unitPrice?: StorefrontAPI.Maybe< + Pick + >; + } + >; + }; + seo: Pick; + } + >; +}; + +export type ProductVariantsFragment = { + variants: { + nodes: Array< + Pick< + StorefrontAPI.ProductVariant, + 'availableForSale' | 'id' | 'sku' | 'title' + > & { + compareAtPrice?: StorefrontAPI.Maybe< + Pick + >; + image?: StorefrontAPI.Maybe< + {__typename: 'Image'} & Pick< + StorefrontAPI.Image, + 'id' | 'url' | 'altText' | 'width' | 'height' + > + >; + price: Pick; + product: Pick; + selectedOptions: Array< + Pick + >; + quantityRule: Pick< + StorefrontAPI.QuantityRule, + 'maximum' | 'minimum' | 'increment' + >; + quantityPriceBreaks: { + nodes: Array< + Pick & { + price: Pick; + } + >; + }; + unitPrice?: StorefrontAPI.Maybe< + Pick + >; + } + >; + }; +}; + +export type ProductVariantsQueryVariables = StorefrontAPI.Exact<{ + country?: StorefrontAPI.InputMaybe; + buyer?: StorefrontAPI.InputMaybe; + language?: StorefrontAPI.InputMaybe; + handle: StorefrontAPI.Scalars['String']['input']; +}>; + +export type ProductVariantsQuery = { + product?: StorefrontAPI.Maybe<{ + variants: { + nodes: Array< + Pick< + StorefrontAPI.ProductVariant, + 'availableForSale' | 'id' | 'sku' | 'title' + > & { + compareAtPrice?: StorefrontAPI.Maybe< + Pick + >; + image?: StorefrontAPI.Maybe< + {__typename: 'Image'} & Pick< + StorefrontAPI.Image, + 'id' | 'url' | 'altText' | 'width' | 'height' + > + >; + price: Pick; + product: Pick; + selectedOptions: Array< + Pick + >; + quantityRule: Pick< + StorefrontAPI.QuantityRule, + 'maximum' | 'minimum' | 'increment' + >; + quantityPriceBreaks: { + nodes: Array< + Pick & { + price: Pick; + } + >; + }; + unitPrice?: StorefrontAPI.Maybe< + Pick + >; + } + >; + }; + }>; +}; + +export type SearchProductFragment = {__typename: 'Product'} & Pick< + StorefrontAPI.Product, + 'handle' | 'id' | 'publishedAt' | 'title' | 'trackingParameters' | 'vendor' +> & { + variants: { + nodes: Array< + Pick & { + image?: StorefrontAPI.Maybe< + Pick + >; + price: Pick; + compareAtPrice?: StorefrontAPI.Maybe< + Pick + >; + selectedOptions: Array< + Pick + >; + product: Pick; + } + >; + }; + }; + +export type SearchPageFragment = {__typename: 'Page'} & Pick< + StorefrontAPI.Page, + 'handle' | 'id' | 'title' | 'trackingParameters' +>; + +export type SearchArticleFragment = {__typename: 'Article'} & Pick< + StorefrontAPI.Article, + 'handle' | 'id' | 'title' | 'trackingParameters' +>; + +export type SearchQueryVariables = StorefrontAPI.Exact<{ + country?: StorefrontAPI.InputMaybe; + endCursor?: StorefrontAPI.InputMaybe< + StorefrontAPI.Scalars['String']['input'] + >; + first?: StorefrontAPI.InputMaybe; + language?: StorefrontAPI.InputMaybe; + last?: StorefrontAPI.InputMaybe; + query: StorefrontAPI.Scalars['String']['input']; + startCursor?: StorefrontAPI.InputMaybe< + StorefrontAPI.Scalars['String']['input'] + >; +}>; + +export type SearchQuery = { + products: { + nodes: Array< + {__typename: 'Product'} & Pick< + StorefrontAPI.Product, + | 'handle' + | 'id' + | 'publishedAt' + | 'title' + | 'trackingParameters' + | 'vendor' + > & { + variants: { + nodes: Array< + Pick & { + image?: StorefrontAPI.Maybe< + Pick< + StorefrontAPI.Image, + 'url' | 'altText' | 'width' | 'height' + > + >; + price: Pick; + compareAtPrice?: StorefrontAPI.Maybe< + Pick + >; + selectedOptions: Array< + Pick + >; + product: Pick; + } + >; + }; + } + >; + pageInfo: Pick< + StorefrontAPI.PageInfo, + 'hasNextPage' | 'hasPreviousPage' | 'startCursor' | 'endCursor' + >; + }; + pages: { + nodes: Array< + {__typename: 'Page'} & Pick< + StorefrontAPI.Page, + 'handle' | 'id' | 'title' | 'trackingParameters' + > + >; + }; + articles: { + nodes: Array< + {__typename: 'Article'} & Pick< + StorefrontAPI.Article, + 'handle' | 'id' | 'title' | 'trackingParameters' + > + >; + }; +}; + +interface GeneratedQueryTypes { + '#graphql\n fragment Shop on Shop {\n id\n name\n description\n primaryDomain {\n url\n }\n brand {\n logo {\n image {\n url\n }\n }\n }\n }\n query Header(\n $country: CountryCode\n $headerMenuHandle: String!\n $language: LanguageCode\n ) @inContext(language: $language, country: $country) {\n shop {\n ...Shop\n }\n menu(handle: $headerMenuHandle) {\n ...Menu\n }\n }\n #graphql\n fragment MenuItem on MenuItem {\n id\n resourceId\n tags\n title\n type\n url\n }\n fragment ChildMenuItem on MenuItem {\n ...MenuItem\n }\n fragment ParentMenuItem on MenuItem {\n ...MenuItem\n items {\n ...ChildMenuItem\n }\n }\n fragment Menu on Menu {\n id\n items {\n ...ParentMenuItem\n }\n }\n\n': { + return: HeaderQuery; + variables: HeaderQueryVariables; + }; + '#graphql\n query Footer(\n $country: CountryCode\n $footerMenuHandle: String!\n $language: LanguageCode\n ) @inContext(language: $language, country: $country) {\n menu(handle: $footerMenuHandle) {\n ...Menu\n }\n }\n #graphql\n fragment MenuItem on MenuItem {\n id\n resourceId\n tags\n title\n type\n url\n }\n fragment ChildMenuItem on MenuItem {\n ...MenuItem\n }\n fragment ParentMenuItem on MenuItem {\n ...MenuItem\n items {\n ...ChildMenuItem\n }\n }\n fragment Menu on Menu {\n id\n items {\n ...ParentMenuItem\n }\n }\n\n': { + return: FooterQuery; + variables: FooterQueryVariables; + }; + '#graphql\n query StoreRobots($country: CountryCode, $language: LanguageCode)\n @inContext(country: $country, language: $language) {\n shop {\n id\n }\n }\n': { + return: StoreRobotsQuery; + variables: StoreRobotsQueryVariables; + }; + '#graphql\n query Sitemap($urlLimits: Int, $language: LanguageCode)\n @inContext(language: $language) {\n products(\n first: $urlLimits\n query: "published_status:\'online_store:visible\'"\n ) {\n nodes {\n updatedAt\n handle\n onlineStoreUrl\n title\n featuredImage {\n url\n altText\n }\n }\n }\n collections(\n first: $urlLimits\n query: "published_status:\'online_store:visible\'"\n ) {\n nodes {\n updatedAt\n handle\n onlineStoreUrl\n }\n }\n pages(first: $urlLimits, query: "published_status:\'published\'") {\n nodes {\n updatedAt\n handle\n onlineStoreUrl\n }\n }\n }\n': { + return: SitemapQuery; + variables: SitemapQueryVariables; + }; + '#graphql\n fragment FeaturedCollection on Collection {\n id\n title\n image {\n id\n url\n altText\n width\n height\n }\n handle\n }\n query FeaturedCollection($country: CountryCode, $language: LanguageCode)\n @inContext(country: $country, language: $language) {\n collections(first: 1, sortKey: UPDATED_AT, reverse: true) {\n nodes {\n ...FeaturedCollection\n }\n }\n }\n': { + return: FeaturedCollectionQuery; + variables: FeaturedCollectionQueryVariables; + }; + '#graphql\n fragment RecommendedProduct on Product {\n id\n title\n handle\n priceRange {\n minVariantPrice {\n amount\n currencyCode\n }\n }\n images(first: 1) {\n nodes {\n id\n url\n altText\n width\n height\n }\n }\n }\n query RecommendedProducts ($country: CountryCode, $language: LanguageCode)\n @inContext(country: $country, language: $language) {\n products(first: 4, sortKey: UPDATED_AT, reverse: true) {\n nodes {\n ...RecommendedProduct\n }\n }\n }\n': { + return: RecommendedProductsQuery; + variables: RecommendedProductsQueryVariables; + }; + '#graphql\n fragment PredictiveArticle on Article {\n __typename\n id\n title\n handle\n image {\n url\n altText\n width\n height\n }\n trackingParameters\n }\n fragment PredictiveCollection on Collection {\n __typename\n id\n title\n handle\n image {\n url\n altText\n width\n height\n }\n trackingParameters\n }\n fragment PredictivePage on Page {\n __typename\n id\n title\n handle\n trackingParameters\n }\n fragment PredictiveProduct on Product {\n __typename\n id\n title\n handle\n trackingParameters\n variants(first: 1) {\n nodes {\n id\n image {\n url\n altText\n width\n height\n }\n price {\n amount\n currencyCode\n }\n }\n }\n }\n fragment PredictiveQuery on SearchQuerySuggestion {\n __typename\n text\n styledText\n trackingParameters\n }\n query predictiveSearch(\n $country: CountryCode\n $language: LanguageCode\n $limit: Int!\n $limitScope: PredictiveSearchLimitScope!\n $searchTerm: String!\n $types: [PredictiveSearchType!]\n ) @inContext(country: $country, language: $language) {\n predictiveSearch(\n limit: $limit,\n limitScope: $limitScope,\n query: $searchTerm,\n types: $types,\n ) {\n articles {\n ...PredictiveArticle\n }\n collections {\n ...PredictiveCollection\n }\n pages {\n ...PredictivePage\n }\n products {\n ...PredictiveProduct\n }\n queries {\n ...PredictiveQuery\n }\n }\n }\n': { + return: PredictiveSearchQuery; + variables: PredictiveSearchQueryVariables; + }; + '#graphql\n query Article(\n $articleHandle: String!\n $blogHandle: String!\n $country: CountryCode\n $language: LanguageCode\n ) @inContext(language: $language, country: $country) {\n blog(handle: $blogHandle) {\n articleByHandle(handle: $articleHandle) {\n title\n contentHtml\n publishedAt\n author: authorV2 {\n name\n }\n image {\n id\n altText\n url\n width\n height\n }\n seo {\n description\n title\n }\n }\n }\n }\n': { + return: ArticleQuery; + variables: ArticleQueryVariables; + }; + '#graphql\n query Blog(\n $language: LanguageCode\n $blogHandle: String!\n $first: Int\n $last: Int\n $startCursor: String\n $endCursor: String\n ) @inContext(language: $language) {\n blog(handle: $blogHandle) {\n title\n seo {\n title\n description\n }\n articles(\n first: $first,\n last: $last,\n before: $startCursor,\n after: $endCursor\n ) {\n nodes {\n ...ArticleItem\n }\n pageInfo {\n hasPreviousPage\n hasNextPage\n hasNextPage\n endCursor\n startCursor\n }\n\n }\n }\n }\n fragment ArticleItem on Article {\n author: authorV2 {\n name\n }\n contentHtml\n handle\n id\n image {\n id\n altText\n url\n width\n height\n }\n publishedAt\n title\n blog {\n handle\n }\n }\n': { + return: BlogQuery; + variables: BlogQueryVariables; + }; + '#graphql\n query Blogs(\n $country: CountryCode\n $endCursor: String\n $first: Int\n $language: LanguageCode\n $last: Int\n $startCursor: String\n ) @inContext(country: $country, language: $language) {\n blogs(\n first: $first,\n last: $last,\n before: $startCursor,\n after: $endCursor\n ) {\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n nodes {\n title\n handle\n seo {\n title\n description\n }\n }\n }\n }\n': { + return: BlogsQuery; + variables: BlogsQueryVariables; + }; + '#graphql\n #graphql\n fragment MoneyProductItem on MoneyV2 {\n amount\n currencyCode\n }\n fragment ProductItem on Product {\n id\n handle\n title\n featuredImage {\n id\n altText\n url\n width\n height\n }\n priceRange {\n minVariantPrice {\n ...MoneyProductItem\n }\n maxVariantPrice {\n ...MoneyProductItem\n }\n }\n variants(first: 1) {\n nodes {\n selectedOptions {\n name\n value\n }\n }\n }\n }\n\n query Collection(\n $handle: String!\n $country: CountryCode\n $language: LanguageCode\n $first: Int\n $last: Int\n $startCursor: String\n $endCursor: String\n ) @inContext(country: $country, language: $language) {\n collection(handle: $handle) {\n id\n handle\n title\n description\n products(\n first: $first,\n last: $last,\n before: $startCursor,\n after: $endCursor\n ) {\n nodes {\n ...ProductItem\n }\n pageInfo {\n hasPreviousPage\n hasNextPage\n endCursor\n startCursor\n }\n }\n }\n }\n': { + return: CollectionQuery; + variables: CollectionQueryVariables; + }; + '#graphql\n fragment Collection on Collection {\n id\n title\n handle\n image {\n id\n url\n altText\n width\n height\n }\n }\n query StoreCollections(\n $country: CountryCode\n $endCursor: String\n $first: Int\n $language: LanguageCode\n $last: Int\n $startCursor: String\n ) @inContext(country: $country, language: $language) {\n collections(\n first: $first,\n last: $last,\n before: $startCursor,\n after: $endCursor\n ) {\n nodes {\n ...Collection\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n': { + return: StoreCollectionsQuery; + variables: StoreCollectionsQueryVariables; + }; + '#graphql\n query Catalog(\n $country: CountryCode\n $language: LanguageCode\n $first: Int\n $last: Int\n $startCursor: String\n $endCursor: String\n ) @inContext(country: $country, language: $language) {\n products(first: $first, last: $last, before: $startCursor, after: $endCursor) {\n nodes {\n ...ProductItem\n }\n pageInfo {\n hasPreviousPage\n hasNextPage\n startCursor\n endCursor\n }\n }\n }\n #graphql\n fragment MoneyProductItem on MoneyV2 {\n amount\n currencyCode\n }\n fragment ProductItem on Product {\n id\n handle\n title\n featuredImage {\n id\n altText\n url\n width\n height\n }\n priceRange {\n minVariantPrice {\n ...MoneyProductItem\n }\n maxVariantPrice {\n ...MoneyProductItem\n }\n }\n variants(first: 1) {\n nodes {\n selectedOptions {\n name\n value\n }\n }\n }\n }\n\n': { + return: CatalogQuery; + variables: CatalogQueryVariables; + }; + '#graphql\n query Page(\n $language: LanguageCode,\n $country: CountryCode,\n $handle: String!\n )\n @inContext(language: $language, country: $country) {\n page(handle: $handle) {\n id\n title\n body\n seo {\n description\n title\n }\n }\n }\n': { + return: PageQuery; + variables: PageQueryVariables; + }; + '#graphql\n fragment Policy on ShopPolicy {\n body\n handle\n id\n title\n url\n }\n query Policy(\n $country: CountryCode\n $language: LanguageCode\n $privacyPolicy: Boolean!\n $refundPolicy: Boolean!\n $shippingPolicy: Boolean!\n $termsOfService: Boolean!\n ) @inContext(language: $language, country: $country) {\n shop {\n privacyPolicy @include(if: $privacyPolicy) {\n ...Policy\n }\n shippingPolicy @include(if: $shippingPolicy) {\n ...Policy\n }\n termsOfService @include(if: $termsOfService) {\n ...Policy\n }\n refundPolicy @include(if: $refundPolicy) {\n ...Policy\n }\n }\n }\n': { + return: PolicyQuery; + variables: PolicyQueryVariables; + }; + '#graphql\n fragment PolicyItem on ShopPolicy {\n id\n title\n handle\n }\n query Policies ($country: CountryCode, $language: LanguageCode)\n @inContext(country: $country, language: $language) {\n shop {\n privacyPolicy {\n ...PolicyItem\n }\n shippingPolicy {\n ...PolicyItem\n }\n termsOfService {\n ...PolicyItem\n }\n refundPolicy {\n ...PolicyItem\n }\n subscriptionPolicy {\n id\n title\n handle\n }\n }\n }\n': { + return: PoliciesQuery; + variables: PoliciesQueryVariables; + }; + '#graphql\n query Product(\n $country: CountryCode\n $buyer: BuyerInput\n $handle: String!\n $language: LanguageCode\n $selectedOptions: [SelectedOptionInput!]!\n ) @inContext(country: $country, language: $language, buyer: $buyer) {\n product(handle: $handle) {\n ...Product\n }\n }\n #graphql\n fragment Product on Product {\n id\n title\n vendor\n handle\n descriptionHtml\n description\n options {\n name\n values\n }\n selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) {\n ...ProductVariant\n }\n variants(first: 1) {\n nodes {\n ...ProductVariant\n }\n }\n seo {\n description\n title\n }\n }\n #graphql\n fragment ProductVariant on ProductVariant {\n availableForSale\n compareAtPrice {\n amount\n currencyCode\n }\n id\n image {\n __typename\n id\n url\n altText\n width\n height\n }\n price {\n amount\n currencyCode\n }\n product {\n title\n handle\n }\n selectedOptions {\n name\n value\n }\n quantityRule {\n maximum\n minimum\n increment\n }\n quantityPriceBreaks(first: 5) {\n nodes {\n minimumQuantity\n price {\n amount\n currencyCode\n }\n }\n }\n sku\n title\n unitPrice {\n amount\n currencyCode\n }\n }\n\n\n': { + return: ProductQuery; + variables: ProductQueryVariables; + }; + '#graphql\n #graphql\n fragment ProductVariants on Product {\n variants(first: 250) {\n nodes {\n ...ProductVariant\n }\n }\n }\n #graphql\n fragment ProductVariant on ProductVariant {\n availableForSale\n compareAtPrice {\n amount\n currencyCode\n }\n id\n image {\n __typename\n id\n url\n altText\n width\n height\n }\n price {\n amount\n currencyCode\n }\n product {\n title\n handle\n }\n selectedOptions {\n name\n value\n }\n quantityRule {\n maximum\n minimum\n increment\n }\n quantityPriceBreaks(first: 5) {\n nodes {\n minimumQuantity\n price {\n amount\n currencyCode\n }\n }\n }\n sku\n title\n unitPrice {\n amount\n currencyCode\n }\n }\n\n\n query ProductVariants(\n $country: CountryCode\n $buyer: BuyerInput\n $language: LanguageCode\n $handle: String!\n ) @inContext(country: $country, language: $language, buyer: $buyer) {\n product(handle: $handle) {\n ...ProductVariants\n }\n }\n': { + return: ProductVariantsQuery; + variables: ProductVariantsQueryVariables; + }; + '#graphql\n fragment SearchProduct on Product {\n __typename\n handle\n id\n publishedAt\n title\n trackingParameters\n vendor\n variants(first: 1) {\n nodes {\n id\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 fragment SearchPage on Page {\n __typename\n handle\n id\n title\n trackingParameters\n }\n fragment SearchArticle on Article {\n __typename\n handle\n id\n title\n trackingParameters\n }\n query search(\n $country: CountryCode\n $endCursor: String\n $first: Int\n $language: LanguageCode\n $last: Int\n $query: String!\n $startCursor: String\n ) @inContext(country: $country, language: $language) {\n products: search(\n query: $query,\n unavailableProducts: HIDE,\n types: [PRODUCT],\n first: $first,\n sortKey: RELEVANCE,\n last: $last,\n before: $startCursor,\n after: $endCursor\n ) {\n nodes {\n ...on Product {\n ...SearchProduct\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n pages: search(\n query: $query,\n types: [PAGE],\n first: 10\n ) {\n nodes {\n ...on Page {\n ...SearchPage\n }\n }\n }\n articles: search(\n query: $query,\n types: [ARTICLE],\n first: 10\n ) {\n nodes {\n ...on Article {\n ...SearchArticle\n }\n }\n }\n }\n': { + return: SearchQuery; + variables: SearchQueryVariables; + }; +} + +interface GeneratedMutationTypes {} + +declare module '@shopify/hydrogen' { + interface StorefrontQueries extends GeneratedQueryTypes {} + interface StorefrontMutations extends GeneratedMutationTypes {} +} diff --git a/examples/b2b/tsconfig.json b/examples/b2b/tsconfig.json new file mode 100644 index 0000000000..110d781eea --- /dev/null +++ b/examples/b2b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../templates/skeleton/tsconfig.json", + "include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "*": ["./*", "../../templates/skeleton/*"], + "~/*": ["app/*", "../../templates/skeleton/app/*"] + } + } +} diff --git a/package-lock.json b/package-lock.json index 095649f397..bdd5d3bb56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "hydrogen", "workspaces": [ "docs/preview", + "examples/b2b", "examples/classic-remix", "examples/custom-cart-method", "examples/express", @@ -83,6 +84,12 @@ "@shopify/cli-hydrogen": "*" } }, + "examples/b2b": { + "name": "example-b2b", + "dependencies": { + "@shopify/cli-hydrogen": "*" + } + }, "examples/classic-remix": { "name": "example-classic-remix", "dependencies": { @@ -15003,6 +15010,10 @@ "resolved": "examples/analytics", "link": true }, + "node_modules/example-b2b": { + "resolved": "examples/b2b", + "link": true + }, "node_modules/example-classic-remix": { "resolved": "examples/classic-remix", "link": true @@ -44952,6 +44963,12 @@ "@shopify/cli-hydrogen": "*" } }, + "example-b2b": { + "version": "file:examples/b2b", + "requires": { + "@shopify/cli-hydrogen": "*" + } + }, "example-classic-remix": { "version": "file:examples/classic-remix", "requires": { diff --git a/package.json b/package.json index 9b9f4b7c76..22bee45c72 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ }, "workspaces": [ "docs/preview", + "examples/b2b", "examples/classic-remix", "examples/custom-cart-method", "examples/express", diff --git a/packages/hydrogen/src/cart/cart-test-helper.ts b/packages/hydrogen/src/cart/cart-test-helper.ts index 76c7b5d41e..41956eaf1a 100644 --- a/packages/hydrogen/src/cart/cart-test-helper.ts +++ b/packages/hydrogen/src/cart/cart-test-helper.ts @@ -2,7 +2,10 @@ import {CachingStrategy} from '../cache/strategies'; import type {ExecutionArgs} from 'graphql'; import {Storefront} from '../storefront'; import {CacheNone} from '../cache/strategies'; +import {CustomerAccount} from '../customer/types'; +export const BUYER_ACCESS_TOKEN = 'sha123'; +export const BUYER_LOCATION_ID = 'gid://shopify/CompanyLocation/1'; export const CART_ID = 'gid://shopify/Cart/c1-123'; export const NEW_CART_ID = 'c1-new-cart-id'; export const CHECKOUT_URL = @@ -72,3 +75,17 @@ export function mockCreateStorefrontClient() { CacheNone: CacheNone, } as Storefront; } + +export function mockGetBuyer() { + return Promise.resolve({ + customerAccessToken: BUYER_ACCESS_TOKEN, + companyLocationId: BUYER_LOCATION_ID, + }); +} + +export function mockCreateCustomerAccountClient() { + return { + UNSTABLE_getBuyer: mockGetBuyer, + isLoggedIn: () => Promise.resolve(true), + } as CustomerAccount; +} diff --git a/packages/hydrogen/src/cart/createCartHandler.test.ts b/packages/hydrogen/src/cart/createCartHandler.test.ts index 56755d018e..04346411d6 100644 --- a/packages/hydrogen/src/cart/createCartHandler.test.ts +++ b/packages/hydrogen/src/cart/createCartHandler.test.ts @@ -4,7 +4,11 @@ import { HydrogenCartCustom, createCartHandler, } from './createCartHandler'; -import {mockCreateStorefrontClient, mockHeaders} from './cart-test-helper'; +import { + mockCreateCustomerAccountClient, + mockCreateStorefrontClient, + mockHeaders, +} from './cart-test-helper'; type MockCarthandler = { cartId?: string; @@ -17,6 +21,7 @@ function getCartHandler(options: MockCarthandler = {}) { const {cartId, ...rest} = options; return createCartHandler({ storefront: mockCreateStorefrontClient(), + customerAccount: mockCreateCustomerAccountClient(), getCartId: () => options.cartId ? `gid://shopify/Cart/${options.cartId}` : undefined, setCartId: () => new Headers(), diff --git a/packages/hydrogen/src/cart/createCartHandler.ts b/packages/hydrogen/src/cart/createCartHandler.ts index 2ca5b806ae..f8a5af11d8 100644 --- a/packages/hydrogen/src/cart/createCartHandler.ts +++ b/packages/hydrogen/src/cart/createCartHandler.ts @@ -112,6 +112,7 @@ export function createCartHandler( storefront, getCartId, cartFragment: cartMutateFragment, + customerAccount, }; const _cartCreate = cartCreateDefault(mutateOptions); diff --git a/packages/hydrogen/src/cart/queries/cart-types.ts b/packages/hydrogen/src/cart/queries/cart-types.ts index f3e54438e8..27bd49197d 100644 --- a/packages/hydrogen/src/cart/queries/cart-types.ts +++ b/packages/hydrogen/src/cart/queries/cart-types.ts @@ -9,6 +9,7 @@ import type { MetafieldDeleteUserError, } from '@shopify/hydrogen-react/storefront-api-types'; import type {StorefrontApiErrors, Storefront} from '../../storefront'; +import {CustomerAccount} from '../../customer/types'; export type CartOptionalInput = { /** @@ -43,6 +44,10 @@ export type CartQueryOptions = { * The cart fragment to override the one used in this query. */ cartFragment?: string; + /** + * The customer account instance created by [`createCustomerAccount`](docs/api/hydrogen/latest/customer/createcustomeraccount). + */ + customerAccount?: CustomerAccount; }; export type CartReturn = Cart & { diff --git a/packages/hydrogen/src/cart/queries/cartBuyerIdentityUpdateDefault.ts b/packages/hydrogen/src/cart/queries/cartBuyerIdentityUpdateDefault.ts index b588dcdbb8..0321723f5d 100644 --- a/packages/hydrogen/src/cart/queries/cartBuyerIdentityUpdateDefault.ts +++ b/packages/hydrogen/src/cart/queries/cartBuyerIdentityUpdateDefault.ts @@ -17,13 +17,26 @@ export function cartBuyerIdentityUpdateDefault( options: CartQueryOptions, ): CartBuyerIdentityUpdateFunction { return async (buyerIdentity, optionalParams) => { + if (buyerIdentity.companyLocationId && options.customerAccount) { + options.customerAccount.UNSTABLE_setBuyer({ + companyLocationId: buyerIdentity.companyLocationId, + }); + } + + const buyer = options.customerAccount + ? await options.customerAccount.UNSTABLE_getBuyer() + : undefined; + const {cartBuyerIdentityUpdate, errors} = await options.storefront.mutate<{ cartBuyerIdentityUpdate: CartQueryData; errors: StorefrontApiErrors; }>(CART_BUYER_IDENTITY_UPDATE_MUTATION(options.cartFragment), { variables: { cartId: options.getCartId(), - buyerIdentity, + buyerIdentity: { + ...buyer, + ...buyerIdentity, + }, ...optionalParams, }, }); diff --git a/packages/hydrogen/src/cart/queries/cartCreateDefault.ts b/packages/hydrogen/src/cart/queries/cartCreateDefault.ts index e0cd883f93..78a57efe60 100644 --- a/packages/hydrogen/src/cart/queries/cartCreateDefault.ts +++ b/packages/hydrogen/src/cart/queries/cartCreateDefault.ts @@ -17,13 +17,23 @@ export function cartCreateDefault( options: CartQueryOptions, ): CartCreateFunction { return async (input, optionalParams) => { + const buyer = options.customerAccount + ? await options.customerAccount.UNSTABLE_getBuyer() + : undefined; const {cartId, ...restOfOptionalParams} = optionalParams || {}; + const {buyerIdentity, ...restOfInput} = input; const {cartCreate, errors} = await options.storefront.mutate<{ cartCreate: CartQueryData; errors: StorefrontApiErrors; }>(CART_CREATE_MUTATION(options.cartFragment), { variables: { - input, + input: { + ...restOfInput, + buyerIdentity: { + ...buyer, + ...buyerIdentity, + }, + }, ...restOfOptionalParams, }, }); diff --git a/packages/hydrogen/src/constants.ts b/packages/hydrogen/src/constants.ts index 125695e351..e1b4af592f 100644 --- a/packages/hydrogen/src/constants.ts +++ b/packages/hydrogen/src/constants.ts @@ -1,3 +1,5 @@ +import {LIB_VERSION} from './version'; + export const STOREFRONT_REQUEST_GROUP_ID_HEADER = 'Custom-Storefront-Request-Group-ID'; export const STOREFRONT_ACCESS_TOKEN_HEADER = @@ -5,3 +7,11 @@ export const STOREFRONT_ACCESS_TOKEN_HEADER = export const SDK_VARIANT_HEADER = 'X-SDK-Variant'; export const SDK_VARIANT_SOURCE_HEADER = 'X-SDK-Variant-Source'; export const SDK_VERSION_HEADER = 'X-SDK-Version'; + +// For Customer Account API +export const DEFAULT_CUSTOMER_API_VERSION = '2024-04'; +export const USER_AGENT = `Shopify Hydrogen ${LIB_VERSION}`; +// This is a static api client id: https://shopify.dev/docs/api/customer#useaccesstoken-propertydetail-audience +export const CUSTOMER_API_CLIENT_ID = '30243aa5-17c1-465a-8493-944bcc4e88aa'; +export const CUSTOMER_ACCOUNT_SESSION_KEY = 'customerAccount'; +export const BUYER_SESSION_KEY = 'buyer'; diff --git a/packages/hydrogen/src/customer/auth.helpers.test.ts b/packages/hydrogen/src/customer/auth.helpers.test.ts index 460db56e4b..bfce68697a 100644 --- a/packages/hydrogen/src/customer/auth.helpers.test.ts +++ b/packages/hydrogen/src/customer/auth.helpers.test.ts @@ -1,6 +1,6 @@ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; import type {HydrogenSession} from '../hydrogen'; -import {CUSTOMER_ACCOUNT_SESSION_KEY} from './constants'; +import {CUSTOMER_ACCOUNT_SESSION_KEY} from '../constants'; import {checkExpires, clearSession, refreshToken} from './auth.helpers'; vi.mock('./BadRequest', () => { @@ -36,6 +36,8 @@ function createFetchResponse(data: T, options: {ok: boolean}) { let session: HydrogenSession; +const exchangeForStorefrontCustomerAccessToken = vi.fn(); + describe('auth.helpers', () => { describe('refreshToken', () => { beforeEach(() => { @@ -58,6 +60,7 @@ describe('auth.helpers', () => { customerAccountId: 'customerAccountId', customerAccountUrl: 'customerAccountUrl', httpsOrigin: 'https://localhost', + exchangeForStorefrontCustomerAccessToken, }); } @@ -77,6 +80,7 @@ describe('auth.helpers', () => { customerAccountId: 'customerAccountId', customerAccountUrl: 'customerAccountUrl', httpsOrigin: 'https://localhost', + exchangeForStorefrontCustomerAccessToken, }); } @@ -106,6 +110,7 @@ describe('auth.helpers', () => { customerAccountId: 'customerAccountId', customerAccountUrl: 'customerAccountUrl', httpsOrigin: 'https://localhost', + exchangeForStorefrontCustomerAccessToken, }); } @@ -136,6 +141,7 @@ describe('auth.helpers', () => { customerAccountId: 'customerAccountId', customerAccountUrl: 'customerAccountUrl', httpsOrigin: 'https://localhost', + exchangeForStorefrontCustomerAccessToken, }); expect(session.set).toHaveBeenNthCalledWith( @@ -194,6 +200,7 @@ describe('auth.helpers', () => { customerAccountId: 'customerAccountId', customerAccountUrl: 'customerAccountUrl', httpsOrigin: 'https://localhost', + exchangeForStorefrontCustomerAccessToken, }); } @@ -224,6 +231,7 @@ describe('auth.helpers', () => { customerAccountId: 'customerAccountId', customerAccountUrl: 'customerAccountUrl', httpsOrigin: 'https://localhost', + exchangeForStorefrontCustomerAccessToken, }); expect(session.set).toHaveBeenNthCalledWith( @@ -265,6 +273,7 @@ describe('auth.helpers', () => { customerAccountId: 'customerAccountId', customerAccountUrl: 'customerAccountUrl', httpsOrigin: 'https://localhost', + exchangeForStorefrontCustomerAccessToken, }); expect(session.set).not.toHaveBeenNthCalledWith(1, 'customerAccount', { diff --git a/packages/hydrogen/src/customer/auth.helpers.ts b/packages/hydrogen/src/customer/auth.helpers.ts index ea055e6afb..5b2197b0a9 100644 --- a/packages/hydrogen/src/customer/auth.helpers.ts +++ b/packages/hydrogen/src/customer/auth.helpers.ts @@ -4,7 +4,8 @@ import { USER_AGENT, CUSTOMER_API_CLIENT_ID, CUSTOMER_ACCOUNT_SESSION_KEY, -} from './constants'; + BUYER_SESSION_KEY, +} from '../constants'; type H2OEvent = Parameters>[0]; @@ -72,12 +73,14 @@ export async function refreshToken({ customerAccountUrl, httpsOrigin, debugInfo, + exchangeForStorefrontCustomerAccessToken, }: { session: HydrogenSession; customerAccountId: string; customerAccountUrl: string; httpsOrigin: string; debugInfo?: Partial; + exchangeForStorefrontCustomerAccessToken: () => Promise; }) { const newBody = new URLSearchParams(); @@ -145,10 +148,13 @@ export async function refreshToken({ refreshToken: refresh_token, idToken: id_token, }); + + await exchangeForStorefrontCustomerAccessToken(); } export function clearSession(session: HydrogenSession): void { session.unset(CUSTOMER_ACCOUNT_SESSION_KEY); + session.unset(BUYER_SESSION_KEY); } export async function checkExpires({ @@ -159,6 +165,7 @@ export async function checkExpires({ customerAccountUrl, httpsOrigin, debugInfo, + exchangeForStorefrontCustomerAccessToken, }: { locks: Locks; expiresAt: string; @@ -167,6 +174,7 @@ export async function checkExpires({ customerAccountUrl: string; httpsOrigin: string; debugInfo?: Partial; + exchangeForStorefrontCustomerAccessToken: () => Promise; }) { if (parseInt(expiresAt, 10) - 1000 < new Date().getTime()) { try { @@ -178,6 +186,7 @@ export async function checkExpires({ customerAccountUrl, httpsOrigin, debugInfo, + exchangeForStorefrontCustomerAccessToken, }); await locks.refresh; diff --git a/packages/hydrogen/src/customer/customer.test.ts b/packages/hydrogen/src/customer/customer.test.ts index fffe2919df..2894391f78 100644 --- a/packages/hydrogen/src/customer/customer.test.ts +++ b/packages/hydrogen/src/customer/customer.test.ts @@ -1,7 +1,7 @@ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; import type {HydrogenSession, HydrogenSessionData} from '../hydrogen'; import {createCustomerAccountClient} from './customer'; -import {CUSTOMER_ACCOUNT_SESSION_KEY} from './constants'; +import {BUYER_SESSION_KEY, CUSTOMER_ACCOUNT_SESSION_KEY} from '../constants'; import crypto from 'node:crypto'; if (!globalThis.crypto) { @@ -55,11 +55,18 @@ const mockCustomerAccountSession: HydrogenSessionData['customerAccount'] = { nonce: 'nonce', }; +const mockBuyerSession = { + customerAccessToken: 'sha123', + companyLocationId: '1', +}; + describe('customer', () => { beforeEach(() => { session = { commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), - get: vi.fn(() => mockCustomerAccountSession) as HydrogenSession['get'], + get: vi.fn(() => { + return {...mockCustomerAccountSession, ...mockBuyerSession}; + }) as HydrogenSession['get'], set: vi.fn(), unset: vi.fn(), }; @@ -519,7 +526,6 @@ describe('customer', () => { expiresAt: expect.any(String), idToken: 'e30=.eyJub25jZSI6ICJub25jZSJ9.signature', refreshToken: 'refresh_token', - redirectPath: undefined, }), ); }); @@ -570,7 +576,50 @@ describe('customer', () => { expiresAt: expect.any(String), idToken: 'e30=.eyJub25jZSI6ICJub25jZSJ9.signature', refreshToken: 'refresh_token', - redirectPath: undefined, + }), + ); + }); + + it('exchanges for a storefront customer access token for b2b', async () => { + const redirectPath = '/account/orders'; + session = { + commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), + get: vi.fn(() => { + return {...mockCustomerAccountSession, redirectPath}; + }) as HydrogenSession['get'], + set: vi.fn(), + unset: vi.fn(), + }; + + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost?state=state&code=code'), + unstableB2b: true, + waitUntil: vi.fn(), + }); + + fetch.mockResolvedValue( + createFetchResponse( + { + access_token: 'access_token', + expires_in: '', + id_token: `${btoa('{}')}.${btoa('{"nonce": "nonce"}')}.signature`, + refresh_token: 'refresh_token', + }, + {ok: true}, + ), + ); + + const response = await customer.authorize(); + + expect(response.status).toBe(302); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('https://customer-api/account/customer/api'), + expect.objectContaining({ + body: expect.stringContaining('storefrontCustomerAccessTokenCreate'), }), ); }); @@ -835,4 +884,55 @@ describe('customer', () => { } }); }); + + describe('setBuyer()', async () => { + it('set buyer in session', async () => { + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost'), + waitUntil: vi.fn(), + }); + + customer.UNSTABLE_setBuyer(mockBuyerSession); + + expect(session.set).toHaveBeenCalledWith( + BUYER_SESSION_KEY, + expect.objectContaining(mockBuyerSession), + ); + }); + }); + + describe('setBuyer and getBuyer()', async () => { + it('returns a buyer when logged in', async () => { + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost'), + waitUntil: vi.fn(), + }); + + const buyer = await customer.UNSTABLE_getBuyer(); + + expect(buyer).toEqual(expect.objectContaining(mockBuyerSession)); + }); + + it('returns undefined when not logged in', async () => { + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost'), + waitUntil: vi.fn(), + }); + + (session.get as any).mockReturnValueOnce(undefined); + + const buyer = await customer.UNSTABLE_getBuyer(); + + expect(buyer).toBeUndefined(); + }); + }); }); diff --git a/packages/hydrogen/src/customer/customer.ts b/packages/hydrogen/src/customer/customer.ts index 3603de2c83..65b3b09517 100644 --- a/packages/hydrogen/src/customer/customer.ts +++ b/packages/hydrogen/src/customer/customer.ts @@ -3,8 +3,9 @@ import type {WritableDeep} from 'type-fest'; import { DEFAULT_CUSTOMER_API_VERSION, CUSTOMER_ACCOUNT_SESSION_KEY, + BUYER_SESSION_KEY, USER_AGENT, -} from './constants'; +} from '../constants'; import { clearSession, generateCodeChallenge, @@ -45,6 +46,7 @@ import type { CustomerAPIResponse, LoginOptions, LogoutOptions, + Buyer, } from './types'; const DEFAULT_LOGIN_URL = '/account/login'; @@ -73,6 +75,7 @@ export function createCustomerAccountClient({ authUrl, customAuthStatusHandler, logErrors = true, + unstableB2b = false, }: CustomerAccountOptions): CustomerAccount { if (customerApiVersion !== DEFAULT_CUSTOMER_API_VERSION) { console.warn( @@ -229,6 +232,7 @@ export function createCustomerAccountClient({ stackInfo, ...getDebugHeaders(request), }, + exchangeForStorefrontCustomerAccessToken, }); } catch { return false; @@ -250,6 +254,85 @@ export function createCustomerAccountClient({ return session.get(CUSTOMER_ACCOUNT_SESSION_KEY)?.accessToken; } + async function mutate( + mutation: Parameters[0], + options?: Parameters[1], + ) { + ifInvalidCredentialThrowError(customerAccountUrl, customerAccountId); + + mutation = minifyQuery(mutation); + assertMutation(mutation, 'customer.mutate'); + + return withSyncStack( + fetchCustomerAPI({query: mutation, type: 'mutation', ...options}), + {logErrors}, + ); + } + + async function query( + query: Parameters[0], + options?: Parameters[1], + ) { + ifInvalidCredentialThrowError(customerAccountUrl, customerAccountId); + + query = minifyQuery(query); + assertQuery(query, 'customer.query'); + + return withSyncStack(fetchCustomerAPI({query, type: 'query', ...options}), { + logErrors, + }); + } + + function setBuyer(buyer: Buyer) { + session.set(BUYER_SESSION_KEY, { + ...session.get(BUYER_SESSION_KEY), + ...buyer, + }); + } + + async function getBuyer() { + // check loggedIn and trigger refresh if expire + const hasAccessToken = await isLoggedIn(); + + if (!hasAccessToken) { + return; + } + + return session.get(BUYER_SESSION_KEY); + } + + async function exchangeForStorefrontCustomerAccessToken() { + if (!unstableB2b) { + return; + } + + const STOREFRONT_CUSTOMER_ACCOUNT_TOKEN_CREATE = `#graphql + mutation storefrontCustomerAccessTokenCreate { + storefrontCustomerAccessTokenCreate { + customerAccessToken + } + } + `; + + // Remove hard coded type later + const {data} = (await mutate(STOREFRONT_CUSTOMER_ACCOUNT_TOKEN_CREATE)) as { + data: { + storefrontCustomerAccessTokenCreate?: { + customerAccessToken?: string; + }; + }; + }; + + const customerAccessToken = + data?.storefrontCustomerAccessTokenCreate?.customerAccessToken; + + if (customerAccessToken) { + setBuyer({ + customerAccessToken, + }); + } + } + return { login: async (options?: LoginOptions) => { ifInvalidCredentialThrowError(customerAccountUrl, customerAccountId); @@ -334,28 +417,8 @@ export function createCustomerAccountClient({ handleAuthStatus, getAccessToken, getApiUrl: () => customerAccountApiUrl, - mutate(mutation, options?) { - ifInvalidCredentialThrowError(customerAccountUrl, customerAccountId); - - mutation = minifyQuery(mutation); - assertMutation(mutation, 'customer.mutate'); - - return withSyncStack( - fetchCustomerAPI({query: mutation, type: 'mutation', ...options}), - {logErrors}, - ); - }, - query(query, options?) { - ifInvalidCredentialThrowError(customerAccountUrl, customerAccountId); - - query = minifyQuery(query); - assertQuery(query, 'customer.query'); - - return withSyncStack( - fetchCustomerAPI({query, type: 'query', ...options}), - {logErrors}, - ); - }, + mutate: mutate as CustomerAccount['mutate'], + query: query as CustomerAccount['query'], authorize: async () => { ifInvalidCredentialThrowError(customerAccountUrl, customerAccountId); @@ -473,20 +536,22 @@ export function createCustomerAccountClient({ session.set(CUSTOMER_ACCOUNT_SESSION_KEY, { accessToken: customerAccessToken, expiresAt: - new Date( - new Date().getTime() + (expires_in! - 120) * 1000, - ).getTime() + '', + new Date(new Date().getTime() + (expires_in - 120) * 1000).getTime() + + '', refreshToken: refresh_token, idToken: id_token, - redirectPath: undefined, }); + await exchangeForStorefrontCustomerAccessToken(); + return redirect(redirectPath || DEFAULT_REDIRECT_PATH, { headers: { 'Set-Cookie': await session.commit(), }, }); }, + UNSTABLE_setBuyer: setBuyer, + UNSTABLE_getBuyer: getBuyer, }; } diff --git a/packages/hydrogen/src/customer/types.ts b/packages/hydrogen/src/customer/types.ts index 8d6b7e5362..5ebad85c1b 100644 --- a/packages/hydrogen/src/customer/types.ts +++ b/packages/hydrogen/src/customer/types.ts @@ -3,11 +3,12 @@ import type { ClientVariablesInRestParams, } from '@shopify/hydrogen-codegen'; import {type GraphQLError} from '../utils/graphql'; - import type {CrossRuntimeRequest} from '../utils/request'; - import type {HydrogenSession} from '../hydrogen'; -import {LanguageCode} from '@shopify/hydrogen-react/storefront-api-types'; +import type { + LanguageCode, + BuyerInput, +} from '@shopify/hydrogen-react/storefront-api-types'; // Return type of unauthorizedHandler = Return type of loader/action function // This type is not exported https://github.com/remix-run/react-router/blob/main/packages/router/utils.ts#L167 @@ -15,6 +16,8 @@ type DataFunctionValue = Response | NonNullable | null; type JsonGraphQLError = ReturnType; // Equivalent to `Jsonify[]` +export type Buyer = Partial; + export type CustomerAPIResponse = { data: ReturnType; errors: Array<{ @@ -116,6 +119,10 @@ export type CustomerAccount = { 'errors' > & {errors?: JsonGraphQLError[]} >; + /** UNSTABLE feature. Set buyer information into session.*/ + UNSTABLE_setBuyer: (buyer: Buyer) => void; + /** UNSTABLE feature. Get buyer token and company location id from session.*/ + UNSTABLE_getBuyer: () => Promise; }; export type CustomerAccountOptions = { @@ -137,6 +144,8 @@ export type CustomerAccountOptions = { customAuthStatusHandler?: () => DataFunctionValue; /** Whether it should print GraphQL errors automatically. Defaults to true */ logErrors?: boolean | ((error?: Error) => boolean); + /** UNSTABLE feature, this will eventually goes away. If true then we will exchange customerAccessToken for storefrontCustomerAccessToken. */ + unstableB2b?: boolean; }; /** Below are types meant for documentation only. Ensure it stay in sync with the type above. */ diff --git a/packages/hydrogen/src/hydrogen.d.ts b/packages/hydrogen/src/hydrogen.d.ts index 7d99134c20..79072d0b18 100644 --- a/packages/hydrogen/src/hydrogen.d.ts +++ b/packages/hydrogen/src/hydrogen.d.ts @@ -5,9 +5,11 @@ import type { FlashSessionData, } from '@remix-run/server-runtime'; import type {RequestEventPayload} from './vite/request-events'; +import {CUSTOMER_ACCOUNT_SESSION_KEY} from './constants'; +import type {BuyerInput} from '@shopify/hydrogen-react/storefront-api-types'; export interface HydrogenSessionData { - customerAccount: { + [CUSTOMER_ACCOUNT_SESSION_KEY]: { accessToken?: string; expiresAt?: string; refreshToken?: string; @@ -17,6 +19,8 @@ export interface HydrogenSessionData { state?: string; redirectPath?: string; }; + // for B2B buyer context + [BUYER_SESSION_KEY]: Partial; } export interface HydrogenSession< diff --git a/packages/hydrogen/src/storefront.test.ts b/packages/hydrogen/src/storefront.test.ts index 95e993197b..6f3bc614ff 100644 --- a/packages/hydrogen/src/storefront.test.ts +++ b/packages/hydrogen/src/storefront.test.ts @@ -6,9 +6,14 @@ import { SHOPIFY_STOREFRONT_S_HEADER, SHOPIFY_STOREFRONT_Y_HEADER, } from '@shopify/hydrogen-react'; +import { + BUYER_ACCESS_TOKEN, + BUYER_LOCATION_ID, + mockCreateCustomerAccountClient, +} from './cart/cart-test-helper'; vi.mock('./cache/fetch.ts', async () => { - const original = await vi.importActual( + const original = await vi.importActual( './cache/fetch.ts', );