From 891b7c2abdbd0c802eaedf158d4941c963e890ed Mon Sep 17 00:00:00 2001 From: Dustin Firman Date: Mon, 22 Apr 2024 15:07:26 -0400 Subject: [PATCH] cart handles quantity rules --- examples/b2b/app/components/Cart.tsx | 349 ++++++++++++++++++++++ examples/b2b/app/lib/fragments.ts | 118 ++++++++ examples/b2b/storefrontapi.generated.d.ts | 75 ++--- 3 files changed, 489 insertions(+), 53 deletions(-) create mode 100644 examples/b2b/app/components/Cart.tsx create mode 100644 examples/b2b/app/lib/fragments.ts 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 ( +
+ +
+ ); +} + +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 ( +
    + +

    Continue to Checkout →

    +
    +
    +
    + ); +} + +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 ( + + ); +} + +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/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;