diff --git a/packages/api/src/__generated__/schema.ts b/packages/api/src/__generated__/schema.ts index ad8de635fc..83f0480b3f 100644 --- a/packages/api/src/__generated__/schema.ts +++ b/packages/api/src/__generated__/schema.ts @@ -611,6 +611,8 @@ export type SkuVariants = { __typename?: 'SkuVariants'; /** SKU property values for the current SKU. */ activeVariations?: Maybe; + /** All possible variant combinations of the current product. It also includes the data for each variant. */ + allVariantProducts?: Maybe>; /** All available options for each SKU variant property, indexed by their name. */ allVariantsByName?: Maybe; /** diff --git a/packages/api/src/platforms/vtex/resolvers/skuVariations.ts b/packages/api/src/platforms/vtex/resolvers/skuVariations.ts index 81d725dac6..e59fc7794c 100644 --- a/packages/api/src/platforms/vtex/resolvers/skuVariations.ts +++ b/packages/api/src/platforms/vtex/resolvers/skuVariations.ts @@ -40,4 +40,5 @@ export const SkuVariants: Record> = { return filteredFormattedVariations }, + allVariantProducts: (root) => root.isVariantOf.items, } diff --git a/packages/api/src/typeDefs/skuVariants.graphql b/packages/api/src/typeDefs/skuVariants.graphql index 866b3492be..a56d69d0e0 100644 --- a/packages/api/src/typeDefs/skuVariants.graphql +++ b/packages/api/src/typeDefs/skuVariants.graphql @@ -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!] } """ diff --git a/packages/components/src/hooks/index.ts b/packages/components/src/hooks/index.ts index ae00f0330c..302d67bd2f 100644 --- a/packages/components/src/hooks/index.ts +++ b/packages/components/src/hooks/index.ts @@ -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 { @@ -11,5 +12,3 @@ export type { SlideDirection, } from './useSlider' export { useSlideVisibility } from './useSlideVisibility' - - diff --git a/packages/components/src/hooks/useSKUMatrix.ts b/packages/components/src/hooks/useSKUMatrix.ts new file mode 100644 index 0000000000..3db5e45517 --- /dev/null +++ b/packages/components/src/hooks/useSKUMatrix.ts @@ -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 +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 3764572943..252250a912 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -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' diff --git a/packages/components/src/organisms/SKUMatrix/SKUMatrix.tsx b/packages/components/src/organisms/SKUMatrix/SKUMatrix.tsx new file mode 100644 index 0000000000..7b1e404683 --- /dev/null +++ b/packages/components/src/organisms/SKUMatrix/SKUMatrix.tsx @@ -0,0 +1,22 @@ +import React, { forwardRef, HTMLAttributes } from 'react' +import SKUMatrixProvider from './provider/SKUMatrixProvider' + +export interface SKUMatrixProps extends HTMLAttributes { + /** + * ID to find this component in testing tools (e.g.: cypress, testing library, and jest). + */ + testId?: string +} + +const SKUMatrix = forwardRef(function SKUMatrix( + { testId = 'fs-sku-matrix', children, ...otherProps }, + ref +) { + return ( +
+ {children} +
+ ) +}) + +export default SKUMatrix diff --git a/packages/components/src/organisms/SKUMatrix/SKUMatrixSidebar.tsx b/packages/components/src/organisms/SKUMatrix/SKUMatrixSidebar.tsx new file mode 100644 index 0000000000..fee3a1acc9 --- /dev/null +++ b/packages/components/src/organisms/SKUMatrix/SKUMatrixSidebar.tsx @@ -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 { + /** + * 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): 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) { + buyButtonOnClick(e) + onClose() + } + + const totalColumnsSkeletonLength = + Object.keys(columns).filter((v) => v !== 'additionalColumns').length + + (columns.additionalColumns?.length ?? 0) + + return ( + + +

{title}

+
+ + {children} + + + + + + {columns.name} + + + {columns.additionalColumns?.map(({ label, value }) => ( + + {label} + + ))} + + + {columns.availability.label} + + + + {columns.price} + + + + {columns.quantitySelector} + + + + + + {loading ? ( + <> + {Array.from({ length: 5 }).map((_, index) => { + return ( + + {Array.from({ + length: totalColumnsSkeletonLength, + }).map((_, index) => { + return ( + + + + + + ) + })} + + ) + })} + + ) : ( + <> + {allVariantProducts.map((variantProduct) => ( + + + + {variantProduct.name} + + + {columns.additionalColumns?.map(({ value }) => ( + + {variantProduct.specifications[value.toLowerCase()]} + + ))} + + + {columns.availability.stockDisplaySettings === + 'showAvailability' && ( + + {variantProduct.availability === 'outOfStock' + ? 'Out of stock' + : 'Available'} + + )} + + {columns.availability.stockDisplaySettings === + 'showStockQuantity' && variantProduct.inventory} + + + +
+ +
+
+ + +
+ + 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: ( + + ), + }) + }} + /> +
+
+
+ ))} + + )} +
+
+ +
+
+

+ {cartDetails.amount} {cartDetails.amount !== 1 ? 'Items' : 'Item'} +

+ +
+ + +
+
+ ) +} + +export default SKUMatrixSidebar diff --git a/packages/components/src/organisms/SKUMatrix/SKUMatrixTrigger.tsx b/packages/components/src/organisms/SKUMatrix/SKUMatrixTrigger.tsx new file mode 100644 index 0000000000..4332265738 --- /dev/null +++ b/packages/components/src/organisms/SKUMatrix/SKUMatrixTrigger.tsx @@ -0,0 +1,31 @@ +import React, { forwardRef } from 'react' +import Button from '../../atoms/Button' +import type { ButtonProps } from '../../atoms/Button' +import { useSKUMatrix } from '../../hooks' + +export type SKUMatrixTriggerProps = ButtonProps + +const SKUMatrixTrigger = forwardRef( + function SKUMatrixTrigger( + { children, variant = 'secondary', onClick, ...otherProps }, + ref + ) { + const { setIsOpen } = useSKUMatrix() + + return ( + + ) + } +) + +export default SKUMatrixTrigger diff --git a/packages/components/src/organisms/SKUMatrix/index.ts b/packages/components/src/organisms/SKUMatrix/index.ts new file mode 100644 index 0000000000..3be2f31a81 --- /dev/null +++ b/packages/components/src/organisms/SKUMatrix/index.ts @@ -0,0 +1,8 @@ +export { default } from './SKUMatrix' +export type { SKUMatrixProps } from './SKUMatrix' + +export { default as SKUMatrixTrigger } from './SKUMatrixTrigger' +export type { SKUMatrixTriggerProps } from './SKUMatrixTrigger' + +export { default as SKUMatrixSidebar } from './SKUMatrixSidebar' +export type { SKUMatrixSidebarProps } from './SKUMatrixSidebar' diff --git a/packages/components/src/organisms/SKUMatrix/provider/SKUMatrixProvider.tsx b/packages/components/src/organisms/SKUMatrix/provider/SKUMatrixProvider.tsx new file mode 100644 index 0000000000..263ca41a55 --- /dev/null +++ b/packages/components/src/organisms/SKUMatrix/provider/SKUMatrixProvider.tsx @@ -0,0 +1,104 @@ +import React, { createContext, useCallback, useState, SetStateAction } from 'react' +import type { ReactNode } from 'react' + +interface IAllVariantProducts { + id: string + name: string + image: { + url: string + alternateName: string + } + inventory: number + availability: string + price: number + listPrice: number + priceWithTaxes: number + listPriceWithTaxes: number + specifications: Record + selectedCount: number + offers: { + highPrice: number + lowPrice: number + lowPriceWithTaxes: number + offerCount: number + priceCurrency: string + offers: Array<{ + listPrice: number + listPriceWithTaxes: number + sellingPrice: number + priceCurrency: string + price: number + priceWithTaxes: number + priceValidUntil: string + itemCondition: string + availability: string + quantity: number + }> + } +} + +export interface SKUMatrixProviderContextValue { + /* + A boolean value that indicates if the modal is open. + */ + isOpen: boolean + /* + Array of all variant products. + */ + allVariantProducts: IAllVariantProducts[] + /* + Function to set the array of all variant products. + */ + setAllVariantProducts( + items: SetStateAction + ): void + /* + */ + onChangeQuantityItem(id: string, value: number): IAllVariantProducts[] + /* + function to set the modal is open + */ + setIsOpen(value: boolean): void +} + +export const SKUMatrixContext = + createContext(null) + +function SKUMatrixProvider({ children }: { children: ReactNode }) { + const [isOpen, setIsOpen] = useState(false) + const [allVariantProducts, setAllVariantProducts] = useState< + IAllVariantProducts[] + >([]) + + const onChangeQuantityItem = useCallback( + (id: string, value: number) => { + const data = [...allVariantProducts] + const matchedSKU = data.find((item) => item.id === id) + + if(matchedSKU) { + matchedSKU.selectedCount = value + } + + setAllVariantProducts(data) + + return data + }, + [allVariantProducts] + ) + + return ( + + {children} + + ) +} + +export default SKUMatrixProvider diff --git a/packages/core/@generated/gql.ts b/packages/core/@generated/gql.ts index 0176621770..8fcec3cc26 100644 --- a/packages/core/@generated/gql.ts +++ b/packages/core/@generated/gql.ts @@ -16,8 +16,10 @@ 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 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 lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\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\t\t\tskuVariants {\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 lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\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 ProductSKUMatrixSidebarFragment_product on StoreProduct {\n id: productID\n isVariantOf {\n name\n productGroupID\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n\t\t\t\t\tsku\n name\n image {\n url\n alternateName\n }\n offers {\n highPrice\n lowPrice\n lowPriceWithTaxes\n offerCount\n priceCurrency\n offers {\n listPrice\n listPriceWithTaxes\n sellingPrice\n priceCurrency\n price\n priceWithTaxes\n priceValidUntil\n itemCondition\n availability\n quantity\n }\n }\n additionalProperty {\n propertyID\n value\n name\n valueReference\n }\n }\n }\n }\n }\n': + types.ProductSkuMatrixSidebarFragment_ProductFragmentDoc, '\n fragment ClientManyProducts on Query {\n search(\n first: $first\n after: $after\n sort: $sort\n term: $term\n selectedFacets: $selectedFacets\n sponsoredCount: $sponsoredCount\n\n ) {\n products {\n pageInfo {\n totalCount\n }\n }\n }\n }\n': types.ClientManyProductsFragmentDoc, '\n fragment ClientProduct on Query {\n product(locator: $locator) {\n id: productID\n }\n }\n': @@ -42,6 +44,8 @@ const documents = { types.ValidateCartMutationDocument, '\n mutation SubscribeToNewsletter($data: IPersonNewsletter!) {\n subscribeToNewsletter(data: $data) {\n id\n }\n }\n': types.SubscribeToNewsletterDocument, + '\n query ClientAllVariantProductsQuery($locator: [IStoreSelectedFacet!]!) {\n product(locator: $locator) {\n ...ProductSKUMatrixSidebarFragment_product\n }\n }\n': + types.ClientAllVariantProductsQueryDocument, '\n query ClientManyProductsQuery(\n $first: Int!\n $after: String\n $sort: StoreSort!\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]!\n $sponsoredCount: Int\n ) {\n ...ClientManyProducts\n search(\n first: $first\n after: $after\n sort: $sort\n term: $term\n selectedFacets: $selectedFacets\n sponsoredCount: $sponsoredCount\n ) {\n products {\n pageInfo {\n totalCount\n }\n edges {\n node {\n ...ProductSummary_product\n }\n }\n }\n }\n }\n': types.ClientManyProductsQueryDocument, '\n query ClientProductGalleryQuery(\n $first: Int!\n $after: String!\n $sort: StoreSort!\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]!\n ) {\n ...ClientProductGallery\n redirect(term: $term, selectedFacets: $selectedFacets) {\n url\n }\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 facets {\n ...Filter_facets\n }\n metadata {\n ...SearchEvent_metadata\n }\n }\n }\n\n fragment SearchEvent_metadata on SearchMetadata {\n isTermMisspelled\n logicalOperator\n fuzzy\n }\n': @@ -74,8 +78,14 @@ 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 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 lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\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\t\t\tskuVariants {\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 lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\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. + */ +export function gql( + source: '\n fragment ProductSKUMatrixSidebarFragment_product on StoreProduct {\n id: productID\n isVariantOf {\n name\n productGroupID\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n\t\t\t\t\tsku\n name\n image {\n url\n alternateName\n }\n offers {\n highPrice\n lowPrice\n lowPriceWithTaxes\n offerCount\n priceCurrency\n offers {\n listPrice\n listPriceWithTaxes\n sellingPrice\n priceCurrency\n price\n priceWithTaxes\n priceValidUntil\n itemCondition\n availability\n quantity\n }\n }\n additionalProperty {\n propertyID\n value\n name\n valueReference\n }\n }\n }\n }\n }\n' +): typeof import('./graphql').ProductSkuMatrixSidebarFragment_ProductFragmentDoc /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -148,6 +158,12 @@ export function gql( export function gql( source: '\n mutation SubscribeToNewsletter($data: IPersonNewsletter!) {\n subscribeToNewsletter(data: $data) {\n id\n }\n }\n' ): typeof import('./graphql').SubscribeToNewsletterDocument +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql( + source: '\n query ClientAllVariantProductsQuery($locator: [IStoreSelectedFacet!]!) {\n product(locator: $locator) {\n ...ProductSKUMatrixSidebarFragment_product\n }\n }\n' +): typeof import('./graphql').ClientAllVariantProductsQueryDocument /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/core/@generated/graphql.ts b/packages/core/@generated/graphql.ts index 730dc2d938..0f71d6e880 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -599,6 +599,8 @@ export type ShippingSla = { export type SkuVariants = { /** SKU property values for the current SKU. */ activeVariations: Maybe + /** All possible variant combinations of the current product. It also includes the data for each variant. */ + allVariantProducts: Maybe> /** All available options for each SKU variant property, indexed by their name. */ allVariantsByName: Maybe /** @@ -1221,6 +1223,49 @@ export type ProductDetailsFragment_ProductFragment = { }> } +export type ProductSkuMatrixSidebarFragment_ProductFragment = { + id: string + isVariantOf: { + name: string + productGroupID: string + skuVariants: { + activeVariations: any | null + slugsMap: any | null + availableVariations: any | null + allVariantProducts: Array<{ + sku: string + name: string + image: Array<{ url: string; alternateName: string }> + offers: { + highPrice: number + lowPrice: number + lowPriceWithTaxes: number + offerCount: number + priceCurrency: string + offers: Array<{ + listPrice: number + listPriceWithTaxes: number + sellingPrice: number + priceCurrency: string + price: number + priceWithTaxes: number + priceValidUntil: string + itemCondition: string + availability: string + quantity: number + }> + } + additionalProperty: Array<{ + propertyID: string + value: any + name: string + valueReference: any + }> + }> | null + } | null + } +} + export type ClientManyProductsFragment = { search: { products: { pageInfo: { totalCount: number } } } } @@ -1428,6 +1473,55 @@ export type SubscribeToNewsletterMutation = { subscribeToNewsletter: { id: string } | null } +export type ClientAllVariantProductsQueryQueryVariables = Exact<{ + locator: Array | IStoreSelectedFacet +}> + +export type ClientAllVariantProductsQueryQuery = { + product: { + id: string + isVariantOf: { + name: string + productGroupID: string + skuVariants: { + activeVariations: any | null + slugsMap: any | null + availableVariations: any | null + allVariantProducts: Array<{ + sku: string + name: string + image: Array<{ url: string; alternateName: string }> + offers: { + highPrice: number + lowPrice: number + lowPriceWithTaxes: number + offerCount: number + priceCurrency: string + offers: Array<{ + listPrice: number + listPriceWithTaxes: number + sellingPrice: number + priceCurrency: string + price: number + priceWithTaxes: number + priceValidUntil: string + itemCondition: string + availability: string + quantity: number + }> + } + additionalProperty: Array<{ + propertyID: string + value: any + name: string + valueReference: any + }> + }> | null + } | null + } + } +} + export type ClientManyProductsQueryQueryVariables = Exact<{ first: Scalars['Int']['input'] after: InputMaybe @@ -1891,6 +1985,60 @@ export const ProductDetailsFragment_ProductFragmentDoc = ProductDetailsFragment_ProductFragment, unknown > +export const ProductSkuMatrixSidebarFragment_ProductFragmentDoc = + new TypedDocumentString( + ` + fragment ProductSKUMatrixSidebarFragment_product on StoreProduct { + id: productID + isVariantOf { + name + productGroupID + skuVariants { + activeVariations + slugsMap + availableVariations + allVariantProducts { + sku + name + image { + url + alternateName + } + offers { + highPrice + lowPrice + lowPriceWithTaxes + offerCount + priceCurrency + offers { + listPrice + listPriceWithTaxes + sellingPrice + priceCurrency + price + priceWithTaxes + priceValidUntil + itemCondition + availability + quantity + } + } + additionalProperty { + propertyID + value + name + valueReference + } + } + } + } +} + `, + { fragmentName: 'ProductSKUMatrixSidebarFragment_product' } + ) as unknown as TypedDocumentString< + ProductSkuMatrixSidebarFragment_ProductFragment, + unknown + > export const ClientManyProductsFragmentDoc = new TypedDocumentString( ` fragment ClientManyProducts on Query { @@ -2102,6 +2250,15 @@ export const SubscribeToNewsletterDocument = { SubscribeToNewsletterMutation, SubscribeToNewsletterMutationVariables > +export const ClientAllVariantProductsQueryDocument = { + __meta__: { + operationName: 'ClientAllVariantProductsQuery', + operationHash: '4039e05f01a2fe449e20e8b82170d0ba94b1fbe9', + }, +} as unknown as TypedDocumentString< + ClientAllVariantProductsQueryQuery, + ClientAllVariantProductsQueryQueryVariables +> export const ClientManyProductsQueryDocument = { __meta__: { operationName: 'ClientManyProductsQuery', diff --git a/packages/core/cms/faststore/sections.json b/packages/core/cms/faststore/sections.json index c33bad1c1c..8c4f1cf1dd 100644 --- a/packages/core/cms/faststore/sections.json +++ b/packages/core/cms/faststore/sections.json @@ -29,12 +29,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "Magnifying Glass" - ], - "enum": [ - "MagnifyingGlass" - ], + "enumNames": ["Magnifying Glass"], + "enum": ["MagnifyingGlass"], "default": "MagnifyingGlass" }, "alt": { @@ -67,12 +63,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "Clock Clockwise" - ], - "enum": [ - "ClockClockwise" - ], + "enumNames": ["Clock Clockwise"], + "enum": ["ClockClockwise"], "default": "ClockClockwise" }, "alt": { @@ -116,12 +108,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "Magnifying Glass" - ], - "enum": [ - "MagnifyingGlass" - ] + "enumNames": ["Magnifying Glass"], + "enum": ["MagnifyingGlass"] }, "alt": { "type": "string", @@ -163,16 +151,12 @@ "title": "Navbar", "type": "object", "description": "Navbar configuration", - "required": [ - "logo" - ], + "required": ["logo"], "properties": { "logo": { "title": "Logo", "type": "object", - "required": [ - "src" - ], + "required": ["src"], "properties": { "src": { "title": "Image", @@ -188,10 +172,7 @@ "link": { "title": "Logo Link", "type": "object", - "required": [ - "url", - "title" - ], + "required": ["url", "title"], "properties": { "url": { "title": "Link URL", @@ -209,9 +190,7 @@ "title": "Search Input", "description": "Search Input configurations", "type": "object", - "required": [ - "sort" - ], + "required": ["sort"], "properties": { "placeholder": { "title": "Placeholder for Search Bar", @@ -256,12 +235,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "User" - ], - "enum": [ - "User" - ], + "enumNames": ["User"], + "enum": ["User"], "default": "User" }, "alt": { @@ -290,12 +265,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "Shopping Cart" - ], - "enum": [ - "ShoppingCart" - ], + "enumNames": ["Shopping Cart"], + "enum": ["ShoppingCart"], "default": "ShoppingCart" }, "alt": { @@ -325,12 +296,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "Map Pin" - ], - "enum": [ - "MapPin" - ], + "enumNames": ["Map Pin"], + "enum": ["MapPin"], "default": "MapPin" }, "alt": { @@ -354,10 +321,7 @@ "items": { "title": "Link", "type": "object", - "required": [ - "text", - "url" - ], + "required": ["text", "url"], "properties": { "text": { "title": "Link Text", @@ -381,12 +345,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "List" - ], - "enum": [ - "List" - ], + "enumNames": ["List"], + "enum": ["List"], "default": "List" }, "alt": { @@ -421,11 +381,7 @@ "title": "Alert", "description": "Add an alert", "type": "object", - "required": [ - "icon", - "content", - "dismissible" - ], + "required": ["icon", "content", "dismissible"], "properties": { "icon": { "type": "string", @@ -438,14 +394,7 @@ "Truck", "User" ], - "enum": [ - "Bell", - "BellRinging", - "Checked", - "Info", - "Truck", - "User" - ] + "enum": ["Bell", "BellRinging", "Checked", "Info", "Truck", "User"] }, "content": { "type": "string", @@ -489,11 +438,7 @@ "items": { "title": "Incentive", "type": "object", - "required": [ - "title", - "firstLineText", - "icon" - ], + "required": ["title", "firstLineText", "icon"], "properties": { "title": { "type": "string", @@ -552,10 +497,7 @@ "items": { "title": "Link", "type": "object", - "required": [ - "text", - "url" - ], + "required": ["text", "url"], "properties": { "text": { "title": "Link Text", @@ -589,11 +531,7 @@ "items": { "title": "Link", "type": "object", - "required": [ - "alt", - "url", - "icon" - ], + "required": ["alt", "url", "icon"], "properties": { "icon": { "title": "Icon", @@ -648,10 +586,7 @@ "link": { "title": "Logo Link", "type": "object", - "required": [ - "url", - "title" - ], + "required": ["url", "title"], "properties": { "url": { "title": "Link URL", @@ -672,9 +607,7 @@ "acceptedPaymentMethods": { "title": "Payment Methods Sections", "type": "object", - "required": [ - "showPaymentMethods" - ], + "required": ["showPaymentMethods"], "properties": { "showPaymentMethods": { "title": "Display Payment Methods", @@ -692,10 +625,7 @@ "items": { "title": "Payment Method", "type": "object", - "required": [ - "icon", - "alt" - ], + "required": ["icon", "alt"], "properties": { "icon": { "type": "object", @@ -745,11 +675,7 @@ "title": "Banner Text", "description": "Add a quick promotion with a text/action pair", "type": "object", - "required": [ - "title", - "caption", - "link" - ], + "required": ["title", "caption", "link"], "properties": { "title": { "title": "Title", @@ -762,10 +688,7 @@ "link": { "title": "Call to Action", "type": "object", - "required": [ - "text", - "url" - ], + "required": ["text", "url"], "properties": { "text": { "title": "Text", @@ -785,28 +708,14 @@ "colorVariant": { "type": "string", "title": "Color variant", - "enumNames": [ - "Main", - "Light", - "Accent" - ], - "enum": [ - "main", - "light", - "accent" - ] + "enumNames": ["Main", "Light", "Accent"], + "enum": ["main", "light", "accent"] }, "variant": { "type": "string", "title": "Variant", - "enumNames": [ - "Primary", - "Secondary" - ], - "enum": [ - "primary", - "secondary" - ] + "enumNames": ["Primary", "Secondary"], + "enum": ["primary", "secondary"] } } } @@ -818,9 +727,7 @@ "title": "Hero", "description": "Add a quick promotion with an image/action pair", "type": "object", - "required": [ - "title" - ], + "required": ["title"], "properties": { "title": { "title": "Title", @@ -869,28 +776,14 @@ "colorVariant": { "type": "string", "title": "Color variant", - "enumNames": [ - "Main", - "Light", - "Accent" - ], - "enum": [ - "main", - "light", - "accent" - ] + "enumNames": ["Main", "Light", "Accent"], + "enum": ["main", "light", "accent"] }, "variant": { "type": "string", "title": "Variant", - "enumNames": [ - "Primary", - "Secondary" - ], - "enum": [ - "primary", - "secondary" - ] + "enumNames": ["Primary", "Secondary"], + "enum": ["primary", "secondary"] } } } @@ -911,11 +804,7 @@ "items": { "title": "Incentive", "type": "object", - "required": [ - "title", - "firstLineText", - "icon" - ], + "required": ["title", "firstLineText", "icon"], "properties": { "title": { "type": "string", @@ -964,12 +853,7 @@ "title": "Product Shelf", "description": "Add custom shelves to your store", "type": "object", - "required": [ - "title", - "numberOfItems", - "after", - "sort" - ], + "required": ["title", "numberOfItems", "after", "sort"], "properties": { "title": { "type": "string", @@ -1028,10 +912,7 @@ "items": { "title": "Facet", "type": "object", - "required": [ - "key", - "value" - ], + "required": ["key", "value"], "properties": { "key": { "title": "Key", @@ -1085,19 +966,12 @@ }, { "name": "CrossSellingShelf", - "requiredScopes": [ - "pdp", - "custom" - ], + "requiredScopes": ["pdp", "custom"], "schema": { "title": "Cross Selling Shelf", "description": "Add cross selling product data to your users", "type": "object", - "required": [ - "title", - "numberOfItems", - "kind" - ], + "required": ["title", "numberOfItems", "kind"], "properties": { "title": { "title": "Title", @@ -1119,14 +993,8 @@ "title": "Kind", "description": "Change cross selling types", "default": "buy", - "enum": [ - "buy", - "view" - ], - "enumNames": [ - "Who bought also bought", - "Who saw also saw" - ] + "enum": ["buy", "view"], + "enumNames": ["Who bought also bought", "Who saw also saw"] }, "taxesConfiguration": { "title": "Taxes Configuration", @@ -1154,12 +1022,7 @@ "title": "Product Tiles", "description": "Add custom highlights to your store", "type": "object", - "required": [ - "title", - "first", - "after", - "sort" - ], + "required": ["title", "first", "after", "sort"], "properties": { "title": { "title": "Title", @@ -1212,10 +1075,7 @@ "items": { "title": "Facet", "type": "object", - "required": [ - "key", - "value" - ], + "required": ["key", "value"], "properties": { "key": { "title": "Key", @@ -1258,9 +1118,7 @@ "title": "Newsletter", "description": "Allow users to subscribe to your updates", "type": "object", - "required": [ - "title" - ], + "required": ["title"], "properties": { "icon": { "title": "Icon", @@ -1269,12 +1127,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "Envelope" - ], - "enum": [ - "Envelope" - ], + "enumNames": ["Envelope"], + "enum": ["Envelope"], "default": "Envelope" }, "alt": { @@ -1334,16 +1188,8 @@ "colorVariant": { "title": "Color variant", "type": "string", - "enumNames": [ - "Main", - "Light", - "Accent" - ], - "enum": [ - "main", - "light", - "accent" - ], + "enumNames": ["Main", "Light", "Accent"], + "enum": ["main", "light", "accent"], "default": "main" }, "toastSubscribe": { @@ -1365,12 +1211,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "CircleWavyCheck" - ], - "enum": [ - "CircleWavyCheck" - ], + "enumNames": ["CircleWavyCheck"], + "enum": ["CircleWavyCheck"], "default": "CircleWavyCheck" } } @@ -1394,12 +1236,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "CircleWavyWarning" - ], - "enum": [ - "CircleWavyWarning" - ], + "enumNames": ["CircleWavyWarning"], + "enum": ["CircleWavyWarning"], "default": "CircleWavyWarning" } } @@ -1422,18 +1260,12 @@ "title": "Banner Newsletter", "description": "Add newsletter with a banner", "type": "object", - "required": [ - "banner", - "newsletter" - ], + "required": ["banner", "newsletter"], "properties": { "banner": { "title": "Banner", "type": "object", - "required": [ - "title", - "link" - ], + "required": ["title", "link"], "properties": { "title": { "title": "Title", @@ -1448,10 +1280,7 @@ "link": { "title": "Call to Action", "type": "object", - "required": [ - "text", - "url" - ], + "required": ["text", "url"], "properties": { "text": { "title": "Text", @@ -1468,29 +1297,15 @@ "colorVariant": { "title": "Color variant", "type": "string", - "enumNames": [ - "Main", - "Light", - "Accent" - ], - "enum": [ - "main", - "light", - "accent" - ], + "enumNames": ["Main", "Light", "Accent"], + "enum": ["main", "light", "accent"], "default": "light" }, "variant": { "title": "Variant", "type": "string", - "enumNames": [ - "Primary", - "Secondary" - ], - "enum": [ - "primary", - "secondary" - ], + "enumNames": ["Primary", "Secondary"], + "enum": ["primary", "secondary"], "default": "secondary" } } @@ -1498,10 +1313,7 @@ "newsletter": { "title": "Newsletter", "type": "object", - "required": [ - "title", - "description" - ], + "required": ["title", "description"], "properties": { "icon": { "title": "Icon", @@ -1510,12 +1322,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "Envelope" - ], - "enum": [ - "Envelope" - ], + "enumNames": ["Envelope"], + "enum": ["Envelope"], "default": "Envelope" }, "alt": { @@ -1570,16 +1378,8 @@ "colorVariant": { "title": "Color variant", "type": "string", - "enumNames": [ - "Main", - "Light", - "Accent" - ], - "enum": [ - "main", - "light", - "accent" - ], + "enumNames": ["Main", "Light", "Accent"], + "enum": ["main", "light", "accent"], "default": "main" }, "toastSubscribe": { @@ -1601,12 +1401,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "CircleWavyCheck" - ], - "enum": [ - "CircleWavyCheck" - ], + "enumNames": ["CircleWavyCheck"], + "enum": ["CircleWavyCheck"], "default": "CircleWavyCheck" } } @@ -1630,12 +1426,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "CircleWavyWarning" - ], - "enum": [ - "CircleWavyWarning" - ], + "enumNames": ["CircleWavyWarning"], + "enum": ["CircleWavyWarning"], "default": "CircleWavyWarning" } } @@ -1647,28 +1439,18 @@ }, { "name": "Breadcrumb", - "requiredScopes": [ - "pdp", - "plp" - ], + "requiredScopes": ["pdp", "plp"], "schema": { "title": "Breadcrumb", "description": "Configure the breadcrumb icon and depth", "type": "object", - "required": [ - "icon", - "alt" - ], + "required": ["icon", "alt"], "properties": { "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "House" - ], - "enum": [ - "House" - ] + "enumNames": ["House"], + "enum": ["House"] }, "alt": { "title": "Alternative Label", @@ -1679,9 +1461,7 @@ }, { "name": "ProductDetails", - "requiredScopes": [ - "pdp" - ], + "requiredScopes": ["pdp"], "schema": { "title": "Product Details", "type": "object", @@ -1703,14 +1483,8 @@ "size": { "title": "Size", "type": "string", - "enumNames": [ - "Big", - "Small" - ], - "enum": [ - "big", - "small" - ] + "enumNames": ["Big", "Small"], + "enum": ["big", "small"] } } }, @@ -1737,12 +1511,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "Shopping Cart" - ], - "enum": [ - "ShoppingCart" - ] + "enumNames": ["Shopping Cart"], + "enum": ["ShoppingCart"] }, "alt": { "type": "string", @@ -1807,16 +1577,8 @@ "initiallyExpanded": { "type": "string", "title": "Initially Expanded?", - "enumNames": [ - "First", - "All", - "None" - ], - "enum": [ - "first", - "all", - "none" - ] + "enumNames": ["First", "All", "None"], + "enum": ["first", "all", "none"] }, "displayDescription": { "title": "Should display description?", @@ -1856,23 +1618,102 @@ "default": "Tax included" } } + }, + "skuMatrix": { + "title": "SKUMatrix Configuration", + "type": "object", + "properties": { + "shouldDisplaySKUMatrix": { + "title": "Should display SKUMatrix?", + "type": "boolean", + "default": false + }, + "triggerButtonLabel": { + "title": "SKU Matrix Trigger label to be displayed", + "type": "string", + "default": "Select multiple" + }, + "separatorButtonsText": { + "title": "Separator text", + "description": "Text that separates the add to cart button from the SKU Matrix Trigger button.", + "type": "string", + "default": "Or" + }, + "columns": { + "title": "Columns", + "type": "object", + "properties": { + "name": { + "title": "SKU name column label", + "type": "string", + "default": "Name" + }, + "additionalColumns": { + "title": "Additional columns", + "type": "array", + "items": { + "title": "Column", + "type": "object", + "required": ["label", "value"], + "properties": { + "label": { + "title": "Label", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + } + } + } + }, + "availability": { + "title": "Availability column label", + "type": "object", + "properties": { + "label": { + "title": "Label", + "type": "string", + "default": "Availability" + }, + "stockDisplaySettings": { + "title": "Stock display settings", + "description": "Control how the stock status of your products is displayed to customers on your online store.", + "type": "string", + "enum": ["showAvailability", "showStockQuantity"], + "enumNames": [ + "Show availability (Available/Out of Stock)", + "Show stock quantity" + ], + "default": "showAvailability" + } + } + }, + "price": { + "title": "Price column label", + "type": "string", + "default": "Price" + }, + "quantitySelector": { + "title": "Quantity selector column label", + "type": "string", + "default": "Quantity" + } + } + } + } } } } }, { "name": "ProductGallery", - "requiredScopes": [ - "plp", - "search" - ], + "requiredScopes": ["plp", "search"], "schema": { "title": "Product Gallery", "type": "object", "description": "Product Gallery configuration", - "required": [ - "filter" - ], + "required": ["filter"], "properties": { "searchTermLabel": { "title": "Search page term label", @@ -1887,10 +1728,7 @@ "previousPageButton": { "title": "Previous page button", "type": "object", - "required": [ - "icon", - "label" - ], + "required": ["icon", "label"], "properties": { "icon": { "title": "Icon", @@ -1899,12 +1737,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "ArrowLeft" - ], - "enum": [ - "ArrowLeft" - ], + "enumNames": ["ArrowLeft"], + "enum": ["ArrowLeft"], "default": "ArrowLeft" }, "alt": { @@ -1924,9 +1758,7 @@ "loadMorePageButton": { "title": "Load more products Button", "type": "object", - "required": [ - "label" - ], + "required": ["label"], "properties": { "label": { "title": "Load more products label", @@ -1938,10 +1770,7 @@ "filter": { "title": "Filter", "type": "object", - "required": [ - "title", - "mobileOnly" - ], + "required": ["title", "mobileOnly"], "properties": { "title": { "title": "Filter title", @@ -1960,10 +1789,7 @@ "filterButton": { "title": "Show filter button", "type": "object", - "required": [ - "label", - "icon" - ], + "required": ["label", "icon"], "properties": { "label": { "title": "Label", @@ -1973,20 +1799,13 @@ "icon": { "title": "Icon", "type": "object", - "required": [ - "icon", - "alt" - ], + "required": ["icon", "alt"], "properties": { "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "FadersHorizontal" - ], - "enum": [ - "FadersHorizontal" - ], + "enumNames": ["FadersHorizontal"], + "enum": ["FadersHorizontal"], "default": "FadersHorizontal" }, "alt": { @@ -2125,10 +1944,7 @@ "icon": { "title": "Icon", "type": "object", - "required": [ - "icon", - "alt" - ], + "required": ["icon", "alt"], "properties": { "icon": { "title": "Icon", @@ -2168,11 +1984,7 @@ "checkoutButton": { "title": "Checkout button", "type": "object", - "required": [ - "label", - "loadingLabel", - "icon" - ], + "required": ["label", "loadingLabel", "icon"], "properties": { "label": { "title": "Label", @@ -2187,20 +1999,13 @@ "icon": { "title": "Icon", "type": "object", - "required": [ - "icon", - "alt" - ], + "required": ["icon", "alt"], "properties": { "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "ArrowRight" - ], - "enum": [ - "ArrowRight" - ], + "enumNames": ["ArrowRight"], + "enum": ["ArrowRight"], "default": "ArrowRight" }, "alt": { @@ -2249,9 +2054,7 @@ "title": "Region Bar", "type": "object", "description": "Region Bar configuration", - "required": [ - "label" - ], + "required": ["label"], "properties": { "icon": { "title": "Location Icon", @@ -2260,12 +2063,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "Map Pin" - ], - "enum": [ - "MapPin" - ], + "enumNames": ["Map Pin"], + "enum": ["MapPin"], "default": "MapPin" }, "alt": { @@ -2292,12 +2091,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "Caret Right" - ], - "enum": [ - "CaretRight" - ], + "enumNames": ["Caret Right"], + "enum": ["CaretRight"], "default": "CaretRight" }, "alt": { @@ -2369,12 +2164,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "Arrow Square Out" - ], - "enum": [ - "ArrowSquareOut" - ], + "enumNames": ["Arrow Square Out"], + "enum": ["ArrowSquareOut"], "default": "ArrowSquareOut" }, "alt": { @@ -2407,12 +2198,8 @@ "icon": { "title": "Icon", "type": "string", - "enumNames": [ - "CircleWavy Warning" - ], - "enum": [ - "CircleWavyWarning" - ] + "enumNames": ["CircleWavy Warning"], + "enum": ["CircleWavyWarning"] }, "alt": { "title": "Alternative Label", @@ -2466,4 +2253,4 @@ } } } -] \ No newline at end of file +] diff --git a/packages/core/src/components/sections/ProductDetails/DefaultComponents.ts b/packages/core/src/components/sections/ProductDetails/DefaultComponents.ts index 07b63e4f3e..7854fab8e2 100644 --- a/packages/core/src/components/sections/ProductDetails/DefaultComponents.ts +++ b/packages/core/src/components/sections/ProductDetails/DefaultComponents.ts @@ -9,13 +9,17 @@ import { QuantitySelector as UIQuantitySelector, ImageGalleryViewer as UIImageGalleryViewer, ImageGallery as UIImageGallery, + SKUMatrix as UISKUMatrix, + SKUMatrixSidebar as UISKUMatrixSidebar, + SKUMatrixTrigger as UISKUMatrixTrigger, } from '@faststore/ui' import LocalImageGallery from 'src/components/ui/ImageGallery' import LocalShippingSimulation from 'src/components/ui/ShippingSimulation/ShippingSimulation' import { Image } from 'src/components/ui/Image' import LocalNotAvailableButton from 'src/components/product/NotAvailableButton' -import LocalProductDescription from 'src/components/ui/ProductDescription' +import LocalSKUMatrixSidebar from 'src/components/ui/SKUMatrix/SKUMatrixSidebar' +import LocalProductDescription from 'src/components/ui/ProductDescription/ProductDescription' import { ProductDetailsSettings as LocalProductDetailsSettings } from 'src/components/ui/ProductDetails' export const ProductDetailsDefaultComponents = { @@ -29,9 +33,13 @@ export const ProductDetailsDefaultComponents = { ShippingSimulation: UIShippingSimulation, ImageGallery: UIImageGallery, ImageGalleryViewer: UIImageGalleryViewer, + SKUMatrix: UISKUMatrix, + SKUMatrixTrigger: UISKUMatrixTrigger, + SKUMatrixSidebar: UISKUMatrixSidebar, __experimentalImageGalleryImage: Image, __experimentalImageGallery: LocalImageGallery, __experimentalShippingSimulation: LocalShippingSimulation, + __experimentalSKUMatrixSidebar: LocalSKUMatrixSidebar, __experimentalNotAvailableButton: LocalNotAvailableButton, __experimentalProductDescription: LocalProductDescription, __experimentalProductDetailsSettings: LocalProductDetailsSettings, diff --git a/packages/core/src/components/sections/ProductDetails/ProductDetails.tsx b/packages/core/src/components/sections/ProductDetails/ProductDetails.tsx index cd77ef01ff..29df9bf941 100644 --- a/packages/core/src/components/sections/ProductDetails/ProductDetails.tsx +++ b/packages/core/src/components/sections/ProductDetails/ProductDetails.tsx @@ -55,6 +55,21 @@ export interface ProductDetailsProps { usePriceWithTaxes?: boolean taxesLabel?: string } + skuMatrix?: { + shouldDisplaySKUMatrix?: boolean + triggerButtonLabel: string + separatorButtonsText: string + columns: { + name: string + additionalColumns: Array<{ label: string; value: string }> + availability: { + label: string + stockDisplaySettings: 'showAvailability' | 'showStockQuantity' + } + price: number + quantitySelector: number + } + } } function ProductDetails({ @@ -74,6 +89,7 @@ function ProductDetails({ initiallyExpanded: productDescriptionInitiallyExpanded, displayDescription: shouldDisplayProductDescription, }, + skuMatrix, notAvailableButton: { title: notAvailableButtonTitle }, quantitySelector, taxesConfiguration, @@ -81,17 +97,19 @@ function ProductDetails({ const { DiscountBadge, ProductTitle, + SKUMatrix, + SKUMatrixTrigger, __experimentalImageGallery: ImageGallery, __experimentalShippingSimulation: ShippingSimulation, __experimentalNotAvailableButton: NotAvailableButton, __experimentalProductDescription: ProductDescription, __experimentalProductDetailsSettings: ProductDetailsSettings, + __experimentalSKUMatrixSidebar: SKUMatrixSidebar, } = useOverrideComponents<'ProductDetails'>() const { currency } = useSession() const context = usePDP() const { product, isValidating } = context?.data const [quantity, setQuantity] = useState(1) - if (!product) { throw new Error('NotFound') } @@ -104,7 +122,11 @@ function ProductDetails({ brand, isVariantOf, description, - isVariantOf: { name, productGroupID: productId }, + isVariantOf: { + name, + productGroupID: productId, + skuVariants: { slugsMap }, + }, image: productImages, offers: { offers: [{ availability, price, listPrice, listPriceWithTaxes, seller }], @@ -213,6 +235,27 @@ function ProductDetails({ isValidating={isValidating} taxesConfiguration={taxesConfiguration} /> + + {skuMatrix?.shouldDisplaySKUMatrix && + Object.keys(slugsMap).length > 1 && ( + <> +
+ {skuMatrix.separatorButtonsText} +
+ + + + {skuMatrix.triggerButtonLabel} + + + + + + )} {!outOfStock && ( @@ -277,7 +320,7 @@ export const fragment = gql(` isVariantOf { name productGroupID - skuVariants { + skuVariants { activeVariations slugsMap availableVariations diff --git a/packages/core/src/components/sections/ProductDetails/section.module.scss b/packages/core/src/components/sections/ProductDetails/section.module.scss index dbf85d3937..a74aa3886a 100644 --- a/packages/core/src/components/sections/ProductDetails/section.module.scss +++ b/packages/core/src/components/sections/ProductDetails/section.module.scss @@ -1,9 +1,13 @@ @layer components { .section { // Taxes label - --fs-product-details-taxes-label-color : var(--fs-color-info-text); - --fs-product-details-taxes-text-size : var(--fs-text-size-tiny); - --fs-product-details-taxes-text-weight : var(--fs-text-weight-regular); + --fs-product-details-taxes-label-color : var(--fs-color-info-text); + --fs-product-details-taxes-text-size : var(--fs-text-size-tiny); + --fs-product-details-taxes-text-weight : var(--fs-text-weight-regular); + + // Separator colors + --fs-product-details-separator-color : var(--fs-color-neutral-2); + --fs-product-details-separator-color-text : var(--fs-color-text-light); margin-top: 0; @@ -29,11 +33,43 @@ @import "@faststore/ui/src/components/organisms/ShippingSimulation/styles.scss"; @import "@faststore/ui/src/components/organisms/ImageGallery/styles.scss"; @import "@faststore/ui/src/components/organisms/ProductDetails/styles.scss"; + @import "@faststore/ui/src/components/organisms/SlideOver/styles.scss"; + @import "@faststore/ui/src/components/organisms/SKUMatrix/styles.scss"; [data-fs-product-details-taxes-label] { font-size: var(--fs-product-details-taxes-text-size); font-weight: var(--fs-product-details-taxes-text-weight); color: var(--fs-product-details-taxes-label-color); } + + [data-fs-product-details-settings-separator] { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + color: var(--fs-product-details-separator-color-text); + + &::after { + display: inline-block; + width: 45%; + height: 1px; + content: ""; + background-color: var(--fs-product-details-separator-color); + } + + &::before { + display: inline-block; + width: 45%; + height: 1px; + content: ""; + background-color: var(--fs-product-details-separator-color); + } + } + + [data-fs-sku-matrix] { + > [data-fs-button] { + width: 100%; + } + } } } diff --git a/packages/core/src/components/ui/SKUMatrix/SKUMatrixSidebar.tsx b/packages/core/src/components/ui/SKUMatrix/SKUMatrixSidebar.tsx new file mode 100644 index 0000000000..ff641090cd --- /dev/null +++ b/packages/core/src/components/ui/SKUMatrix/SKUMatrixSidebar.tsx @@ -0,0 +1,132 @@ +import type { SKUMatrixSidebarProps as UISKUMatrixSidebarProps } from '@faststore/ui' +import { + SKUMatrixSidebar as UISKUMatrixSidebar, + useSKUMatrix, +} from '@faststore/ui' +import { gql } from '@generated/gql' +import { useBuyButton } from 'src/sdk/cart/useBuyButton' +import { usePDP } from 'src/sdk/overrides/PageProvider' +import { useAllVariantProducts } from 'src/sdk/product/useAllVariantProducts' + +interface SKUMatrixProps extends UISKUMatrixSidebarProps {} + +function SKUMatrixSidebar(props: SKUMatrixProps) { + const { + data: { product }, + } = usePDP() + + const { allVariantProducts, isOpen, setAllVariantProducts } = useSKUMatrix() + const { isValidating } = useAllVariantProducts( + product.id, + isOpen, + setAllVariantProducts + ) + + const { + gtin, + unitMultiplier, + brand, + additionalProperty, + isVariantOf, + offers: { + offers: [{ seller }], + }, + } = product + + const buyButtonProps = allVariantProducts + .filter((item) => item.selectedCount) + .map((item) => { + const { + offers: { + offers: [{ price, priceWithTaxes, listPrice, listPriceWithTaxes }], + }, + } = item + + return { + id: item.id, + price, + priceWithTaxes, + listPrice, + listPriceWithTaxes, + seller, + quantity: item.selectedCount, + itemOffered: { + sku: item.id, + name: item.name, + gtin, + image: [item.image], + brand, + isVariantOf: { + ...isVariantOf, + skuVariants: { + ...isVariantOf.skuVariants, + activeVariations: item.specifications, + }, + }, + additionalProperty, + unitMultiplier, + }, + } + }) + + const buyProps = useBuyButton(buyButtonProps) + + return ( + + ) +} + +export const fragment = gql(` + fragment ProductSKUMatrixSidebarFragment_product on StoreProduct { + id: productID + isVariantOf { + name + productGroupID + skuVariants { + activeVariations + slugsMap + availableVariations + allVariantProducts { + sku + name + image { + url + alternateName + } + offers { + highPrice + lowPrice + lowPriceWithTaxes + offerCount + priceCurrency + offers { + listPrice + listPriceWithTaxes + sellingPrice + priceCurrency + price + priceWithTaxes + priceValidUntil + itemCondition + availability + quantity + } + } + additionalProperty { + propertyID + value + name + valueReference + } + } + } + } + } +`) + +export default SKUMatrixSidebar diff --git a/packages/core/src/sdk/cart/useBuyButton.ts b/packages/core/src/sdk/cart/useBuyButton.ts index 0964ae84e9..b68122531f 100644 --- a/packages/core/src/sdk/cart/useBuyButton.ts +++ b/packages/core/src/sdk/cart/useBuyButton.ts @@ -8,12 +8,14 @@ import { useUI } from '@faststore/ui' import { useSession } from '../session' import { cartStore } from './index' -export const useBuyButton = (item: CartItem | null) => { +export const useBuyButton = (item: CartItem | CartItem[] | null) => { const { openCart } = useUI() const { currency: { code }, } = useSession() + const itemIsArray = Array.isArray(item) + const onClick = useCallback( (e: React.MouseEvent) => { e.preventDefault() @@ -22,6 +24,33 @@ export const useBuyButton = (item: CartItem | null) => { return } + const value = itemIsArray + ? item.reduce((sum, item) => (sum += item.price * item.quantity), 0) + : item.price * item.quantity + + function generatedItem(item: CartItem) { + return { + item_id: item.itemOffered.isVariantOf.productGroupID, + item_name: item.itemOffered.isVariantOf.name, + item_brand: item.itemOffered.brand.name, + item_variant: item.itemOffered.sku, + quantity: item.quantity, + price: item.price, + discount: item.listPrice - item.price, + currency: code as CurrencyCode, + item_variant_name: item.itemOffered.name, + product_reference_id: item.itemOffered.gtin, + } + } + + function getItems() { + if (!itemIsArray) { + return [generatedItem(item)] + } + + return item.map(generatedItem) + } + import('@faststore/sdk').then(({ sendAnalyticsEvent }) => { sendAnalyticsEvent>({ name: 'add_to_cart', @@ -29,35 +58,27 @@ export const useBuyButton = (item: CartItem | null) => { currency: code as CurrencyCode, // TODO: In the future, we can explore more robust ways of // calculating the value (gift items, discounts, etc.). - value: item.price * item.quantity, - items: [ - { - item_id: item.itemOffered.isVariantOf.productGroupID, - item_name: item.itemOffered.isVariantOf.name, - item_brand: item.itemOffered.brand.name, - item_variant: item.itemOffered.sku, - quantity: item.quantity, - price: item.price, - discount: item.listPrice - item.price, - currency: code as CurrencyCode, - item_variant_name: item.itemOffered.name, - product_reference_id: item.itemOffered.gtin, - }, - ], + value, + items: getItems(), }, }) }) - cartStore.addItem(item) + itemIsArray + ? item.forEach((value) => cartStore.addItem(value)) + : cartStore.addItem(item) + openCart() }, - [code, item, openCart] + [code, item, openCart, itemIsArray] ) return { onClick, 'data-testid': 'buy-button', - 'data-sku': item?.itemOffered.sku, - 'data-seller': item?.seller.identifier, + 'data-sku': itemIsArray ? 'sku-matrix-sidebar' : item?.itemOffered.sku, + 'data-seller': itemIsArray + ? item[0]?.seller.identifier + : item?.seller.identifier, } } diff --git a/packages/core/src/sdk/product/useAllVariantProducts.ts b/packages/core/src/sdk/product/useAllVariantProducts.ts new file mode 100644 index 0000000000..bb30fe3e2c --- /dev/null +++ b/packages/core/src/sdk/product/useAllVariantProducts.ts @@ -0,0 +1,114 @@ +import { useMemo } from 'react' + +import { gql } from '@generated' +import type { + ClientAllVariantProductsQueryQuery, + ClientProductQueryQueryVariables, +} from '@generated/graphql' + +import { useQuery } from '../graphql/useQuery' +import { useSession } from '../session' + +const query = gql(` + query ClientAllVariantProductsQuery($locator: [IStoreSelectedFacet!]!) { + product(locator: $locator) { + ...ProductSKUMatrixSidebarFragment_product + } + } +`) + +type FormattedVariantProduct = { + id: string + name: string + image: { + url: string + alternateName: string + } + inventory: number + selectedCount: number + availability: string + offers: ClientAllVariantProductsQueryQuery['product']['isVariantOf']['skuVariants']['allVariantProducts'][0]['offers'] + price: number + listPrice: number + priceWithTaxes: number + listPriceWithTaxes: number + specifications: Record +} + +export const useAllVariantProducts = < + T extends ClientAllVariantProductsQueryQuery +>( + productID: string, + enabled: boolean, + processResponse: (data: FormattedVariantProduct[]) => void, + fallbackData?: T +) => { + const { channel, locale } = useSession() + const variables = useMemo(() => { + if (!channel) { + throw new Error( + `useAllVariantProducts: 'channel' from session is an empty string.` + ) + } + + return { + locator: [ + { key: 'id', value: productID }, + { key: 'channel', value: channel }, + { key: 'locale', value: locale }, + ], + } + }, [channel, locale, productID]) + + return useQuery( + query, + variables, + { + fallbackData, + revalidateOnMount: true, + doNotRun: !enabled, + onSuccess: (data: ClientAllVariantProductsQueryQuery) => { + const formattedData = + data.product.isVariantOf.skuVariants.allVariantProducts.map( + (item) => { + const specifications = item.additionalProperty.reduce<{ + [key: string]: any + }>( + (acc, prop) => ({ + ...acc, + [prop.name.toLowerCase()]: prop.value, + }), + {} + ) + + const outOfStock = + item.offers.offers[0].availability === + 'https://schema.org/OutOfStock' + + return { + id: item.sku, + name: item.name, + image: { + url: item.image[0].url, + alternateName: item.image[0].alternateName, + }, + inventory: item.offers.offers[0].quantity, + availability: outOfStock ? 'outOfStock' : 'available', + price: item.offers.offers[0].price, + listPrice: item.offers.offers[0].listPrice, + priceWithTaxes: item.offers.offers[0].priceWithTaxes, + listPriceWithTaxes: item.offers.offers[0].listPriceWithTaxes, + specifications, + offers: item.offers, + selectedCount: 0, + } + } + ) + + processResponse( + formattedData.sort((a, b) => a.name.localeCompare(b.name)) + ) + }, + } + ) +} diff --git a/packages/core/src/typings/overrides.ts b/packages/core/src/typings/overrides.ts index d6fc9e0481..df02eb198b 100644 --- a/packages/core/src/typings/overrides.ts +++ b/packages/core/src/typings/overrides.ts @@ -39,6 +39,9 @@ import type { ShippingSimulationProps, SkeletonProps, SkuSelectorProps, + SKUMatrixProps, + SKUMatrixTriggerProps, + SKUMatrixSidebarProps, } from '@faststore/ui' import type { @@ -81,10 +84,10 @@ export type SectionOverride = { export type OverrideComponentsForSection< Section extends SectionsOverrides[keyof SectionsOverrides]['Section'] > = { - // The first 'extends' condition is used to filter out sections that don't have overrides (typed 'never') - [K in keyof SectionsOverrides as SectionsOverrides[K] extends { - Section: never - } +// The first 'extends' condition is used to filter out sections that don't have overrides (typed 'never') +[K in keyof SectionsOverrides as SectionsOverrides[K] extends { + Section: never +} ? never : // In the second 'extends' condition, we check if the section matches the one we're looking for SectionsOverrides[K] extends { @@ -269,12 +272,25 @@ export type SectionsOverrides = { ImageGalleryViewerProps, ImageGalleryViewerProps > + SKUMatrix: ComponentOverrideDefinition + SKUMatrixTrigger: ComponentOverrideDefinition< + SKUMatrixTriggerProps, + SKUMatrixTriggerProps + > + SKUMatrixSidebar: ComponentOverrideDefinition< + SKUMatrixSidebarProps, + SKUMatrixSidebarProps + > __experimentalImageGalleryImage: ComponentOverrideDefinition __experimentalImageGallery: ComponentOverrideDefinition __experimentalShippingSimulation: ComponentOverrideDefinition __experimentalNotAvailableButton: ComponentOverrideDefinition __experimentalProductDescription: ComponentOverrideDefinition - __experimentalProductDetailsSettings: ComponentOverrideDefinition + __experimentalSKUMatrixSidebar: ComponentOverrideDefinition + __experimentalProductDetailsSettings: ComponentOverrideDefinition< + any, + any + > } } ProductGallery: { diff --git a/packages/ui/src/components/organisms/SKUMatrix/styles.scss b/packages/ui/src/components/organisms/SKUMatrix/styles.scss new file mode 100644 index 0000000000..e0bc712f48 --- /dev/null +++ b/packages/ui/src/components/organisms/SKUMatrix/styles.scss @@ -0,0 +1,163 @@ +[data-fs-sku-matrix-sidebar] { + // -------------------------------------------------------- + // Design Tokens for SKU Matrix Sidebar + // -------------------------------------------------------- + + // Default properties + + // Background + --fs-sku-matrix-sidebar-bkg-color : var(--fs-color-body-bkg); + + // Title + --fs-sku-matrix-sidebar-title-size : var(--fs-text-size-6); + --fs-sku-matrix-sidebar-title-text-weight : var(--fs-text-weight-semibold); + + // Cell + --fs-sku-matrix-sidebar-table-cell-font-size : var(--fs-text-size-tiny); + --fs-sku-matrix-sidebar-table-cell-text-weight : var(--fs-text-weight-medium); + + + // Partial + --fs-sku-matrix-slide-over-partial-gap : calc(2 * var(--fs-grid-padding)); + --fs-sku-matrix-slide-over-partial-width-mobile : calc(100vw - var(--fs-sku-matrix-slide-over-partial-gap)); + + // -------------------------------------------------------- + // Structural Styles + // -------------------------------------------------------- + + display: flex; + flex-direction: column; + height: 100vh; + overflow: auto; + + [data-fs-table] { + flex-shrink: 0; + padding: var(--fs-spacing-3) var(--fs-spacing-8); + padding-bottom: 0; + + @include media("=notebook") { + max-width: var(--fs-sku-matrix-slide-over-partial-width-mobile); + } + } + } + + [data-fs-sku-matrix-sidebar-title] { + font-size: var(--fs-sku-matrix-sidebar-title-size); + font-weight: var(--fs-sku-matrix-sidebar-title-text-weight); + line-height: 1.12; + } + + [data-fs-table] { + color: var(--fs-color-neutral-6); + + [data-fs-table-cell] { + &:first-child { + padding-left: 0; + } + + &:last-child { + padding-right: 0; + } + } + } + + [data-fs-table-head] { + [data-fs-table-cell] { + font-size: var(--fs-sku-matrix-sidebar-table-cell-font-size); + font-weight: var(--fs-sku-matrix-sidebar-table-cell-text-weight); + } + } + + [data-fs-table-cell], + [data-fs-price-variant="spot"] { + font-weight: var(--fs-text-weight-medium); + } + + [data-fs-table-cell]:first-child, + [data-fs-sku-matrix-sidebar-table-price] { + color: var(--fs-color-text); + } + + [data-fs-sku-matrix-sidebar-table-price] { + display: flex; + align-items: center; + justify-content: flex-end; + } + + [data-fs-sku-matrix-sidebar-table-cell-quantity-selector] { + width: 1%; + } + + [data-fs-sku-matrix-sidebar-cell-image] { + display: flex; + align-items: center; + gap: var(--fs-spacing-2); + + > div { + img { + object-fit: cover; + object-position: center; + } + } + } + + [data-fs-quantity-selector] { + [data-fs-icon] { + margin: 0; + } + } + + [data-fs-sku-matrix-sidebar-footer] { + display: flex; + justify-content: space-between; + + position: sticky; + bottom: 0; + left: 0; + right: 0; + margin-top: auto; + + background-color: var(--fs-sku-matrix-sidebar-bkg-color); + + padding: var(--fs-spacing-4) var(--fs-spacing-8); + border-top: var(--fs-border-width) solid var(--fs-border-color-light); + width: 100%; + + > div { + display: flex; + gap: var(--fs-spacing-3); + align-items: center; + + > p { + font-weight: var(--fs-text-weight-semibold); + color: var(--fs-color-neutral-5); + } + } + + @include media("