How to Implement the Quickly Add to Cart Feature?
Last updated
Was this helpful?
Last updated
Was this helpful?
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.
Compatibility: The feature is supported in @akinon/next
versions between 1.43.0-rc.14
and 1.72.0
.
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
:
Ensure that item.attribute_key
values for variantsFilter
and variantsSizes
correspond to the attribute keys used for color
and size
in your project.
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}
/>
)}
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>
);
};
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>
))
);
};
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;