Skip to content

Commit

Permalink
packing (#2286)
Browse files Browse the repository at this point in the history
## 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 <[email protected]>
Co-authored-by: Victor Hugo M. Pinto <[email protected]>
  • Loading branch information
3 people authored May 6, 2024
1 parent 1e25e0e commit 8aea48f
Show file tree
Hide file tree
Showing 13 changed files with 127 additions and 16 deletions.
2 changes: 2 additions & 0 deletions packages/api/src/__generated__/schema.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/api/src/platforms/vtex/resolvers/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type QueryProduct = PromiseType<ReturnType<typeof Query.product>>

export type Root = QueryProduct & {
attachmentsValues?: Attachment[]
unitMultiplier: number
}

const DEFAULT_IMAGE = {
Expand Down Expand Up @@ -53,6 +54,7 @@ export const StoreProduct: Record<string, Resolver<Root>> & {
canonical: canonicalFromProduct(isVariantOf),
}),
brand: ({ isVariantOf: { brand } }) => ({ name: brand }),
unitMultiplier: ({unitMultiplier}) => unitMultiplier,
breadcrumbList: ({
isVariantOf: { categories, productName, linkText },
itemId,
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/typeDefs/product.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

"""
Expand Down
12 changes: 12 additions & 0 deletions packages/components/src/molecules/CartItem/CartItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ export interface CartItemProps extends HTMLAttributes<HTMLDivElement> {
*/
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
Expand All @@ -45,6 +53,8 @@ const CartItem = forwardRef<HTMLDivElement, CartItemProps>(function CartItem(
quantity,
unavailable,
onQuantityChange,
unitMultiplier,
useUnitMultiplier,
children,
removeBtnProps,
...otherProps
Expand All @@ -69,6 +79,8 @@ const CartItem = forwardRef<HTMLDivElement, CartItemProps>(function CartItem(
<QuantitySelector
min={1}
initial={quantity}
unitMultiplier={unitMultiplier}
useUnitMultiplier={useUnitMultiplier}
onChange={onQuantityChange}
/>
<ProductPrice
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ export interface QuantitySelectorProps {
* The initial value for quantity selector
*/
initial?: number
/**
* Controls by how many units the value advances
*/
unitMultiplier?: number
/**
* Controls wheter you use or not the unitMultiplier
*/
useUnitMultiplier?: boolean
/**
* Specifies that the whole quantity selector component should be disabled.
*/
Expand All @@ -34,32 +42,54 @@ export interface QuantitySelectorProps {
const QuantitySelector = ({
max,
min = 1,
unitMultiplier = 1,
useUnitMultiplier,
initial,
disabled = false,
onChange,
testId = 'fs-quantity-selector',
...otherProps
}: QuantitySelectorProps) => {
const [quantity, setQuantity] = useState<number>(initial ?? min)
const [multipliedUnit, setMultipliedUnit] = useState<number>(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)

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<HTMLInputElement>) {
Expand All @@ -68,14 +98,15 @@ const QuantitySelector = ({
if (!Number.isNaN(Number(val))) {
setQuantity(() => {
const quantityValue = validateQuantityBounds(Number(val))

setMultipliedUnit(quantityValue)
onChange?.(quantityValue)

return quantityValue
})
}
}


useEffect(() => {
initial && setQuantity(initial)
}, [initial])
Expand All @@ -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}
/>
<IconButton
Expand Down
8 changes: 4 additions & 4 deletions packages/core/@generated/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const documents = {
types.ProductSummary_ProductFragmentDoc,
'\n fragment Filter_facets on StoreFacet {\n ... on StoreFacetRange {\n key\n label\n\n min {\n selected\n absolute\n }\n\n max {\n selected\n absolute\n }\n\n __typename\n }\n ... on StoreFacetBoolean {\n key\n label\n values {\n label\n value\n selected\n quantity\n }\n\n __typename\n }\n }\n':
types.Filter_FacetsFragmentDoc,
'\n fragment ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n\n isVariantOf {\n name\n productGroupID\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n offers {\n availability\n price\n listPrice\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n':
'\n fragment ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n offers {\n availability\n price\n listPrice\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n':
types.ProductDetailsFragment_ProductFragmentDoc,
'\n fragment ClientManyProducts on Query {\n search(\n first: $first\n after: $after\n sort: $sort\n term: $term\n selectedFacets: $selectedFacets\n ) {\n products {\n pageInfo {\n totalCount\n }\n }\n }\n }\n':
types.ClientManyProductsFragmentDoc,
Expand All @@ -38,7 +38,7 @@ const documents = {
types.ServerCollectionPageQueryDocument,
'\n query ServerProductQuery($locator: [IStoreSelectedFacet!]!) {\n ...ServerProduct\n product(locator: $locator) {\n id: productID\n\n seo {\n title\n description\n canonical\n }\n\n brand {\n name\n }\n\n sku\n gtin\n name\n description\n releaseDate\n\n breadcrumbList {\n itemListElement {\n item\n name\n position\n }\n }\n\n image {\n url\n alternateName\n }\n\n offers {\n lowPrice\n highPrice\n priceCurrency\n offers {\n availability\n price\n priceValidUntil\n priceCurrency\n itemCondition\n seller {\n identifier\n }\n }\n }\n\n isVariantOf {\n productGroupID\n }\n\n ...ProductDetailsFragment_product\n }\n }\n':
types.ServerProductQueryDocument,
'\n mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {\n validateCart(cart: $cart, session: $session) {\n order {\n orderNumber\n acceptedOffer {\n ...CartItem\n }\n }\n messages {\n ...CartMessage\n }\n }\n }\n\n fragment CartMessage on StoreCartMessage {\n text\n status\n }\n\n fragment CartItem on StoreOffer {\n seller {\n identifier\n }\n quantity\n price\n listPrice\n itemOffered {\n ...CartProductItem\n }\n }\n\n fragment CartProductItem on StoreProduct {\n sku\n name\n image {\n url\n alternateName\n }\n brand {\n name\n }\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n gtin\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n }\n':
'\n mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {\n validateCart(cart: $cart, session: $session) {\n order {\n orderNumber\n acceptedOffer {\n ...CartItem\n }\n }\n messages {\n ...CartMessage\n }\n }\n }\n\n fragment CartMessage on StoreCartMessage {\n text\n status\n }\n\n fragment CartItem on StoreOffer {\n seller {\n identifier\n }\n quantity\n price\n listPrice\n itemOffered {\n ...CartProductItem\n }\n }\n\n fragment CartProductItem on StoreProduct {\n sku\n name\n unitMultiplier\n image {\n url\n alternateName\n }\n brand {\n name\n }\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n gtin\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n }\n':
types.ValidateCartMutationDocument,
'\n mutation SubscribeToNewsletter($data: IPersonNewsletter!) {\n subscribeToNewsletter(data: $data) {\n id\n }\n }\n':
types.SubscribeToNewsletterDocument,
Expand Down Expand Up @@ -76,7 +76,7 @@ export function gql(
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function gql(
source: '\n fragment ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n\n isVariantOf {\n name\n productGroupID\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n offers {\n availability\n price\n listPrice\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n'
source: '\n fragment ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n offers {\n availability\n price\n listPrice\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n'
): typeof import('./graphql').ProductDetailsFragment_ProductFragmentDoc
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
Expand Down Expand Up @@ -142,7 +142,7 @@ export function gql(
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function gql(
source: '\n mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {\n validateCart(cart: $cart, session: $session) {\n order {\n orderNumber\n acceptedOffer {\n ...CartItem\n }\n }\n messages {\n ...CartMessage\n }\n }\n }\n\n fragment CartMessage on StoreCartMessage {\n text\n status\n }\n\n fragment CartItem on StoreOffer {\n seller {\n identifier\n }\n quantity\n price\n listPrice\n itemOffered {\n ...CartProductItem\n }\n }\n\n fragment CartProductItem on StoreProduct {\n sku\n name\n image {\n url\n alternateName\n }\n brand {\n name\n }\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n gtin\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n }\n'
source: '\n mutation ValidateCartMutation($cart: IStoreCart!, $session: IStoreSession!) {\n validateCart(cart: $cart, session: $session) {\n order {\n orderNumber\n acceptedOffer {\n ...CartItem\n }\n }\n messages {\n ...CartMessage\n }\n }\n }\n\n fragment CartMessage on StoreCartMessage {\n text\n status\n }\n\n fragment CartItem on StoreOffer {\n seller {\n identifier\n }\n quantity\n price\n listPrice\n itemOffered {\n ...CartProductItem\n }\n }\n\n fragment CartProductItem on StoreProduct {\n sku\n name\n unitMultiplier\n image {\n url\n alternateName\n }\n brand {\n name\n }\n isVariantOf {\n productGroupID\n name\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n gtin\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n }\n'
): typeof import('./graphql').ValidateCartMutationDocument
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
Expand Down
18 changes: 15 additions & 3 deletions packages/core/@generated/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,8 @@ export type StoreProduct = {
sku: Scalars['String']['output']
/** Corresponding collection URL slug, with which to retrieve this entity. */
slug: Scalars['String']['output']
/** Sku Unit Multiplier */
unitMultiplier: Maybe<Scalars['Float']['output']>
}

/** 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. */
Expand Down Expand Up @@ -1146,6 +1148,7 @@ export type ProductDetailsFragment_ProductFragment = {
name: string
gtin: string
description: string
unitMultiplier: number | null
id: string
isVariantOf: {
name: string
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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 }
Expand All @@ -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 }
Expand Down Expand Up @@ -1469,6 +1476,7 @@ export type ClientProductQueryQuery = {
name: string
gtin: string
description: string
unitMultiplier: number | null
id: string
isVariantOf: {
name: string
Expand Down Expand Up @@ -1705,6 +1713,7 @@ export const CartProductItemFragmentDoc = new TypedDocumentString(
fragment CartProductItem on StoreProduct {
sku
name
unitMultiplier
image {
url
alternateName
Expand Down Expand Up @@ -1741,6 +1750,7 @@ export const ProductDetailsFragment_ProductFragmentDoc =
name
gtin
description
unitMultiplier
isVariantOf {
name
productGroupID
Expand Down Expand Up @@ -1779,6 +1789,7 @@ export const ProductDetailsFragment_ProductFragmentDoc =
fragment CartProductItem on StoreProduct {
sku
name
unitMultiplier
image {
url
alternateName
Expand Down Expand Up @@ -1943,6 +1954,7 @@ export const CartItemFragmentDoc = new TypedDocumentString(
fragment CartProductItem on StoreProduct {
sku
name
unitMultiplier
image {
url
alternateName
Expand Down Expand Up @@ -1991,7 +2003,7 @@ export const ServerCollectionPageQueryDocument = {
export const ServerProductQueryDocument = {
__meta__: {
operationName: 'ServerProductQuery',
operationHash: '50155d908ff90781e8c56134ded29b70d7494aa7',
operationHash: '3ce56e42296689b601347fedc380c89519355ab7',
},
} as unknown as TypedDocumentString<
ServerProductQueryQuery,
Expand All @@ -2000,7 +2012,7 @@ export const ServerProductQueryDocument = {
export const ValidateCartMutationDocument = {
__meta__: {
operationName: 'ValidateCartMutation',
operationHash: '87e1ba227013cb087bcbb35584c1b0b7cdf612ef',
operationHash: '534fae829675533052d75fd4aa509b9cf85b4d40',
},
} as unknown as TypedDocumentString<
ValidateCartMutationMutation,
Expand Down Expand Up @@ -2036,7 +2048,7 @@ export const ClientProductGalleryQueryDocument = {
export const ClientProductQueryDocument = {
__meta__: {
operationName: 'ClientProductQuery',
operationHash: 'a35530c2f55c1c85bd2b4fe4964356ab27e32622',
operationHash: 'cedeb0c3e7ec1678400fe2ae930f5a79382fba1e',
},
} as unknown as TypedDocumentString<
ClientProductQueryQuery,
Expand Down
22 changes: 22 additions & 0 deletions packages/core/cms/faststore/sections.json
Original file line number Diff line number Diff line change
Expand Up @@ -1538,6 +1538,17 @@
"default": "Description"
}
}
},
"quantitySelector": {
"title": "Quantity Selector",
"type": "object",
"properties": {
"useUnitMultiplier": {
"title": "Should use unit multiplier?",
"type": "boolean",
"default": false
}
}
}
}
}
Expand Down Expand Up @@ -1831,6 +1842,17 @@
}
}
}
},
"quantitySelector": {
"title": "Quantity Selector",
"type": "object",
"properties": {
"useUnitMultiplier": {
"title": "Should use unit multiplier?",
"type": "boolean",
"default": false
}
}
}
}
}
Expand Down
Loading

0 comments on commit 8aea48f

Please sign in to comment.