diff --git a/examples/b2b/app/components/Cart.tsx b/examples/b2b/app/components/Cart.tsx
new file mode 100644
index 0000000000..ecaa663bb6
--- /dev/null
+++ b/examples/b2b/app/components/Cart.tsx
@@ -0,0 +1,349 @@
+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 && (
+
+ )}
+
+
+
{
+ 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} = line.merchandise.quantityRule;
+ const prevQuantity = Number(Math.max(0, quantity - increment).toFixed(0));
+ const nextQuantity = Number((quantity + increment).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 (
+
+
+
+ Looks like you haven’t added anything yet, let’s get you
+ started!
+
+
+
{
+ if (layout === 'aside') {
+ window.location.href = '/collections';
+ }
+ }}
+ >
+ Continue shopping →
+
+
+ );
+}
+
+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 */}
+
+
+
- Discount(s)
+
+
+ {codes?.join(', ')}
+
+
+
+
+
+
+
+ {/* 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/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/storefrontapi.generated.d.ts b/examples/b2b/storefrontapi.generated.d.ts
index 4cb7ce8648..41ff7363f2 100644
--- a/examples/b2b/storefrontapi.generated.d.ts
+++ b/examples/b2b/storefrontapi.generated.d.ts
@@ -35,6 +35,17 @@ export type CartLineFragment = Pick<
selectedOptions: Array<
Pick
>;
+ quantityRule: Pick<
+ StorefrontAPI.QuantityRule,
+ 'maximum' | 'minimum' | 'increment'
+ >;
+ quantityPriceBreaks: {
+ nodes: Array<
+ Pick & {
+ price: Pick;
+ }
+ >;
+ };
};
};
@@ -88,6 +99,17 @@ export type CartApiQueryFragment = Pick<
selectedOptions: Array<
Pick
>;
+ quantityRule: Pick<
+ StorefrontAPI.QuantityRule,
+ 'maximum' | 'minimum' | 'increment'
+ >;
+ quantityPriceBreaks: {
+ nodes: Array<
+ Pick & {
+ price: Pick;
+ }
+ >;
+ };
};
}
>;
@@ -676,55 +698,6 @@ export type StoreCollectionsQuery = {
};
};
-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;
@@ -1250,10 +1223,6 @@ interface GeneratedQueryTypes {
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;