How to Implement the Quickly Add to Cart Feature?

This feature allows users to quickly add products to their basket from the listing pages. A button is displayed on the bottom-right corner of each product, enabling users to view product variants without navigating to the detailed product page. Variants can then be selected and added to the cart directly. This functionality aims to streamline the shopping process, save time, and improve conversion rates and reduce drop-offs caused by lengthy navigation processes.

Technical Requirements and Prerequisites​

  • Compatibility: The feature is supported in @akinon/next versions between 1.43.0-rc.14 and 1.72.0.

Implementation Steps​

1. Adding the Button to Product Item​

To display the "Quickly Add to Cart" button on product items, add the following code block to the desired location in src/views/product-item/index.tsx:

const [openVariantModal, setOpenVariantModal] = useState(false);
  const [openSizeVariantDesktop, setOpenSizeVariantDesktop] = useState(false);
  const [openColorVariantDesktop, setOpenColorVariantDesktop] = useState(false);
  const [variantType, setVariantType] = useState('');
  const [isBasketDrawerOpen, setIsBasketDrawerOpen] = useState(false);
  const variants = product?.extra_data?.variants;
  const image_url_one = product?.productimage_set[0]?.image;
  const product_brand =
    product?.attributes_kwargs?.integration_alt_marka?.label;
  const basket_offers_label = product?.basket_offers[0]?.label?.toLowerCase();
  const basket_offers_price =
    product?.basket_offers[0]?.listing_kwargs?.discounted_total_price;
  const variantsFilter = variants?.filter(
    (item) =>
      item.attribute_key === 'mp_color' ||
      item.attribute_key === 'integration_color' ||
      item.attribute_key === 'integration_renk'
  );

  const variantsSizes = variants?.filter(
    (item) =>
      item.attribute_key === 'mp_size' ||
      item.attribute_key === 'integration_beden'
  );

  const selectableColors =
    variantsFilter &&
    variantsFilter[0]?.options?.filter((item) => item.is_selectable);

  const selectableSizes =
    variantsSizes &&
    variantsSizes[0]?.options?.filter((item) => item.is_selectable);

  useEffect(() => {
    setProductNameText(product_name);
  }, [product_name]);

  useEffect(() => {
    setProducPriceText(price);
  }, [price]);

  const [productNameText, setProductNameText] = useState(product_name);
  const [producPriceText, setProducPriceText] = useState(price);
  const [productVariantUrl, setProducVariantUrl] = useState(absolute_url);
  const [productImage, setProductImage] = useState(image_url_one);
  const [swiper, setSwiper] = useState(null);
  const [multiImages, setMultiImages] = useState(null);
  const [selectedPk, setSelectedPk] = useState<number>(product.pk);

  useEffect(() => {
    setProducVariantUrl(productVariantUrl);
  }, [productVariantUrl]);

  const handleBasketDrawer = useCallback(
    (pk: number) => {
      setSelectedPk(pk);
      setIsBasketDrawerOpen(true);
    },
    [setIsBasketDrawerOpen]
  );

