Skip to content

Commit

Permalink
feat: adds SKUMatrix component (#2588)
Browse files Browse the repository at this point in the history
## What's the purpose of this pull request?

Implements the SKU Matrix feature and the respective controls on Product
Details section of Headless CMS in order to facilitate the selection of
product variations simultaneously (such as color and size) through an
intuitive interface.

## How it works?

Initially, we developed 3 components: `SKUMatrix`, `SKUMatrixTrigger`
and `SKUMatrixSidebar`.

- `SKUMatrix`: this component serves as a wrapper (component that will
wrap `SKUMatrixTrigger` and `SKUMatrixSidebar`). It contains the
`SKUMatrixProvider`. This provider will control all the internal state
of SKU Matrix (open and close the slider, control the state of the SKUs:
change the quantity of items to be added to the cart, return the list of
product variations). For the SKU Matrix feature to work correctly, this
wrapper must be present. To prevent `SKUMatrixSidebar` or
`SKUMatrixTrigger` from being used in isolation, a treatment was made in
the `useSKUMatrix` hook.

- `SKUMatrixTrigger`: This component is responsible for triggering the
slider opening/closing trigger. It is an abstraction of the `Button`
component. Within this component, a context is consumed
(`SKUMatrixProvider` contained in `SKUMatrix`), this hook returns the
`setOpen` property, which is responsible for opening or closing
`SKUMatrixSidebar`.

- `SKUMatrixSidebar`: The feature main component. It is responsible for
displaying the table containing all the variations of a product and
allows the selection of multiple SKUs. For the component to work
correctly, the context created and exposed by `SKUMatrix` must be
consumed, as mentioned previously, all the logic and state management of
`SKUMatrixSidebar` is contained in the context. This component is
composed of: `SlideOver`, `Table`, `QuantitySelector`, `Price`,
`Button`, `Skeleton`.

As mentioned in the description of the created components, a context was
created to manage states of SKU Matrix feature. There is a hook called
`useSKUMatrix`, which is responsible for consuming the
`SKUMatrixProvider` and returning all the properties so that the
components in the chain can use it. A validation was implemented so that
the components in the chain can only be used through the `SKUMatrix`
wrapper.

Continuing the implementation of the SKU Matrix resource, within
`@faststore/core` in the ui folder, an abstraction of `SKUMatrixSidebar`
was created. All the logic for capturing and formatting the data is done
in this abstraction.

To request the data, a new query was created. To use it, simply call the
`useAllVariantProducts` hook. To consume this hook, it is necessary to
pass some properties, of which we can highlight `callBack` and
`enabled`.

- `callBack`: is a function that will be executed only when the request
is successful and will return all the data. This method will return the
data fully formatted following the format pre-established in the
`SKUMatrixSidebar` component.

- `enabled`: is a `boolean` (true/false). It will inform the query
whether the `SKUMatrixSidebar` is open or not. The request will only be
made if the slider is open, otherwise it will not be, avoiding
unnecessary requests.

Within this `SKUMatrixSidebar` abstraction, the `SKUMatrix` context must
be consumed. As mentioned previously, the context will control all
states, such as informing the context of the list of product variations.
Since this abstraction will be responsible for making the request and
informing the context of the data, the `callBack` that is passed in
`useAllVariantProducts` will be the method to add the items that will be
displayed in the `SKUMatrixSidebar` table. Therefore, the
`setAllVariantProducts` method present in the `SKUMatrix` context must
be assigned. This way, the formatted data that will be returned will be
informed to the context and later, the `SKUMatrixSidebar` will be able
to consume this data and display it on the screen.

Speaking a little about `useBuyButton`, this hook is responsible for
adding the selected items to the cart. Previously, it did not accept a
list of items, so it was necessary to modify it to meet the new
functionality. Since the hook must consume some information to then
inform the `buyButtonProps` (responsible for preparing the data to be
inserted in the cart), it needs to observe the data of the products to
be added to the cart. This data is returned from the `SKUMatrix`
context, so whenever there is any type of modification in the context
state (for example, a change in the quantity of a SKU within the
`SKUMatrixSidebar` table), it will be reflected in the `useBuyButton`
hook, and the properties will be generated correctly.

Therefore, we performed:

- Construction of 3 new components: `SKUMatrix`, `SKUMatrixTrigger`,
`SKUMatrixSidebar`

- Provider to manage the entire SKU Matrix context

- Hook for consumption of the context: `useSKUMatrix`

- Exclusive query to access only the important data for the construction
of SKU Matrix: `useAllVariantProducts`

- Change in the `useBuyButton` hook

## How to test it?

Check the "Should display SKUMatrix?" checkbox field on Product Details
section of Product Details Page at Headless CMS and publish changes.
Then access a product details page at store. A "Select multiple" button
must be appear below "Add to Cart" button, responsible to trigger the
SKU Matrix Sidebar.

## References

RFC:

[B2B Faststore - SKU
Matrix.pdf](https://github.com/user-attachments/files/17346054/B2B.Faststore.-.SKU.Matrix.pdf)

## Printscreens

Headless CMS

![SKU Matrix - Headless
CMS](https://github.com/user-attachments/assets/0e234c04-8e85-42d5-9acf-768f041b11ba)

SKU Matrix trigger button:

![SKU Matrix - Trigger
Button](https://github.com/user-attachments/assets/1843d695-7ad8-4eed-b51b-3201ece1b56c)

SKU Matrix side bar:

![SKU Matrix - SKU Matrix
Sidebar](https://github.com/user-attachments/assets/486276f3-862a-43c1-938b-fab095f120b6)

---------

Co-authored-by: Hiago Moreira <[email protected]>
Co-authored-by: Fanny Chien <[email protected]>
Co-authored-by: Hiago Moreira <[email protected]>
  • Loading branch information
4 people authored Jan 15, 2025
1 parent be4c6d9 commit 0d7db56
Show file tree
Hide file tree
Showing 23 changed files with 1,431 additions and 443 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.

1 change: 1 addition & 0 deletions packages/api/src/platforms/vtex/resolvers/skuVariations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ export const SkuVariants: Record<string, Resolver<Root>> = {

return filteredFormattedVariations
},
allVariantProducts: (root) => root.isVariantOf.items,
}
5 changes: 5 additions & 0 deletions packages/api/src/typeDefs/skuVariants.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ type SkuVariants {
considered the dominant one.
"""
availableVariations(dominantVariantName: String): FormattedVariants

"""
All possible variant combinations of the current product. It also includes the data for each variant.
"""
allVariantProducts: [StoreProduct!]
}

"""
Expand Down
3 changes: 1 addition & 2 deletions packages/components/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { default as UIProvider, Toast as ToastProps, useUI } from './UIProvider'
export { useFadeEffect } from './useFadeEffect'
export { useTrapFocus } from './useTrapFocus'
export { useSearch } from './useSearch'
export { useSKUMatrix } from './useSKUMatrix'
export { useScrollDirection } from './useScrollDirection'
export { useSlider } from './useSlider'
export type {
Expand All @@ -11,5 +12,3 @@ export type {
SlideDirection,
} from './useSlider'
export { useSlideVisibility } from './useSlideVisibility'


15 changes: 15 additions & 0 deletions packages/components/src/hooks/useSKUMatrix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useContext } from 'react'

import { SKUMatrixContext } from '../organisms/SKUMatrix/provider/SKUMatrixProvider'

export function useSKUMatrix() {
const context = useContext(SKUMatrixContext)

if (!context) {
throw new Error(
'Do not use SKUMatrix components outside the SKUMatrix context.'
)
}

return context
}
11 changes: 11 additions & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,3 +360,14 @@ export type {
SlideOverProps,
SlideOverHeaderProps,
} from './organisms/SlideOver'

export {
default as SKUMatrix,
SKUMatrixTrigger,
SKUMatrixSidebar,
} from './organisms/SKUMatrix'
export type {
SKUMatrixProps,
SKUMatrixTriggerProps,
SKUMatrixSidebarProps
} from './organisms/SKUMatrix'
22 changes: 22 additions & 0 deletions packages/components/src/organisms/SKUMatrix/SKUMatrix.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React, { forwardRef, HTMLAttributes } from 'react'
import SKUMatrixProvider from './provider/SKUMatrixProvider'

export interface SKUMatrixProps extends HTMLAttributes<HTMLDivElement> {
/**
* ID to find this component in testing tools (e.g.: cypress, testing library, and jest).
*/
testId?: string
}

const SKUMatrix = forwardRef<HTMLDivElement, SKUMatrixProps>(function SKUMatrix(
{ testId = 'fs-sku-matrix', children, ...otherProps },
ref
) {
return (
<div ref={ref} data-fs-sku-matrix data-testid={testId} {...otherProps}>
<SKUMatrixProvider>{children}</SKUMatrixProvider>
</div>
)
})

export default SKUMatrix
297 changes: 297 additions & 0 deletions packages/components/src/organisms/SKUMatrix/SKUMatrixSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
import Image from 'next/image'
import React, { useMemo } from 'react'
import { Badge, Button, QuantitySelector, Skeleton } from '../..'
import Price, { PriceFormatter } from '../../atoms/Price'
import Icon from '../../atoms/Icon'
import { useFadeEffect, useSKUMatrix, useUI } from '../../hooks'
import {
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from '../../molecules/Table'
import SlideOver, { SlideOverHeader, SlideOverProps } from '../SlideOver'

interface VariationProductColumn {
name: string
additionalColumns: Array<{ label: string; value: string }>
availability: {
label: string
stockDisplaySettings: 'showStockQuantity' | 'showAvailability'
}
price: number
quantitySelector: number
}

export interface SKUMatrixSidebarProps
extends Omit<SlideOverProps, 'isOpen' | 'setIsOpen' | "fade"> {
/**
* Title for the SKUMatrixSidebar component.
*/
title?: string
/**
* Represents the variations products to building the table.
*/
columns: VariationProductColumn
/**
* Properties related to the 'add to cart' button
*/
buyProps: {
'data-testid': string
'data-sku': string
'data-seller': string
onClick(e: React.MouseEvent<HTMLButtonElement>): void
}
/**
* Formatter function that transforms the raw price value and render the result.
*/
formatter?: PriceFormatter
/**
* Check if some result is still loading before render the result.
*/
loading?: boolean
}

function SKUMatrixSidebar({
direction = 'rightSide',
title,
overlayProps,
size = 'partial',
children,
columns,
buyProps: { onClick: buyButtonOnClick, ...buyProps },
loading,
formatter,
...otherProps
}: SKUMatrixSidebarProps) {
const {
isOpen,
setIsOpen,
setAllVariantProducts,
allVariantProducts,
onChangeQuantityItem,
} = useSKUMatrix()
const { pushToast } = useUI()
const { fade } = useFadeEffect()

const cartDetails = useMemo(() => {
return allVariantProducts.reduce(
(acc, product) => ({
amount: acc.amount + product.selectedCount,
subtotal: acc.subtotal + product.selectedCount * product.price,
}),
{ amount: 0, subtotal: 0 }
)
}, [allVariantProducts])

function resetQuantityItems() {
setAllVariantProducts((prev) =>
prev.map((item) => ({ ...item, quantity: 0 }))
)
}

function onClose() {
resetQuantityItems()
setIsOpen(false)
}

function handleAddToCart(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
buyButtonOnClick(e)
onClose()
}

const totalColumnsSkeletonLength =
Object.keys(columns).filter((v) => v !== 'additionalColumns').length +
(columns.additionalColumns?.length ?? 0)

return (
<SlideOver
data-fs-sku-matrix-sidebar
size={size}
direction={direction}
overlayProps={overlayProps}
isOpen={isOpen}
fade={fade}
{...otherProps}
>
<SlideOverHeader onClose={onClose}>
<h2 data-fs-sku-matrix-sidebar-title>{title}</h2>
</SlideOverHeader>

{children}

<Table variant="bordered">
<TableHead>
<TableRow>
<TableCell align="left" variant="header" scope="col">
{columns.name}
</TableCell>

{columns.additionalColumns?.map(({ label, value }) => (
<TableCell key={value} align="left" variant="header" scope="col">
{label}
</TableCell>
))}

<TableCell align="left" variant="header" scope="col">
{columns.availability.label}
</TableCell>

<TableCell align="right" variant="header" scope="col">
{columns.price}
</TableCell>

<TableCell align="left" variant="header" scope="col">
{columns.quantitySelector}
</TableCell>
</TableRow>
</TableHead>

<TableBody>
{loading ? (
<>
{Array.from({ length: 5 }).map((_, index) => {
return (
<TableRow key={`table-row-${index}`}>
{Array.from({
length: totalColumnsSkeletonLength,
}).map((_, index) => {
return (
<TableCell key={`table-cell-${index}`}>
<span>
<Skeleton
key={index}
size={{ width: '100%', height: '30px' }}
/>
</span>
</TableCell>
)
})}
</TableRow>
)
})}
</>
) : (
<>
{allVariantProducts.map((variantProduct) => (
<TableRow key={`${variantProduct.name}-${variantProduct.id}`}>
<TableCell data-fs-sku-matrix-sidebar-cell-image align="left">
<Image
src={variantProduct.image.url}
alt={variantProduct.image.alternateName}
width={48}
height={48}
/>
{variantProduct.name}
</TableCell>

{columns.additionalColumns?.map(({ value }) => (
<TableCell
key={`${variantProduct.name}-${variantProduct.id}-${value}`}
align="left"
>
{variantProduct.specifications[value.toLowerCase()]}
</TableCell>
))}

<TableCell align="left">
{columns.availability.stockDisplaySettings ===
'showAvailability' && (
<Badge
variant={
variantProduct.availability === 'outOfStock'
? 'warning'
: 'success'
}
>
{variantProduct.availability === 'outOfStock'
? 'Out of stock'
: 'Available'}
</Badge>
)}

{columns.availability.stockDisplaySettings ===
'showStockQuantity' && variantProduct.inventory}
</TableCell>

<TableCell align="right">
<div data-fs-sku-matrix-sidebar-table-price>
<Price
value={variantProduct.price}
variant="spot"
formatter={formatter}
/>
</div>
</TableCell>

<TableCell
align="right"
data-fs-sku-matrix-sidebar-table-cell-quantity-selector
>
<div data-fs-sku-matrix-sidebar-table-action>
<QuantitySelector
min={0}
max={variantProduct.inventory}
disabled={
!variantProduct.inventory ||
variantProduct.availability === 'outOfStock'
}
initial={variantProduct.selectedCount}
onChange={(value) =>
onChangeQuantityItem(variantProduct.id, value)
}
onValidateBlur={(
min: number,
maxValue: number,
quantity: number
) => {
pushToast({
title: 'Invalid quantity!',
message: `The quantity you entered is outside the range of ${min} to ${maxValue}. The quantity was set to ${quantity}.`,
status: 'INFO',
icon: (
<Icon
name="CircleWavyWarning"
width={30}
height={30}
/>
),
})
}}
/>
</div>
</TableCell>
</TableRow>
))}
</>
)}
</TableBody>
</Table>

<footer data-fs-sku-matrix-sidebar-footer>
<div>
<p>
{cartDetails.amount} {cartDetails.amount !== 1 ? 'Items' : 'Item'}
</p>
<Price
value={cartDetails.subtotal}
variant="spot"
formatter={formatter}
/>
</div>

<Button
variant="primary"
disabled={!cartDetails.amount}
onClick={handleAddToCart}
{...buyProps}
>
Add to Cart
</Button>
</footer>
</SlideOver>
)
}

export default SKUMatrixSidebar
Loading

0 comments on commit 0d7db56

Please sign in to comment.