From 8aea48ff1f7062f9e69faba4894db766272ef1f3 Mon Sep 17 00:00:00 2001 From: ataideverton <56592231+ataideverton@users.noreply.github.com> Date: Mon, 6 May 2024 10:55:24 -0300 Subject: [PATCH] packing (#2286) ## What's the purpose of this pull request? To enable the `unitMultiplier` prop to be used on the `quantity-selector` molecule, as part os the Faststore B2B initiative. The funcionality delivered by this PR already exists on Storeframework and is related to a mandatory field on the sku registry. Every single SKU has a quantity progression that is the result of configuration (The standard configuration being one, resulting in the more common case where the quantity increses in increments of 1) [Product Vision](https://docs.google.com/document/d/17tJprtQEs9izw6Zh49thA90NuljoFLoMOyHwJvJAH1Q/edit#heading=h.tglo77yl0lf5) [B2B Faststore Initiative ](https://docs.google.com/document/d/19jAFzUTJRhDSK5b6ieNigxDu5NnPC5gghPkMIaP6ic8/edit#heading=h.tglo77yl0lf5) [Packing RFC](https://docs.google.com/document/d/1fqhL6ue9isar7UF3CmKlspxHUta7DzTv6D18DazxoRo/edit#heading=h.tglo77yl0lf5) ## How it works? When `useUnitMultiplier` is enabled, quantity-selectors will increase on increments of the number defined on the UnitMultiplier field from that sku. Demo: On, on an sku with unitMultiplier of 3.5 https://github.com/vtex/faststore/assets/56592231/f25df0ef-9bde-4d85-9b27-13bd380c45ca On, on an sku with the default unitMultiplier of 1 (Every SKU has a value for this prop, it's mandatory) https://github.com/vtex/faststore/assets/56592231/f2974361-ac23-4b37-aba9-7c71756c950f ## How to test it? **Using the prop:** - On `faststore.config.default.js` change (To use an account with unit multipliers that are not 1): - storeId: b2bfaststoredev - workspace: evertonprod Or you can use [this preview](https://sfj-1ce2884--b2bfaststoredev.preview.vtex.app/): Product with unitMultiplier as 3.5: `precision-pro-fspe-100-jigsaw2-84/p` Product with unitMultiplier as 1: `combination-spanner-set-storage2-88/p` --------- Co-authored-by: Everton Ataide Co-authored-by: Victor Hugo M. Pinto --- packages/api/src/__generated__/schema.ts | 2 + .../src/platforms/vtex/resolvers/product.ts | 2 + packages/api/src/typeDefs/product.graphql | 4 ++ .../src/molecules/CartItem/CartItem.tsx | 12 ++++++ .../QuantitySelector/QuantitySelector.tsx | 42 ++++++++++++++++--- packages/core/@generated/gql.ts | 8 ++-- packages/core/@generated/graphql.ts | 18 ++++++-- packages/core/cms/faststore/sections.json | 22 ++++++++++ .../src/components/cart/CartItem/CartItem.tsx | 5 ++- .../cart/CartSidebar/CartSidebar.tsx | 11 ++++- .../ProductDetails/ProductDetails.tsx | 9 +++- .../ProductDetails/ProductDetailsSettings.tsx | 7 ++++ packages/core/src/sdk/cart/index.ts | 1 + 13 files changed, 127 insertions(+), 16 deletions(-) diff --git a/packages/api/src/__generated__/schema.ts b/packages/api/src/__generated__/schema.ts index df20cea475..851d8f1784 100644 --- a/packages/api/src/__generated__/schema.ts +++ b/packages/api/src/__generated__/schema.ts @@ -970,6 +970,8 @@ export type StoreProduct = { sku: Scalars['String']; /** Corresponding collection URL slug, with which to retrieve this entity. */ slug: Scalars['String']; + /** Sku Unit Multiplier */ + unitMultiplier?: Maybe; }; diff --git a/packages/api/src/platforms/vtex/resolvers/product.ts b/packages/api/src/platforms/vtex/resolvers/product.ts index 473e1bf8e0..db945e61f9 100644 --- a/packages/api/src/platforms/vtex/resolvers/product.ts +++ b/packages/api/src/platforms/vtex/resolvers/product.ts @@ -18,6 +18,7 @@ type QueryProduct = PromiseType> export type Root = QueryProduct & { attachmentsValues?: Attachment[] + unitMultiplier: number } const DEFAULT_IMAGE = { @@ -53,6 +54,7 @@ export const StoreProduct: Record> & { canonical: canonicalFromProduct(isVariantOf), }), brand: ({ isVariantOf: { brand } }) => ({ name: brand }), + unitMultiplier: ({unitMultiplier}) => unitMultiplier, breadcrumbList: ({ isVariantOf: { categories, productName, linkText }, itemId, diff --git a/packages/api/src/typeDefs/product.graphql b/packages/api/src/typeDefs/product.graphql index 06bfe4e9d6..1fb4613112 100644 --- a/packages/api/src/typeDefs/product.graphql +++ b/packages/api/src/typeDefs/product.graphql @@ -66,6 +66,10 @@ type StoreProduct { The product's release date. Formatted using https://en.wikipedia.org/wiki/ISO_8601 """ releaseDate: String! + """ + Sku Unit Multiplier + """ + unitMultiplier: Float } """ diff --git a/packages/components/src/molecules/CartItem/CartItem.tsx b/packages/components/src/molecules/CartItem/CartItem.tsx index 65ad631336..c44365f562 100644 --- a/packages/components/src/molecules/CartItem/CartItem.tsx +++ b/packages/components/src/molecules/CartItem/CartItem.tsx @@ -25,6 +25,14 @@ export interface CartItemProps extends HTMLAttributes { */ quantity?: number /** + * Controls by how many units the value advances + **/ + unitMultiplier?: number + /** + * Controls wheter you use or not the unitMultiplier + */ + useUnitMultiplier?: boolean + /** * Specifies that this product is unavailable. */ unavailable?: boolean @@ -45,6 +53,8 @@ const CartItem = forwardRef(function CartItem( quantity, unavailable, onQuantityChange, + unitMultiplier, + useUnitMultiplier, children, removeBtnProps, ...otherProps @@ -69,6 +79,8 @@ const CartItem = forwardRef(function CartItem( { const [quantity, setQuantity] = useState(initial ?? min) + const [multipliedUnit, setMultipliedUnit] = useState(quantity * unitMultiplier) + + const roundUpQuantityIfNeeded = (quantity: number) => { + if(!useUnitMultiplier){ + return quantity + } + return Math.ceil(quantity / unitMultiplier) * unitMultiplier; + } const isLeftDisabled = quantity === min - const isRightDisabled = quantity === max + const isRightDisabled = quantity === max const changeQuantity = (increaseValue: number) => { const quantityValue = validateQuantityBounds(quantity + increaseValue) onChange?.(quantityValue) setQuantity(quantityValue) + setMultipliedUnit(quantityValue * unitMultiplier) } - + const increase = () => changeQuantity(1) const decrease = () => changeQuantity(-1) @@ -59,7 +78,18 @@ const QuantitySelector = ({ function validateQuantityBounds(n: number): number { const maxValue = min ? Math.max(n, min) : n - return max ? Math.min(maxValue, max) : maxValue + return max ? Math.min(maxValue, useUnitMultiplier ? max * unitMultiplier : max) : maxValue + } + + function validateBlur() { + const roundedQuantity = roundUpQuantityIfNeeded(quantity) + + setQuantity(() => { + setMultipliedUnit(roundedQuantity) + onChange?.(roundedQuantity / unitMultiplier) + + return roundedQuantity / unitMultiplier + }) } function validateInput(e: React.FormEvent) { @@ -68,7 +98,7 @@ const QuantitySelector = ({ if (!Number.isNaN(Number(val))) { setQuantity(() => { const quantityValue = validateQuantityBounds(Number(val)) - + setMultipliedUnit(quantityValue) onChange?.(quantityValue) return quantityValue @@ -76,6 +106,7 @@ const QuantitySelector = ({ } } + useEffect(() => { initial && setQuantity(initial) }, [initial]) @@ -100,8 +131,9 @@ const QuantitySelector = ({ data-quantity-selector-input id="quantity-selector-input" aria-label="Quantity" - value={quantity} + value={useUnitMultiplier ? multipliedUnit : quantity} onChange={validateInput} + onBlur={validateBlur} disabled={disabled} /> } /** Product information. Products are variants within product groups, equivalent to VTEX [SKUs](https://help.vtex.com/en/tutorial/what-is-an-sku--1K75s4RXAQyOuGUYKMM68u#). For example, you may have a **Shirt** product group with associated products such as **Blue shirt size L**, **Green shirt size XL** and so on. */ @@ -1146,6 +1148,7 @@ export type ProductDetailsFragment_ProductFragment = { name: string gtin: string description: string + unitMultiplier: number | null id: string isVariantOf: { name: string @@ -1227,6 +1230,7 @@ export type ServerProductQueryQuery = { name: string description: string releaseDate: string + unitMultiplier: number | null id: string seo: { title: string; description: string; canonical: string } brand: { name: string } @@ -1283,6 +1287,7 @@ export type ValidateCartMutationMutation = { itemOffered: { sku: string name: string + unitMultiplier: number | null gtin: string image: Array<{ url: string; alternateName: string }> brand: { name: string } @@ -1318,6 +1323,7 @@ export type CartItemFragment = { itemOffered: { sku: string name: string + unitMultiplier: number | null gtin: string image: Array<{ url: string; alternateName: string }> brand: { name: string } @@ -1342,6 +1348,7 @@ export type CartItemFragment = { export type CartProductItemFragment = { sku: string name: string + unitMultiplier: number | null gtin: string image: Array<{ url: string; alternateName: string }> brand: { name: string } @@ -1469,6 +1476,7 @@ export type ClientProductQueryQuery = { name: string gtin: string description: string + unitMultiplier: number | null id: string isVariantOf: { name: string @@ -1705,6 +1713,7 @@ export const CartProductItemFragmentDoc = new TypedDocumentString( fragment CartProductItem on StoreProduct { sku name + unitMultiplier image { url alternateName @@ -1741,6 +1750,7 @@ export const ProductDetailsFragment_ProductFragmentDoc = name gtin description + unitMultiplier isVariantOf { name productGroupID @@ -1779,6 +1789,7 @@ export const ProductDetailsFragment_ProductFragmentDoc = fragment CartProductItem on StoreProduct { sku name + unitMultiplier image { url alternateName @@ -1943,6 +1954,7 @@ export const CartItemFragmentDoc = new TypedDocumentString( fragment CartProductItem on StoreProduct { sku name + unitMultiplier image { url alternateName @@ -1991,7 +2003,7 @@ export const ServerCollectionPageQueryDocument = { export const ServerProductQueryDocument = { __meta__: { operationName: 'ServerProductQuery', - operationHash: '50155d908ff90781e8c56134ded29b70d7494aa7', + operationHash: '3ce56e42296689b601347fedc380c89519355ab7', }, } as unknown as TypedDocumentString< ServerProductQueryQuery, @@ -2000,7 +2012,7 @@ export const ServerProductQueryDocument = { export const ValidateCartMutationDocument = { __meta__: { operationName: 'ValidateCartMutation', - operationHash: '87e1ba227013cb087bcbb35584c1b0b7cdf612ef', + operationHash: '534fae829675533052d75fd4aa509b9cf85b4d40', }, } as unknown as TypedDocumentString< ValidateCartMutationMutation, @@ -2036,7 +2048,7 @@ export const ClientProductGalleryQueryDocument = { export const ClientProductQueryDocument = { __meta__: { operationName: 'ClientProductQuery', - operationHash: 'a35530c2f55c1c85bd2b4fe4964356ab27e32622', + operationHash: 'cedeb0c3e7ec1678400fe2ae930f5a79382fba1e', }, } as unknown as TypedDocumentString< ClientProductQueryQuery, diff --git a/packages/core/cms/faststore/sections.json b/packages/core/cms/faststore/sections.json index 2e61691d9e..86242e977e 100644 --- a/packages/core/cms/faststore/sections.json +++ b/packages/core/cms/faststore/sections.json @@ -1538,6 +1538,17 @@ "default": "Description" } } + }, + "quantitySelector": { + "title": "Quantity Selector", + "type": "object", + "properties": { + "useUnitMultiplier": { + "title": "Should use unit multiplier?", + "type": "boolean", + "default": false + } + } } } } @@ -1831,6 +1842,17 @@ } } } + }, + "quantitySelector": { + "title": "Quantity Selector", + "type": "object", + "properties": { + "useUnitMultiplier": { + "title": "Should use unit multiplier?", + "type": "boolean", + "default": false + } + } } } } diff --git a/packages/core/src/components/cart/CartItem/CartItem.tsx b/packages/core/src/components/cart/CartItem/CartItem.tsx index ff08fed138..6cfb9b0561 100644 --- a/packages/core/src/components/cart/CartItem/CartItem.tsx +++ b/packages/core/src/components/cart/CartItem/CartItem.tsx @@ -62,9 +62,10 @@ function useCartItemEvent() { interface Props { item: ICartItem + useUnitMultiplier: boolean } -function CartItem({ item }: Props) { +function CartItem({ item, useUnitMultiplier = false }: Props) { const btnProps = useRemoveButton(item) const { sendCartItemEvent } = useCartItemEvent() @@ -97,6 +98,8 @@ function CartItem({ item }: Props) { removeBtnProps={btnProps} data-sku={item.itemOffered.sku} data-seller={item.seller.identifier} + unitMultiplier={item.itemOffered.unitMultiplier} + useUnitMultiplier={useUnitMultiplier} > {items.map((item) => (
  • - +
  • ))} {gifts.length > 0 && ( diff --git a/packages/core/src/components/sections/ProductDetails/ProductDetails.tsx b/packages/core/src/components/sections/ProductDetails/ProductDetails.tsx index 5331af6e87..665a9b20bc 100644 --- a/packages/core/src/components/sections/ProductDetails/ProductDetails.tsx +++ b/packages/core/src/components/sections/ProductDetails/ProductDetails.tsx @@ -51,6 +51,9 @@ export interface ProductDetailsProps { notAvailableButton: { title: string } + quantitySelector: { + useUnitMultiplier?: boolean + } } function ProductDetails({ @@ -71,6 +74,7 @@ function ProductDetails({ displayDescription: shouldDisplayProductDescription, }, notAvailableButton: { title: notAvailableButtonTitle }, + quantitySelector, }: ProductDetailsProps) { const { DiscountBadge, @@ -80,9 +84,9 @@ function ProductDetails({ __experimentalNotAvailableButton: NotAvailableButton, } = useOverrideComponents<'ProductDetails'>() const { currency } = useSession() - const [quantity, setQuantity] = useState(1) const context = usePDP() const { product, isValidating } = context?.data + const [quantity, setQuantity] = useState(1) if (!product) { throw new Error('NotFound') @@ -188,6 +192,7 @@ function ProductDetails({ notAvailableButtonTitle={ notAvailableButtonTitle ?? NotAvailableButton.props.title } + useUnitMultiplier={quantitySelector?.useUnitMultiplier ?? false} /> @@ -245,7 +250,7 @@ export const fragment = gql(` name gtin description - + unitMultiplier isVariantOf { name productGroupID diff --git a/packages/core/src/components/ui/ProductDetails/ProductDetailsSettings.tsx b/packages/core/src/components/ui/ProductDetails/ProductDetailsSettings.tsx index 898efdf326..cee677b705 100644 --- a/packages/core/src/components/ui/ProductDetails/ProductDetailsSettings.tsx +++ b/packages/core/src/components/ui/ProductDetails/ProductDetailsSettings.tsx @@ -5,6 +5,7 @@ import type { ProductDetailsFragment_ProductFragment } from '@generated/graphql' import { useBuyButton } from 'src/sdk/cart/useBuyButton' import { useFormattedPrice } from 'src/sdk/product/useFormattedPrice' +import config from '../../../../faststore.config' import Selectors from 'src/components/ui/SkuSelector' import AddToCartLoadingSkeleton from './AddToCartLoadingSkeleton' @@ -22,6 +23,7 @@ interface ProductDetailsSettingsProps { quantity: number setQuantity: Dispatch> notAvailableButtonTitle: string + useUnitMultiplier: boolean } function ProductDetailsSettings({ @@ -32,6 +34,7 @@ function ProductDetailsSettings({ setQuantity, buyButtonIcon: { icon: buyButtonIconName, alt: buyButtonIconAlt }, notAvailableButtonTitle, + useUnitMultiplier, }: ProductDetailsSettingsProps) { const { BuyButton, @@ -45,6 +48,7 @@ function ProductDetailsSettings({ id, sku, gtin, + unitMultiplier, name: variantName, brand, isVariantOf, @@ -70,6 +74,7 @@ function ProductDetailsSettings({ brand, isVariantOf, additionalProperty, + unitMultiplier, }, }) @@ -115,6 +120,8 @@ function ProductDetailsSettings({