<ShowVariants
       variantsColors={variantsFilter}
       variantsSizes={variantsSizes}
       setVariantType={setVariantType}
       setOpenVariantModal={setOpenVariantModal}
       setOpenColorVariantDesktop={setOpenColorVariantDesktop}
       setOpenSizeVariantDesktop={setOpenSizeVariantDesktop}
       openSizeVariantDesktop={openSizeVariantDesktop}
       openColorVariantDesktop={openColorVariantDesktop}
       variantsFilter={variantsFilter}
       setProductNameText={setProductNameText}
       setProducPriceText={setProducPriceText}
       setProductImage={setProductImage}
       setProducVariantUrl={setProducVariantUrl}
       setMultiImages={setMultiImages}
       swiper={swiper}
       absolute_url={productVariantUrl}
       productPk={product?.pk}
       setIsBasketDrawerOpen={handleBasketDrawer}
       selectedProduct={product}
     />

     {openSizeVariantDesktop && (
       <Suspense fallback={<LoaderSpinner />}>
         <div className="bg-gray-100 p-2">
           <div className="mb-3 text-base xl:text-lg font-bold leading-snug text-secondary">
             <span>Quickly Add to Cart</span>
           </div>
           <VariantModal
             key={product?.pk}
             item={product}
             index={index + 1}
             type={'size'}
             setIsBasketDrawerOpen={handleBasketDrawer}
           />
         </div>
       </Suspense>
     )}

     {!openSizeVariantDesktop && (
       <div className="h-full">
         <Link
           href={productVariantUrl}
           data-testid={`${product.pk}-${index}`}
           className="flex flex-col mb-3 2xl:mb-[1.125rem]"
           key={`${product.pk}-${index}`}
         >
           <span
             className={clsx(
               'text-xs text-secondary font-bold leading-snug xl:!leading-6',
               '2xl:text-lg 2xl:tracking-tight'
             )}
           >
             {product_brand}
           </span>
           <span
             key={`${product.pk}-${index}`}
             className="text-xs font-medium leading-snug text-secondary-100 2xl:text-sm 2xl:leading-5 min-h-[50px] md:min-h-10"
           >
             {productNameText}
           </span>
         </Link>
         {/* <div>
           {retail_price && retail_price > price ? (
             <DiscountPrice
               price={product_min_quantity ? (+product_min_quantity * +producPriceText) : producPriceText}
               retailPrice={product_min_quantity ? (+product_min_quantity * +retail_price) : retail_price}
               priceColor={
                 basket_offers_price ? 'text-secondary' : 'text-primary'
               }
             />
           ) : (
             <Price
               value={product_min_quantity ? (+product_min_quantity * +producPriceText).toString() : producPriceText}
               data-testid="product-price"
               className="text-secondary text-sm font-bold leading-snug tracking-tight 2xl:text-lg"
             />
           )}
         </div> */}
       </div>
     )}

     {variants && (
       <Modal
         portalId={`size_${product.pk}`}
         title={`Farklı ${
           variantType === 'size' ? 'Beden' : 'Renk'
         } Seçenekleri  (${
           variantType === 'size'
             ? selectableSizes?.length
             : selectableColors?.length
         })`}
         open={openVariantModal}
         setOpen={setOpenVariantModal}
         showCloseButton
         className="w-full bottom-0 top-auto transform-none left-0 px-0 pb-5 md:hidden"
         // titleClass="text-secondary md:text-lg font-semibold leading-snug py-3 px-4 border-b-0"
       >
         <Suspense fallback={<LoaderSpinner />}>
           <div>
             <VariantModal
               key={product?.pk}
               item={product}
               index={index + 1}
               type={variantType}
               setIsBasketDrawerOpen={handleBasketDrawer}
             />
           </div>
         </Suspense>
       </Modal>
     )}

     {product.pk && (
       <DrawerBasket
         offerLabel={basket_offers_label}
         offerPrice={basket_offers_price}
         open={isBasketDrawerOpen}
         productPk={selectedPk}
         setOpen={setIsBasketDrawerOpen}
       />
     )}

2. Creating Components for Variants​

Color Variants Component​

A variants folder should be created within the src/views/product-item directory, and a color-variant.tsx file with the following code should be added inside it.

This component ensures that color variants are displayed on the listing page if available:

import { Image } from '@akinon/next/components/image';
import { Link, Price } from '@theme/components';

interface ProductItem {
 is_selectable: boolean;
 product: {
   absolute_url: string;
   productimage_set: { image: string };
   name: string;
   price: number;
 };
}

interface Props {
 variant: ProductItem[];
 index: number;
}

export const ColorVariant = (props: Props) => {
 const { variant, index } = props;
 const default_image = '/default-no-image.png'
 return (
   <div
     key={index}
     className="flex flex-row items-start justify-start gap-x-2 overflow-scroll pl-4"
   >
     {variant
       ?.filter((option) => option.is_selectable)
       ?.map((productItem, itemIndex) => (
         <Link
           key={itemIndex}
           href={productItem.product.absolute_url}
           className="w-[3.75rem] min-w-[3.75rem] h-auto p-0 bg-white border-none flex flex-col gap-y-1"
         >
           <Image
             loading="lazy"
             src={productItem.product.productimage_set[0].image || default_image}
             alt={productItem.product.name}
             title={productItem.product.name}
             aspectRatio={1}
             width={60}
             height={60}
             crop="center"
             className="flex"
             style={{
               imageRendering : "-webkit-optimize-contrast",
             }}
           />

           <Price
             value={productItem.product.price}
             data-testid="product-price"
             className="text-secondary text-xs font-medium leading-snug"
           />
         </Link>
       ))}
   </div>
 );
};

