diff --git a/packages/components/src/molecules/SearchProducts/SearchProductItemContent.tsx b/packages/components/src/molecules/SearchProducts/SearchProductItemContent.tsx index 18cd6b359b..547615fee5 100644 --- a/packages/components/src/molecules/SearchProducts/SearchProductItemContent.tsx +++ b/packages/components/src/molecules/SearchProducts/SearchProductItemContent.tsx @@ -1,5 +1,6 @@ -import React, { forwardRef } from 'react' +import React, { forwardRef, useCallback } from 'react' import { ProductPrice } from '../..' +import SearchProductItemControl from './SearchProductItemControl' import type { PriceDefinition } from '../../typings/PriceDefinition' @@ -12,23 +13,63 @@ export interface SearchProductItemContentProps { * Specifies product's prices. */ price: PriceDefinition + /** + * Quick order condition. + */ + quickOrder?: { + enabled: boolean + availability: boolean + hasVariants: boolean + skuMatrixControl: React.ReactNode + quantity: number, + onChangeQuantity(value: number): void + buyProps?: { + onClick: (e: React.MouseEvent) => void + 'data-testid': string + 'data-sku': string + 'data-seller': string + } + } } const SearchProductItemContent = forwardRef< HTMLElement, SearchProductItemContentProps ->(function SearchProductItemContent({ price, title, ...otherProps }, ref) { +>(function SearchProductItemContent( + { price, title, quickOrder, ...otherProps }, + ref +) { + const renderProductItemContent = useCallback(() => { + return ( + <> +

{title}

+ {price.value !== 0 && ( + + )} + + ) + }, [quickOrder?.enabled]) + return (
-

{title}

+ {!quickOrder?.enabled && renderProductItemContent()} - {price.value !== 0 && ( - + {quickOrder?.enabled && ( + + {renderProductItemContent()} + )}
) diff --git a/packages/components/src/molecules/SearchProducts/SearchProductItemControl.tsx b/packages/components/src/molecules/SearchProducts/SearchProductItemControl.tsx new file mode 100644 index 0000000000..eced0dbb2a --- /dev/null +++ b/packages/components/src/molecules/SearchProducts/SearchProductItemControl.tsx @@ -0,0 +1,124 @@ +import React, { forwardRef, HTMLAttributes } from 'react' +import { Badge, Icon, IconButton, Input, Loader, QuantitySelector } from '../..' +type StatusButtonAddToCartType = 'default' | 'inProgress' | 'completed' + +export interface SearchProductItemControlProps + extends Omit, 'children' | 'onClick'> { + children: React.ReactNode + availability: boolean + hasVariants: boolean + skuMatrixControl: React.ReactNode + quantity: number + onClick?(e: React.MouseEvent): void + onChangeQuantity(value: number): void +} + +const SearchProductItemControl = forwardRef< + HTMLDivElement, + SearchProductItemControlProps +>(function SearchProductItemControl( + { + availability, + children, + hasVariants, + skuMatrixControl, + quantity, + onClick, + onChangeQuantity, + ...otherProps + }, + ref +) { + const [statusAddToCart, setStatusAddToCart] = + React.useState('default') + function stopPropagationClick(e: React.MouseEvent) { + e.preventDefault() + e.stopPropagation() + } + function handleAddToCart(event: React.MouseEvent) { + if (onClick) { + setStatusAddToCart('inProgress') + + setTimeout(() => { + setStatusAddToCart('completed') + onClick(event) + }, 1000) + + setTimeout(() => { + setStatusAddToCart('default') + onChangeQuantity(1) + }, 2000) + } + } + + const getIcon = React.useCallback(() => { + switch (statusAddToCart) { + case 'inProgress': + return + case 'completed': + return + default: + return + } + }, [statusAddToCart]) + + const showSKUMatrixControl = availability && hasVariants; + const isMobile = window.innerWidth <= 768 + + return ( +
+
+ {!availability && ( + + Out of Stock + + )} + {children} +
+ {availability && !hasVariants && ( +
+ {!isMobile && ( + + )} + + {isMobile && ( + onChangeQuantity(e.target.valueAsNumber)} + /> + )} + + +
+ )} + + {showSKUMatrixControl && skuMatrixControl} +
+ ) +}) +export default SearchProductItemControl diff --git a/packages/core/src/components/search/SearchProductItem/SearchProductItem.tsx b/packages/core/src/components/search/SearchProductItem/SearchProductItem.tsx index 6a6f168f35..dd59fe497f 100644 --- a/packages/core/src/components/search/SearchProductItem/SearchProductItem.tsx +++ b/packages/core/src/components/search/SearchProductItem/SearchProductItem.tsx @@ -9,6 +9,8 @@ import { Image } from 'src/components/ui/Image' import { useFormattedPrice } from 'src/sdk/product/useFormattedPrice' import { useProductLink } from 'src/sdk/product/useProductLink' import type { ProductSummary_ProductFragment } from '@generated/graphql' +import { useMemo, useState } from 'react' +import { useBuyButton } from 'src/sdk/cart/useBuyButton' type SearchProductItemProps = { /** @@ -19,11 +21,16 @@ type SearchProductItemProps = { * Index to generate product link. */ index: number + /** + * Enable Quick Order. + */ + quickOrder?: boolean } function SearchProductItem({ product, index, + quickOrder, ...otherProps }: SearchProductItemProps) { const { @@ -36,13 +43,31 @@ function SearchProductItem({ index, }) + const [quantity, setQuantity] = useState(1) + const { - isVariantOf: { name }, + id, + sku, + gtin, + name, + brand, + isVariantOf, + unitMultiplier, image: [img], offers: { lowPrice: spotPrice, - offers: [{ listPrice }], + offers: [ + { + listPrice, + availability, + price, + listPriceWithTaxes, + seller, + priceWithTaxes, + }, + ], }, + additionalProperty, } = product const linkProps = { @@ -54,6 +79,43 @@ function SearchProductItem({ ...baseLinkProps, } + const outOfStock = useMemo( + () => availability === 'https://schema.org/OutOfStock', + [availability] + ) + + const hasVariants = useMemo( + () => + Boolean( + Object.keys(product.isVariantOf.skuVariants.allVariantsByName).length + ), + + [product] + ) + + const buyProps = useBuyButton( + { + id, + price, + priceWithTaxes, + listPrice, + listPriceWithTaxes, + seller, + quantity, + itemOffered: { + sku, + name, + gtin, + image: [img], + brand, + isVariantOf, + additionalProperty, + unitMultiplier, + }, + }, + false + ) + return ( @@ -66,6 +128,16 @@ function SearchProductItem({ listPrice: listPrice, formatter: useFormattedPrice, }} + quickOrder={{ + enabled: quickOrder, + availability: !outOfStock, + hasVariants, + buyProps, + quantity, + onChangeQuantity: setQuantity, + // FIXME: Use SKU Matrix component + skuMatrixControl: , + }} > ) diff --git a/packages/core/src/components/sections/Navbar/section.module.scss b/packages/core/src/components/sections/Navbar/section.module.scss index 9933d2bc0f..1ea0cffb80 100644 --- a/packages/core/src/components/sections/Navbar/section.module.scss +++ b/packages/core/src/components/sections/Navbar/section.module.scss @@ -7,12 +7,14 @@ @import "@faststore/ui/src/components/atoms/Badge/styles.scss"; @import "@faststore/ui/src/components/atoms/Button/styles.scss"; @import "@faststore/ui/src/components/atoms/Icon/styles.scss"; + @import "@faststore/ui/src/components/atoms/Loader/styles.scss"; @import "@faststore/ui/src/components/atoms/Input/styles.scss"; @import "@faststore/ui/src/components/atoms/Link/styles.scss"; @import "@faststore/ui/src/components/atoms/List/styles.scss"; @import "@faststore/ui/src/components/atoms/Logo/styles.scss"; @import "@faststore/ui/src/components/atoms/Price/styles.scss"; @import "@faststore/ui/src/components/molecules/LinkButton/styles.scss"; + @import "@faststore/ui/src/components/molecules/QuantitySelector/styles.scss"; @import "@faststore/ui/src/components/molecules/NavbarLinks/styles.scss"; @import "@faststore/ui/src/components/molecules/ProductPrice/styles.scss"; @import "@faststore/ui/src/components/molecules/SearchAutoComplete/styles.scss"; diff --git a/packages/core/src/sdk/cart/useBuyButton.ts b/packages/core/src/sdk/cart/useBuyButton.ts index 0964ae84e9..29e49b649c 100644 --- a/packages/core/src/sdk/cart/useBuyButton.ts +++ b/packages/core/src/sdk/cart/useBuyButton.ts @@ -8,7 +8,7 @@ import { useUI } from '@faststore/ui' import { useSession } from '../session' import { cartStore } from './index' -export const useBuyButton = (item: CartItem | null) => { +export const useBuyButton = (item: CartItem | null, shouldOpenCart = true) => { const { openCart } = useUI() const { currency: { code }, @@ -49,7 +49,10 @@ export const useBuyButton = (item: CartItem | null) => { }) cartStore.addItem(item) - openCart() + + if (shouldOpenCart) { + openCart() + } }, [code, item, openCart] ) diff --git a/packages/ui/src/components/molecules/SearchProducts/styles.scss b/packages/ui/src/components/molecules/SearchProducts/styles.scss index 0bdc9279bb..3c9443bce4 100644 --- a/packages/ui/src/components/molecules/SearchProducts/styles.scss +++ b/packages/ui/src/components/molecules/SearchProducts/styles.scss @@ -38,6 +38,10 @@ // Item Price --fs-search-product-item-price-size : var(--fs-text-size-base); + + // Item Control + --fs-search-product-item-control-padding-0 : var(--fs-spacing-0); + --fs-search-product-item-control-padding-1 : var(--fs-spacing-1); // -------------------------------------------------------- // Structural Styles @@ -85,6 +89,10 @@ padding-right: var(--fs-search-products-padding-right); } + [data-fs-search-product-item-content] { + flex-grow: 1; + } + [data-fs-search-product-item-image] { display: flex; width: var(--fs-search-product-item-image-size); @@ -114,4 +122,31 @@ font-size: var(--fs-search-product-item-price-size); } } + + [data-fs-search-product-item-control] { + display: flex; + justify-content: space-between; + width: 100%; + } + + [data-fs-product-item-control-input] { + width: 4.625rem; + height: 100%; + text-align: center; + } + + [data-fs-search-product-item-control-badge] { + margin-bottom: var(--fs-search-product-item-control-padding-0); + + & + p { + color: var(--fs-color-neutral-6); + font-weight: var(--fs-text-weight-medium); + } + } + + [data-fs-search-product-item-control-actions] { + display: flex; + margin-left: auto; + gap: var(--fs-search-product-item-control-padding-1); + } }