Show Variants Component​

A variants folder should be created within the src/views/product-item directory, and a show-variant.tsx file with the following code should be added inside it.

This component handles the display of size and color variants:

import { Image } from '@akinon/next/components/image';
import { Product } from '@akinon/next/types';
import { Button, Icon, Link } from '@theme/components';
import { useAddProductToBasket } from '@theme/hooks';
import { pushAddToCart } from '@theme/utils/gtm';

interface ColorImage {
 is_selectable: boolean;
 product: Product;
}

const ShowVariants = ({
 variantsSizes,
 variantsColors,
 setVariantType,
 setOpenVariantModal,
 setOpenSizeVariantDesktop,
 openSizeVariantDesktop,
 openColorVariantDesktop,
 variantsFilter,
 setProductNameText,
 setProducPriceText,
 setProductImage,
 setMultiImages,
 swiper,
 absolute_url,
 productPk,
 setIsBasketDrawerOpen,
 setProducVariantUrl,
 selectedProduct
}) => {
 const [addProductToBasket] = useAddProductToBasket();

 const sizeClickHandler = (isMobile: boolean = false) => {
   const selectableSizes =
     variantsSizes &&
     variantsSizes[0]?.options.filter((option) => option.is_selectable);

   if (!selectableSizes) {
     addProductToBasket({
       product: productPk,
       quantity: 1,
       attributes: {},
       shouldOpenMiniBasket: false
     }).then(() => {
       setIsBasketDrawerOpen(productPk);
       pushAddToCart(selectedProduct);
     });
     return;
   }

   setVariantType('size');
   if (isMobile) {
     setOpenVariantModal(true);
     return;
   }
   setOpenSizeVariantDesktop(!openSizeVariantDesktop);
 };

 const selectableColorsLength =
   variantsFilter &&
   variantsFilter[0]?.options?.filter((item) => item.is_selectable)?.length;

 const default_image = '/default-no-image.png';
 return (
   <>
     {variantsColors && variantsColors[0]?.options?.length > 1 && (
       <Button
         onClick={() => {
           setVariantType('color');
           setOpenVariantModal(true);
         }}
         className="absolute bottom-3 left-4 z-10 p-1 bg-white flex flex-row items-center gap-x-1 border-none h-auto md:hidden"
       >
         <Image
           loading="lazy"
           src="/colors.png"
           alt="colors"
           aspectRatio={1}
           width={28.5}
           height={17}
           style={{
             imageRendering: '-webkit-optimize-contrast'
           }}
         />
         <span className="text-xs font-medium leading-snug text-secondary">
           {selectableColorsLength}
         </span>
       </Button>
     )}

     <Button
       onClick={() => sizeClickHandler(true)}
       className="absolute bottom-3 right-4 z-10 p-1.5 bg-secondary flex flex-row items-center gap-x-1 rounded-full border-none h-auto xl:p-2 hover:bg-secondary md:hidden"
     >
       <Icon
         name={'add'}
         className="text-white text-xs !leading-none xl:text-base"
       />
     </Button>

     <div className="hidden md:flex absolute bottom-[1rem] md:bottom-2 h-4 z-10 right-4">
       <Button
         onClick={() => sizeClickHandler()}
         className="bg-secondary flex flex-row items-center gap-x-1 rounded-full border-none h-auto ml-auto p-2 hover:bg-secondary hover:!text-secondary"
       >
         <Icon
           name={'add'}
           className="text-xs text-inherit !leading-none xl:text-base"
         />
       </Button>
     </div>

     {openColorVariantDesktop && variantsFilter?.length && (
       <div className="absolute bottom-0 z-10 w-full bg-white flex flex-row items-center justify-start gap-x-1 p-3">
         {variantsFilter &&
           variantsFilter[0]?.options
             ?.filter((option: ColorImage) => option.is_selectable)
             ?.map(
               (colorImage: ColorImage, colorImageIndex: number) =>
                 colorImageIndex < 4 && (
                   <Image
                     key={colorImageIndex}
                     loading="lazy"
                     src={
                       colorImage.product.productimage_set[0].image ||
                       default_image
                     }
                     alt={colorImage.product.name}
                     title={colorImage.product.name}
                     aspectRatio={1}
                     width={48}
                     height={48}
                     crop="center"
                     className="flex cursor-pointer"
                     style={{
                       imageRendering: '-webkit-optimize-contrast'
                     }}
                     onClick={() => {
                       setProductNameText(colorImage.product.name);
                       setProducPriceText(colorImage.product.price);
                       setProductImage(
                         colorImage.product.productimage_set[0].image ||
                           default_image
                       );
                       setProducVariantUrl(colorImage.product.absolute_url);
                       setMultiImages(colorImage.product.productimage_set);
                       swiper?.slideTo(0);
                     }}
                   />
                 )
             )}

         {variantsFilter && variantsFilter[0]?.options?.length >= 5 && (
           <Link href={absolute_url} className="flex">
             <div className="flex flex-row items-center justify-center gap-x-1 w-[4.25rem]">
               <span className="text-xs font-medium leading-6 text-secondary underline underline-offset-4">
                 +4
               </span>
               <Icon name="chevron-right" size={13} />
             </div>
           </Link>
         )}
       </div>
     )}
   </>
 );
};

export default ShowVariants;

Size Variants Component​

A variants folder should be created within the src/views/product-item directory, and a size-variant.tsx file with the following code should be added inside it.

This component displays size variants for products:

import { Button } from '@theme/components';
import clsx from 'clsx';
import { useAddProductToBasket } from '@theme/hooks';
import { pushAddToCart } from '@theme/utils/gtm';
import { Product } from '@akinon/next/types';

interface VariantItem {
 is_selectable: boolean;
 value: string;
 label: string;
 product: Product;
 order: string;
}

interface Props {
 variant: VariantItem[];
 index: number;
 setIsBasketDrawerOpen: (pk: number) => void;
 productMinQuantity?: number;
}

export const checkSizeHasComma = (size: string) => {
 if (!size) return;

 return size?.includes(',') ? size.replace(',', '.') : size;
};

export const SizeVariant = (props: Props) => {
 const { variant, index, setIsBasketDrawerOpen, productMinQuantity } = props;
 const [addProductToBasket, { isLoading: isAddProductToBasketLoading }] =
   useAddProductToBasket();

 const handleOnClick = (selectedProduct) => {
   addProductToBasket({
     product: selectedProduct.pk,
     quantity: productMinQuantity ?? 1,
     attributes: {},
     shouldOpenMiniBasket: false
   }).then(() => {
     setIsBasketDrawerOpen(selectedProduct.pk);
     pushAddToCart(selectedProduct);
     localStorage?.setItem(
       `GA4_index_${selectedProduct.pk}`,
       JSON.stringify(index)
     );
   });
 };

 return (
   <div
     key={index}
     className={clsx(
       'sm:grid sm:grid-cols-4 lg:flex lg:flex-row items-start justify-start lg:flex-wrap gap-x-5 gap-y-3',
       'md:gap-y-1.5 md:gap-x-1'
     )}
   >
     {variant
       .filter((option) => option.is_selectable)
       .sort((a, b) => {
         const filterableSizeA = a?.product?.attributes
           ?.filterable_size as string;
         const filterableSizeB = b?.product?.attributes
           ?.filterable_size as string;

         if (
           filterableSizeA &&
           filterableSizeB &&
           parseFloat(filterableSizeA) &&
           parseFloat(filterableSizeB)
         ) {
           const floatA = parseFloat(checkSizeHasComma(filterableSizeA));
           const floatB = parseFloat(checkSizeHasComma(filterableSizeB));

           return floatA - floatB;
         } else if (Number(a?.order) && Number(b?.order)) {
           return parseFloat(a.order) - parseFloat(b.order);
         }

         return 0;
       })
       ?.map((productItem, itemIndex) => (
         <Button
           key={itemIndex}
           type="submit"
           appearance="ghost"
           value={productItem.value}
           onClick={() => handleOnClick(productItem.product)}
           disabled={
             !productItem.product.in_stock || isAddProductToBasketLoading
           }
           className={clsx(
             'text-sm text-secondary px-0 h-auto w-[2.875rem]',
             'hover:bg-secondary hover:text-white hover:border-none hover:rounded',
             'md:w-[3.125rem]',
             !productItem.product.in_stock &&
               'hover:bg-gray-200 hover:text-gray-600'
           )}
           data-testid="list-variant-add-cart"
         >
           {productItem?.product?.attributes_kwargs?.filterable_size.label}
         </Button>
       ))}
   </div>
 );
};

3. Creating Variant Modal Component​

A variants folder should be created within the src/views/product-item directory, and a variant-modal.tsx file with the following code should be added inside it.

This modal is displayed when the "Quickly Add to Cart" button is clicked:

import { useGetProductByPkQuery } from '@akinon/next/data/client/product';
import { Product } from '@akinon/next/types';
import { ColorVariant } from './color-variant';
import { SizeVariant } from './size-variant';
import { LoaderSpinner } from '@theme/components';

interface Props {
 item: Product;
 index: number;
 type: string;
 setIsBasketDrawerOpen: (pk: number) => void;
}

export const VariantModal = (props: Props) => {
 const { item, type, index, setIsBasketDrawerOpen } = props;
 const { data, isSuccess , isLoading } = useGetProductByPkQuery(item?.pk);
 const product_min_quantity = data?.product?.attributes?.integration_min_quantity;
 let variantData = [];

 type === 'size'
   ? (variantData = data?.variants?.filter(
       (item) =>
         item.attribute_key === 'mp_size' ||
         item.attribute_key === 'integration_beden'
     ))
   : (variantData = data?.variants?.filter(
       (item) =>
         item.attribute_key === 'mp_color' ||
         item.attribute_key === 'integration_color' ||
         item.attribute_key === 'integration_renk'
     ));

     if(isLoading) {
       return <div><LoaderSpinner/></div>
     }
 return (
   isSuccess &&
   variantData &&
   variantData?.map((variant, i) => (
     <div key={i}>
       {type === 'color' && (
         <ColorVariant key={i} variant={variant.options} index={i} />
       )}
       {type === 'size' && (
         <SizeVariant
           key={i}
           variant={variant.options}
           index={index}
           setIsBasketDrawerOpen={setIsBasketDrawerOpen}
           productMinQuantity={product_min_quantity}
         />
       )}
     </div>
   ))
 );
};

4. Creating Basket Drawer Component​

Create a basket-drawer.tsx file under src/views/basket with the following code.

This component displays the cart drawer that slides out when an item is added to the cart:

import { Image } from '@akinon/next/components/image';
import { Modal, LoaderSpinner, Icon, Link } from '@theme/components';
import { useGetProductByParamsQuery } from '@akinon/next/data/client/product';
import { Price } from '@theme/components';
import { ROUTES } from '@theme/routes';
import { useMediaQuery } from '@akinon/next/hooks';
import clsx from 'clsx';

type Props = {
 open: boolean;
 setOpen: React.Dispatch<React.SetStateAction<boolean>>;
 productPk: number;
 offerLabel?: string | null;
 offerPrice?: number | null;
};

const DrawerBasket = (props: Props) => {
 const matches = useMediaQuery('(max-height: 670px)');

 const { data, isFetching, isSuccess } = useGetProductByParamsQuery(
   {
     pk: props.productPk
   },
   {
     skip: !props.open
   }
 );

 const product_min_quantity =
   data?.product?.attributes?.integration_min_quantity;

 const title = (
   <div className="flex items-center gap-3 text-2xl text-success font-semibold tracing-[-0.48px] leading-snug">
     <Icon name="tick-circle" size={24} />
     <span>It is in the bag</span>
   </div>
 );

 return (
   <>
     <Modal
       portalId="basket-drawer"
       title={title}
       open={props.open}
       setOpen={props.setOpen}
       className={
         '!w-full  md:!w-[820px] !max-w-[510px] right-0 left-auto !transform-none md:!transform md:translate-x-0 h-[95%] md:h-screen !rounded-none overflow-auto md:!px-0'
       }
     >
       {isFetching && <LoaderSpinner />}
       {isSuccess && (
         <div className="mb-8">
           <div className="!md:pt-8 md:px-[52px]">
             <div className="flex items-center justify-between text-secondary text-sm mb-8 w-full gap-3 md:gap-5">
               <div className="w-[110px] h-[110px] md:w-[184px] md:h-[184px] md:max-w-[184px]">
                 <Image
                   alt={data.product.name}
                   src={data.product.productimage_set[0].image}
                   aspectRatio={184 / 184}
                   fill
                   sizes="768px"
                   className="w-[110px] h-[110px] md:w-[184px] md:h-[184px]"
                   style={{
                     imageRendering: '-webkit-optimize-contrast'
                   }}
                 />
               </div>
               <div className="text-xs md:text-sm">
                 <p className="font-medium mb-3">
                   <span className="font-bold">
                     {data.product.attributes_kwargs.integration_alt_marka
                       ?.label ||
                       data.product.attributes.integration_alt_marka}
                   </span>{' '}
                   {data.product.name}
                 </p>
                 <div className="flex justify-between mb-3 md:mb-4">
                   <p>
                     Renk:{' '}
                     <span className="font-bold">
                       {data.product.attributes.integration_renk_tonu}
                     </span>{' '}
                   </p>
                   <p className="hidden md:block">
                     Beden:{' '}
                     <span className="font-bold">
                       {data.product.attributes_kwargs.filterable_size?.value}
                     </span>{' '}
                   </p>
                 </div>
                 <p className="font-bold text-sm md:text-lg leading-6 tracking-tighter">
                   {parseFloat(data.product.retail_price) >
                     parseFloat(data.product.price) && (
                     <Price
                       value={parseFloat(
                         product_min_quantity
                           ? (
                               +product_min_quantity *
                               +data.product.retail_price
                             ).toString()
                           : data.product.retail_price
                       )}
                       decimalScale={2}
                       className="line-through text-base font-medium leading-6 text-secondary"
                     />
                   )}
                   <Price
                     value={
                       product_min_quantity
                         ? (
                             +product_min_quantity * +data.product.price
                           ).toString()
                         : data.product.price
                     }
                     decimalScale={2}
                     className={clsx(
                       'text-lg font-semibold leading-snug',
                       parseFloat(data.product.retail_price) >
                         parseFloat(data.product.price) && 'text-primary ml-4'
                     )}
                   />
                 </p>
                 {props.offerPrice && props.offerLabel && (
                   <div className="flex flex-col mt-1 2xl:flex-row 2xl:items-center 2xl:gap-x-1 2xl:mt-1.5">
                     <span className="text-xs text-secondary whitespace-nowrap font-medium leading-snug capitalize 2xl:leading-6">
                       {props.offerLabel}
                     </span>
                     <Price
                       value={props.offerPrice}
                       data-testid="product-price"
                       className="text-sm text-primary whitespace-nowrap font-bold leading-snug 2xl:tracking-tight"
                     />
                   </div>
                 )}
               </div>
             </div>
             <div
               style={{
                 boxShadow: matches
                   ? '0 -6px 6px 0 rgba(0, 0, 0, 0.08)'
                   : 'none'
               }}
               className="p-4 md:p-0 fixed bottom-0 left-0 w-full md:static bg-white z-10 md:!shadow-none flex justify-between items-center md:mb-24"
             >
               <button
                 onClick={() => props.setOpen(false)}
                 className="font-medium underline"
               >
                 Continue Shopping
               </button>
               <Link
                 as="a"
                 href={ROUTES.BASKET}
                 className="font-medium w-[151px] md:w-[214px] text-center"
               >
                 Go to Bag
               </Link>
             </div>
           </div>
         </div>
       )}
     </Modal>
   </>
 );
};

export default DrawerBasket;

Last updated

Was this helpful?