[web] projects/challenge/solution: shopping cart section solution (#789)

This commit is contained in:
Nitesh Seram 2024-08-12 10:56:59 +05:30 committed by GitHub
parent 03f650dd65
commit edfdf72cd7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1693 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@ -19,6 +19,8 @@
"performance": 5
},
"skills": [],
"solutionFrameworks": ["react"],
"solutionFrameworkDefault": "react",
"resources": [
"design-files",
"image-assets",

View File

@ -0,0 +1,10 @@
{
"visibleFiles": [
"/src/App.js",
"/src/index.css",
"/src/pages/Cart/CartPage.jsx"
],
"activeFile": "/src/App.js",
"environment": "create-react-app",
"externalResources": ["https://cdn.tailwindcss.com"]
}

View File

@ -0,0 +1,6 @@
{
"compilerOptions": {
"baseUrl": "."
},
"include": ["src"]
}

View File

@ -0,0 +1,19 @@
{
"name": "@gfe-challenges/shopping-cart-section-solution",
"version": "0.0.1",
"dependencies": {
"clsx": "^2.1.1",
"react-icons": "^5.2.1",
"react-router-dom": "^6.23.1",
"usehooks-ts": "^3.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-scripts": "5.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
}
}

View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app" />
<title>Shopping cart section</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,20 @@
import { Route, Routes } from 'react-router-dom';
import Layout from './Layout';
import CartPage from './pages/Cart';
import CartContextProvider from './context/CartContext';
function App() {
return (
<CartContextProvider>
<Routes>
<Route path="/" element={<Layout />}>
<Route path="/" element={<CartPage />} />
</Route>
</Routes>
</CartContextProvider>
);
}
export default App;

View File

@ -0,0 +1,21 @@
import clsx from 'clsx';
import { Outlet } from 'react-router-dom';
const Layout = () => {
return (
<>
<main className="min-h-screen p-4 max-w-[1440px] mx-auto">
<div
className={clsx(
'rounded-md bg-white min-h-[calc(100vh_-_32px)]',
'shadow-sm md:shadow-md lg:shadow-lg',
'text-neutral-900'
)}>
<Outlet />
</div>
</main>
</>
);
};
export default Layout;

View File

@ -0,0 +1,55 @@
import clsx from 'clsx';
import { RiAddFill, RiSubtractFill } from 'react-icons/ri';
import Tooltip from '../ui/Tooltip';
const CartControl = ({ quantity, decrement, increment, availableStock }) => {
const disabledDecrement = quantity === 1;
const disabledIncrement = quantity >= availableStock;
return (
<div
className={clsx(
'w-[125px] h-9',
'flex justify-center items-center gap-3',
'py-0.5 px-[5px]',
'bg-neutral-50 rounded-md border border-neutral-200'
)}
role="group"
aria-label="Product Quantity control">
<button
type="button"
className={clsx(
'flex justify-center items-center rounded',
'text-neutral-600 disabled:text-neutral-400',
'cursor-pointer disabled:pointer-events-none'
)}
disabled={disabledDecrement}
onClick={decrement}
aria-label="Decrease quantity">
<RiSubtractFill className="size-5 p-0.5 shrink-0" />
</button>
<span
className="flex-1 text-center font-medium text-sm text-neutral-600"
aria-live="polite">
{quantity}
</span>
<Tooltip content="Insufficient stock" show={disabledIncrement}>
<button
type="button"
className={clsx(
'flex justify-center items-center rounded',
'text-neutral-600 disabled:text-neutral-400',
'cursor-pointer disabled:pointer-events-none'
)}
disabled={disabledIncrement}
onClick={increment}
aria-label="Increase quantity">
<RiAddFill className="size-5 p-0.5 shrink-0" />
</button>
</Tooltip>
</div>
);
};
export default CartControl;

View File

@ -0,0 +1,3 @@
import CartControl from './CartControl';
export default CartControl;

View File

@ -0,0 +1,32 @@
import clsx from 'clsx';
const sizeClasses = {
sm: clsx('h-5', 'py px-[5px]', 'text-xs'),
md: clsx('h-6', 'py px-[7px]', 'text-sm'),
lg: clsx('h-7', 'py-[3px] px-[9px]', 'text-sm'),
};
const variantClasses = {
neutral: clsx('bg-gray-50', 'border-neutral-200', 'text-neutral-600'),
danger: clsx('bg-red-50', 'border-red-200', 'text-red-600'),
warning: clsx('bg-amber-50', 'border-amber-200', 'text-amber-700'),
success: clsx('bg-green-50', 'border-green-200', 'text-green-700'),
brand: clsx('bg-indigo-50', 'border-indigo-200', 'text-indigo-700'),
};
const Badge = ({ label, size = 'md', variant = 'neutral', className }) => {
const commonClasses = clsx('rounded-full text-center border');
return (
<div
className={clsx(
commonClasses,
sizeClasses[size],
variantClasses[variant],
className
)}>
{label}
</div>
);
};
export default Badge;

View File

@ -0,0 +1,3 @@
import Badge from './Badge';
export default Badge;

View File

@ -0,0 +1,210 @@
import clsx from 'clsx';
import Link from '../Link';
const paddingClasses = {
md: 'px-3.5 py-2.5',
lg: 'px-4 py-2.5',
xl: 'px-5 py-3',
'2xl': 'px-6 py-4',
};
// We need this because secondary button has border
const secondaryVariantPaddingClasses = {
md: 'px-[13px] py-[9px]',
lg: 'px-[15px] py-[9px]',
xl: 'px-[19px] py-[11px]',
'2xl': 'px-[23px] py-[15px]',
};
const fontSizeClasses = {
md: 'text-sm',
lg: 'text-base',
xl: 'text-base',
'2xl': 'text-lg',
};
const spacingClasses = {
md: 'gap-x-1.5',
lg: 'gap-x-2',
xl: 'gap-x-2',
'2xl': 'gap-x-3',
};
const heightClasses = {
md: 'h-10',
lg: 'h-11',
xl: 'h-12',
'2xl': 'h-15',
};
const iconOnlySizeClasses = {
md: 'size-10',
lg: 'size-11',
xl: 'size-12',
'2xl': 'size-14',
};
const iconSizeClasses = {
md: 'size-5',
lg: 'size-5',
xl: 'size-5',
'2xl': 'size-6',
};
const variantClasses = {
primary: clsx(
'border-none',
'bg-indigo-700',
'shadow-custom',
'text-white',
'hover:bg-indigo-800 focus:bg-indigo-800'
),
secondary: clsx(
'border border-neutral-200',
'bg-white',
'shadow-custom',
'text-neutral-900',
'hover:bg-neutral-50 focus:bg-neutral-50'
),
tertiary: clsx(
'border-none',
'text-indigo-700',
'hover:bg-neutral-50 focus:bg-neutral-50'
),
danger: clsx(
'border-none',
'bg-red-600',
'text-white',
'hover:bg-red-700 focus:bg-red-700 focus:outline-none focus-visible:ring-4 focus-visible:ring-red-600/[.12]'
),
link: clsx(
'text-indigo-700',
'hover:text-indigo-800 focus:text-indigo-800',
'rounded focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]'
),
'gray-link': clsx(
'text-neutral-600',
'hover:text-neutral-900 focus:text-neutral-900',
'rounded focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]',
'px-0.5'
),
};
const variantDisabledClasses = {
primary: clsx(
'disabled:bg-neutral-100',
'disabled:text-neutral-400',
'disabled:shadow-none'
),
secondary: clsx(
'disabled:bg-neutral-100',
'disabled:text-neutral-400',
'disabled:shadow-none'
),
tertiary: clsx('disabled:bg-none', 'disabled:text-neutral-400'),
danger: clsx('disabled:bg-none', 'disabled:text-neutral-400'),
link: clsx('disabled:text-neutral-400'),
'gray-link': clsx('disabled:text-neutral-400'),
};
const Button = ({
label,
className,
isDisabled,
startIcon: StartIcon,
endIcon: EndIcon,
isLabelHidden,
size = 'md',
variant = 'primary',
iconClassName,
href,
...props
}) => {
const isLinkVariant = ['link', 'gray-link'].includes(variant);
const commonClasses = clsx(
'inline-flex items-center justify-center rounded font-medium outline-none cursor-pointer',
'focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]',
'transition-colors',
'text-nowrap',
!isLinkVariant && heightClasses[size],
!isLinkVariant &&
(variant === 'secondary'
? secondaryVariantPaddingClasses[size]
: paddingClasses[size]),
fontSizeClasses[size],
spacingClasses[size],
isLabelHidden && iconOnlySizeClasses[size],
variantClasses[variant],
variantDisabledClasses[variant],
isDisabled && 'pointer-events-none'
);
if (href) {
return (
<Link
to={href}
variant="unstyled"
disabled={isDisabled}
className={clsx(commonClasses, className)}
{...props}>
{StartIcon && (
<StartIcon
className={clsx('size-5 p-0.5 shrink-0', iconClassName)}
aria-hidden="true"
/>
)}
{label}
{EndIcon && (
<EndIcon
className={clsx('size-5 p-0.5 shrink-0', iconClassName)}
aria-hidden="true"
/>
)}
</Link>
);
}
const children = isLabelHidden ? (
(
<StartIcon
className={clsx('shrink-0', iconSizeClasses[size], iconClassName)}
aria-hidden="true"
/>
) || (
<EndIcon
className={clsx('shrink-0', iconSizeClasses[size], iconClassName)}
aria-hidden="true"
/>
)
) : (
<>
{StartIcon && (
<StartIcon
className={clsx('size-5 p-0.5 shrink-0', iconClassName)}
aria-hidden="true"
/>
)}
{label}
{EndIcon && (
<EndIcon
className={clsx('size-5 p-0.5 shrink-0', iconClassName)}
aria-hidden="true"
/>
)}
</>
);
return (
<button
type="button"
className={clsx(commonClasses, className)}
disabled={isDisabled}
{...props}>
{children}
</button>
);
};
export default Button;

View File

@ -0,0 +1,3 @@
import Button from './Button';
export default Button;

View File

@ -0,0 +1,90 @@
import clsx from 'clsx';
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { RiCloseLine } from 'react-icons/ri';
import Button from '../Button';
const ConfirmModal = ({
isOpen,
onClose,
onAction,
title,
description,
children,
primaryActionLabel,
secondaryActionLabel,
actionBtnSize = 'lg',
className,
}) => {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
return () => {
document.body.style.overflow = 'auto';
};
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div
className={clsx(
'fixed inset-0 z-modal',
'bg-neutral-950 bg-opacity-70',
'flex items-center justify-center'
)}
role="dialog"
aria-modal="true">
<div
className={clsx(
'bg-white rounded-lg',
'w-[343px]',
'p-6',
'flex flex-col gap-8',
className
)}>
{children ? (
children
) : (
<div className="flex flex-col gap-1">
<div className={clsx('flex justify-between items-center gap-4')}>
<div className="font-semibold text-lg">{title}</div>
<button
aria-label="Close modal"
className="text-black text-xl font-semibold"
onClick={onClose}>
<RiCloseLine className="size-6" />
</button>
</div>
<p className="text-sm text-neutral-600">{description}</p>
</div>
)}
<div className={clsx('flex gap-3')}>
{secondaryActionLabel && (
<Button
label={secondaryActionLabel}
variant="secondary"
size={actionBtnSize}
className="flex-1"
onClick={onClose}
/>
)}
<Button
label={primaryActionLabel}
size={actionBtnSize}
className="flex-1"
onClick={onAction}
/>
</div>
</div>
</div>,
document.body
);
};
export default ConfirmModal;

View File

@ -0,0 +1,3 @@
import ConfirmModal from './ConfirmModal';
export default ConfirmModal;

View File

@ -0,0 +1,61 @@
import clsx from 'clsx';
import { NavLink, Link as RouterLink } from 'react-router-dom';
const linkVariantClasses = {
primary: clsx(
'text-indigo-700',
'hover:text-indigo-800 focus:text-indigo-800',
'rounded focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]',
'px-0.5'
),
gray: clsx(
'text-neutral-600',
'hover:text-neutral-900 focus:text-neutral-900',
'rounded focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]',
'px-0.5'
),
unstyled: '',
};
const activeLinkClasses = {
primary: 'text-indigo-800',
gray: 'text-neutral-900',
unstyled: '',
};
const Link = ({
children,
disabled,
className,
type = 'default',
variant = 'primary',
...props
}) => {
const commonClassName = clsx(
'font-medium rounded',
linkVariantClasses[variant],
{
'pointer-events-none text-neutral-400': disabled,
},
className
);
if (type === 'nav') {
return (
<NavLink
{...props}
className={({ isActive }) =>
clsx(commonClassName, isActive && activeLinkClasses[variant])
}>
{children}
</NavLink>
);
}
return (
<RouterLink {...props} className={clsx(commonClassName)}>
{children}
</RouterLink>
);
};
export default Link;

View File

@ -0,0 +1,3 @@
import Link from './Link';
export default Link;

View File

@ -0,0 +1,26 @@
import clsx from 'clsx';
const Tag = ({ label, onAction, actionIcon: Icon }) => {
return (
<div
className={clsx(
'flex justify-center items-center gap-1',
'bg-gray-200 rounded',
'px-[7px] py-[3px]',
'border-[0.5px] border-[#e6e6e6]'
)}>
<span className="font-medium text-sm px-0.5">{label}</span>
{Icon && (
<Icon
className={clsx(
'siz-5 text-black',
Icon ? 'cursor-pointer' : 'pointer-events-none'
)}
onClick={onAction}
/>
)}
</div>
);
};
export default Tag;

View File

@ -0,0 +1,3 @@
import Tag from './Tag';
export default Tag;

View File

@ -0,0 +1,98 @@
import clsx from 'clsx';
import { useId } from 'react';
const TextInput = ({
label,
placeholder,
value,
onChange,
type,
id: idParam,
required,
isDisabled,
errorMessage,
hintMessage,
startIcon: StartIcon,
endIcon: EndIcon,
startIconClassName,
endIconClassName,
}) => {
const generateId = useId();
const id = idParam ?? generateId;
const hasError = !!errorMessage;
const messageId = useId();
const hasBottomSection = !!errorMessage || !!hintMessage;
return (
<div className={clsx('flex flex-col gap-1.5 w-full', 'relative')}>
{label && (
<label
className={clsx('text-sm font-medium text-neutral-700')}
htmlFor={id}>
{label}
</label>
)}
<div className="relative">
{StartIcon && (
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<StartIcon
aria-hidden="true"
className={clsx('text-neutral-400', 'size-5', startIconClassName)}
/>
</div>
)}
<input
id={id}
aria-describedby={hasError ? messageId : undefined}
aria-invalid={hasError ? true : undefined}
type={type}
placeholder={placeholder}
value={value}
onChange={event => onChange(event.target.value, event)}
required={required}
disabled={isDisabled}
className={clsx(
'block w-full',
'py-[9px] px-[13px]',
'outline:none',
'border border-neutral-200 disabled:border-neutral-100',
'bg-neutral-50',
'rounded',
'text-sm text-neutral-900 placeholder:text-neutral-500 disabled:text-neutral-400 disabled:placeholder:text-neutral-400',
'focus:outline-none',
'focus:ring-4 focus:ring-offset-0 focus:ring-indigo-600/[.12] focus:border-indigo-600',
hasError && 'focus:ring-red-600/[.12] focus:border-red-600',
'disabled:pointer-events-none',
StartIcon && 'pl-[41px]',
EndIcon && 'pr-[38px]'
)}
/>
{EndIcon && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3.5">
<EndIcon
aria-hidden="true"
className={clsx('text-neutral-400', 'size-4', endIconClassName)}
/>
</div>
)}
</div>
{hasBottomSection && (
<div
id={messageId}
className={clsx(
'text-sm text-neutral-500',
hasError && 'text-red-600'
)}>
{errorMessage || hintMessage}
</div>
)}
</div>
);
};
export default TextInput;

View File

@ -0,0 +1,3 @@
import TextInput from './TextInput';
export default TextInput;

View File

@ -0,0 +1,45 @@
import clsx from 'clsx';
import { useState } from 'react';
const Tooltip = ({ children, content, position = 'top', show = true }) => {
const [visible, setVisible] = useState(false);
const positions = {
top: 'bottom-full left-1/2 transform -translate-x-1/2 mb-2',
bottom: 'top-full left-1/2 transform -translate-x-1/2 mt-2',
left: 'right-full top-1/2 transform -translate-y-1/2 mr-2',
right: 'left-full top-1/2 transform -translate-y-1/2 ml-2',
};
const arrowPositions = {
top: 'bottom-[-4px] left-1/2 transform -translate-x-1/2 border-t-neutral-950 border-t-8 border-x-8 border-x-transparent',
bottom:
'top-[-4px] left-1/2 transform -translate-x-1/2 border-b-neutral-950 border-b-8 border-x-8 border-x-transparent',
left: 'right-[-4px] top-1/2 transform -translate-y-1/2 border-l-neutral-950 border-l-8 border-y-8 border-y-transparent',
right:
'left-[-4px] top-1/2 transform -translate-y-1/2 border-r-neutral-950 border-r-8 border-y-8 border-y-transparent',
};
return (
<div
className="relative flex items-center"
onMouseEnter={() => show && setVisible(true)}
onMouseLeave={() => show && setVisible(false)}>
{children}
{visible && (
<div
className={clsx(
'absolute py-2 px-3 rounded-lg shadow-lg min-w-max max-w-xs',
'bg-neutral-950',
'text-white text-xs font-medium',
positions[position]
)}>
{content}
<div className={clsx('absolute', arrowPositions[position])} />
</div>
)}
</div>
);
};
export default Tooltip;

View File

@ -0,0 +1,3 @@
import Tooltip from './Tooltip';
export default Tooltip;

View File

@ -0,0 +1,20 @@
export const COLORS = {
white: { value: '#fff', label: 'White' },
black: { value: '#000', label: 'Black' },
red: { value: '#DC2626', label: 'Red' },
orange: { value: '#EA580C', label: 'Orange' },
yellow: { value: '#F59E0B', label: 'Yellow' },
green: { value: '#10B981', label: 'Green' },
blue: { value: '#4F46E5', label: 'Blue' },
brown: { value: '#CA8A04', label: 'Brown' },
beige: { value: '#d2b08a', label: 'Beige' },
pink: { value: '#EC4899', label: 'Pink' },
};
export const SIZE = {
xs: { short: 'XS', long: 'Extra Small' },
sm: { short: 'S', long: 'Small' },
md: { short: 'M', long: 'Medium' },
lg: { short: 'L', long: 'Large' },
xl: { short: 'XL', long: 'Extra Large' },
};

View File

@ -0,0 +1,167 @@
import {
createContext,
useState,
useEffect,
useContext,
useMemo,
useCallback,
} from 'react';
import {
getStockChangedData,
mergeSampleAndStorageCartItems,
} from 'src/pages/Cart/utils';
const CartContext = createContext();
export const useCartContext = () => useContext(CartContext);
const CartContextProvider = ({ children }) => {
const [cartItems, setCartItems] = useState([]);
const [stockChangedItems, setStockChangedItems] = useState([]);
const [discount, setDiscount] = useState(null);
const [isFetching, setIsFetching] = useState(true);
const [checkingStock, setCheckingStock] = useState(false);
const [showStockChangedModal, setShowStockChangedModal] = useState(false);
const updateCartItems = items => {
setCartItems(items);
localStorage.setItem('cart', JSON.stringify(items));
};
const checkForStockChanged = useCallback(async cartItems => {
setCheckingStock(true);
const data = await getStockChangedData(cartItems);
setStockChangedItems(data);
setShowStockChangedModal(data.length > 0);
setCheckingStock(false);
}, []);
const getCartItems = useCallback(async () => {
setIsFetching(true);
const data = await fetch(
`https://www.greatfrontend.com/api/projects/challenges/e-commerce/cart-sample`
);
const result = await data.json();
if (!result.error) {
const finalCartItems = mergeSampleAndStorageCartItems(result.items);
updateCartItems(finalCartItems);
checkForStockChanged(finalCartItems);
}
setIsFetching(false);
}, [checkForStockChanged]);
useEffect(() => {
getCartItems();
}, [getCartItems]);
const removeFromCart = useCallback(
item => {
const updatedCart = cartItems.filter(
cartItem =>
!(
cartItem.product.product_id === item.product.product_id &&
cartItem.unit.color === item.unit.color &&
cartItem.unit.size === item.unit.size
)
);
updateCartItems(updatedCart);
},
[cartItems]
);
const changeQuantity = useCallback(
(item, increment = true) => {
let updatedCart;
updatedCart = cartItems.map(cartItem => {
if (
cartItem.product.product_id === item.product.product_id &&
cartItem.unit.color === item.unit.color &&
cartItem.unit.size === item.unit.size
) {
const finalQuantity = increment
? item.quantity + 1
: item.quantity - 1;
return {
...cartItem,
quantity: finalQuantity,
total_list_price: finalQuantity * cartItem.unit.list_price,
total_sale_price: finalQuantity * cartItem.unit.sale_price,
};
}
return cartItem;
});
updateCartItems(updatedCart);
},
[cartItems]
);
const acknowledgeStockChanged = useCallback(
(cartItems, currentStockItems) => {
const updatedCartItems = cartItems.reduce((acc, item) => {
const product = currentStockItems.find(
cartItem =>
cartItem.product.product_id === item.product.product_id &&
cartItem.unit.sku === item.unit.sku
);
if (product) {
// if there is stock then update the quantity, otherwise remove it
if (product.stock > 0) {
acc.push({
...item,
quantity: product.stock,
});
}
} else {
acc.push(item);
}
setShowStockChangedModal(false);
return acc;
}, []);
updateCartItems(updatedCartItems);
},
[]
);
const value = useMemo(
() => ({
cartItems,
isFetching,
discount,
stockChangedItems,
checkingStock,
showStockChangedModal,
acknowledgeStockChanged,
checkForStockChanged,
setDiscount,
removeFromCart,
incrementQuantity: item => changeQuantity(item, true),
decrementQuantity: item => changeQuantity(item, false),
}),
[
cartItems,
isFetching,
discount,
stockChangedItems,
checkingStock,
showStockChangedModal,
acknowledgeStockChanged,
checkForStockChanged,
setDiscount,
removeFromCart,
changeQuantity,
]
);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
};
export default CartContextProvider;

View File

@ -0,0 +1,37 @@
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family:
'Noto Sans',
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Open Sans',
'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
background: linear-gradient(147.52deg, #f9fafb 8.89%, #d2d6db 100.48%);
}
/* Custom z-index */
.z-modal {
z-index: 1055;
}
/* Custom box shadow */
.shadow-custom {
box-shadow:
0px 1px 2px 0 rgb(0 0 0 / 0.06),
0px 1px 3px 0 rgb(0 0 0 / 0.1);
}

View File

@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter as Router } from 'react-router-dom';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<Router>
<App />
</Router>
</React.StrictMode>,
);

View File

@ -0,0 +1,40 @@
import clsx from 'clsx';
import { useCartContext } from 'src/context/CartContext';
import CartItemsSection from './components/CartItemsSection';
import OrderSummarySection from './components/OrderSummarySection';
import EmptyCart from './components/EmptyCart';
const CartPage = () => {
const { isFetching, checkForStockChanged, cartItems } = useCartContext();
const onSubmitCart = e => {
e.preventDefault();
checkForStockChanged(cartItems);
};
return (
<div
className={clsx('px-4 py-12 md:py-16 lg:p-24', 'flex flex-col gap-16')}>
<h2 className="font-semibold text-3xl md:text-5xl">Shopping Cart</h2>
{isFetching ? (
<div>Loading...</div>
) : cartItems.length === 0 ? (
<EmptyCart />
) : (
<form
onSubmit={onSubmitCart}
className={clsx(
'grid grid-cols-4 md:grid-cols-6 lg:grid-cols-12',
'gap-x-4 md:gap-x-8 gap-y-16'
)}>
<CartItemsSection className="col-span-4 md:col-span-6 lg:col-span-8" />
<OrderSummarySection className="col-span-4 md:col-span-6 lg:col-span-4" />
</form>
)}
</div>
);
};
export default CartPage;

View File

@ -0,0 +1,142 @@
import clsx from 'clsx';
import { useState } from 'react';
import CartControl from 'src/components/CartControl';
import Button from 'src/components/ui/Button';
import Link from 'src/components/ui/Link';
import ConfirmModal from 'src/components/ui/ConfirmModal';
import { useCartContext } from 'src/context/CartContext';
import { COLORS, SIZE } from 'src/constants';
import { formatPrice } from '../utils';
const CartItemsSection = ({ className }) => {
const { cartItems, incrementQuantity, decrementQuantity, removeFromCart } =
useCartContext();
const [removalConfirmation, setRemovalConfirmation] = useState({
show: false,
onAction: () => {},
});
const closeRemovalConfirmation = () => {
setRemovalConfirmation({
show: false,
onAction: () => {},
});
};
const openRemovalConfirmation = item => {
setRemovalConfirmation({
show: true,
onAction: () => {
removeFromCart(item);
closeRemovalConfirmation();
},
});
};
return (
<section aria-describedby="cart-items-section" className={clsx(className)}>
<h2 className="sr-only">Items in your shopping cart</h2>
<ul
className={clsx(
'divide-y divide-dashed divide-neutral-300',
className
)}>
{cartItems.map(item => {
const productUrl = `/products/${item.product.product_id}`;
const { unit, product, total_list_price, total_sale_price } = item;
const hasDiscount =
!!total_sale_price && total_sale_price !== total_list_price;
return (
<li
key={product.product_id + unit.size + unit.color}
className={clsx(
'flex flex-col md:flex-row gap-4 md:gap-8',
'py-[31.5px] first:pt-0 last:pb-0'
)}>
<div className="relative">
<img
src={unit.image_url}
alt={`${SIZE[unit.size]?.long ?? unit.size} ${
product.name
} in ${unit.color}`}
className="w-full md:min-w-[280px] h-[200px] object-cover rounded-lg"
/>
<Link
to={productUrl}
variant="unstyled"
className="absolute inset-0"
/>
</div>
<div className="flex flex-col gap-4">
<Link
to={productUrl}
className="font-medium text-2xl"
variant="unstyled">
{product.name}
</Link>
<span className="font-medium text-neutral-600">
{COLORS[unit.color].label}
{unit.size && (
<>
{' • '}
{SIZE[unit.size]?.long ?? unit.size}
</>
)}
</span>
<span className="text-sm text-neutral-600">
{product.description}
</span>
<div className="flex items-center gap-4 justify-between">
<div className="flex items-center gap-4">
<CartControl
quantity={item.quantity}
increment={() => incrementQuantity(item)}
decrement={() => decrementQuantity(item)}
availableStock={unit.stock}
/>
<Button
label="Remove"
variant="gray-link"
onClick={() => openRemovalConfirmation(item)}
/>
</div>
<div className="flex justify-end items-center gap-2">
<span className="font-medium text-lg text-right text-neutral-900">
$
{hasDiscount
? formatPrice(total_sale_price)
: formatPrice(total_list_price)}
</span>
{hasDiscount && (
<span className="text-xs line-through text-neutral-600">
${formatPrice(total_list_price)}
</span>
)}
</div>
</div>
</div>
</li>
);
})}
</ul>
{removalConfirmation.show && (
<ConfirmModal
isOpen={removalConfirmation.show}
title="Confirm Item Removal"
description="Are you sure you want to remove this item from your shopping cart?"
primaryActionLabel="Yes"
secondaryActionLabel="Cancel"
onClose={closeRemovalConfirmation}
onAction={removalConfirmation.onAction}
/>
)}
</section>
);
};
export default CartItemsSection;

View File

@ -0,0 +1,100 @@
import { useState } from 'react';
import { RiCloseFill, RiCouponLine } from 'react-icons/ri';
import Badge from 'src/components/ui/Badge';
import Button from 'src/components/ui/Button';
import Tag from 'src/components/ui/Tag';
import TextInput from 'src/components/ui/TextInput';
import { useCartContext } from 'src/context/CartContext';
const CouponCode = () => {
const { setDiscount, discount } = useCartContext();
const [showAddCoupon, setShowAddCoupon] = useState(false);
const [couponCode, setCouponCode] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [isChecking, setIsChecking] = useState(false);
const checkCoupon = async () => {
if (!couponCode) {
setErrorMessage('Please enter a valid code.');
return;
}
const requestOptions = {
method: 'PUT',
body: JSON.stringify({
coupon_code: couponCode,
}),
};
setIsChecking(true);
const response = await fetch(
'https://www.greatfrontend.com/api/projects/challenges/e-commerce/coupons/apply',
requestOptions
);
const result = await response.json();
if (result.error) {
setErrorMessage("Sorry, but this coupon doesn't exist.");
} else {
setDiscount(result);
setCouponCode('');
setErrorMessage('');
}
setIsChecking(false);
};
return (
<div className="flex flex-col gap-4">
{discount && (
<div className="flex items-center gap-2 justify-between">
<Badge label={discount.coupon_code} variant="brand" size="lg" />
<span className="font-semibold text-lg text-right text-neutral-900">
-
{discount.discount_amount
? `$${discount.discount_amount}`
: `${discount.discount_percentage}%`}
</span>
</div>
)}
{showAddCoupon ? (
<div className="flex flex-col gap-2 items-start py-1">
<div className="flex gap-2 w-full">
<TextInput
placeholder="Enter coupon code"
label="Coupon Code"
value={couponCode}
errorMessage={errorMessage}
onChange={value => setCouponCode(value)}
/>
<Button
label="Apply"
variant="secondary"
className="w-20 shrink-0 mt-[26px]"
onClick={checkCoupon}
isDisabled={isChecking}
/>
</div>
{discount && (
<Tag
label={discount.coupon_code}
actionIcon={RiCloseFill}
onAction={() => setDiscount(null)}
/>
)}
</div>
) : (
<div className="flex justify-end">
<Button
onClick={() => setShowAddCoupon(true)}
label="Add coupon code"
variant="link"
size="lg"
startIcon={RiCouponLine}
/>
</div>
)}
</div>
);
};
export default CouponCode;

View File

@ -0,0 +1,50 @@
import clsx from 'clsx';
import { RiArrowRightLine, RiShoppingCart2Line } from 'react-icons/ri';
import Button from 'src/components/ui/Button';
const EmptyCart = () => {
return (
<div
className={clsx(
'grid grid-cols-4 md:grid-cols-6 lg:grid-cols-12',
'gap-x-4 gap-y-8 md:gap-x-8',
)}>
<div
className={clsx(
'col-span-4 md:col-span-6 lg:col-span-5',
'h-[372px] md:h-[400px] lg:h-full',
'flex flex-col items-center justify-center gap-5',
)}>
<div
className={clsx(
'size-12 shadow-custom rounded-full bg-white',
'flex items-center justify-center',
)}>
<RiShoppingCart2Line className="size-6 text-indigo-700" />
</div>
<div
className={clsx('flex flex-col items-center gap-2', 'text-center')}>
<span className="text-xl font-medium">Your cart is empty</span>
<span>Let's go explore some products</span>
</div>
<Button
size="xl"
label="Explore products"
endIcon={RiArrowRightLine}
href="/products"
/>
</div>
<img
src="https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/empty-cart.jpg"
alt="Empty cart"
className={clsx(
'w-full object-cover',
'h-[180px] md:h-80 lg:h-full',
'col-span-4 md:col-span-6 lg:col-span-7',
)}
/>
</div>
);
};
export default EmptyCart;

View File

@ -0,0 +1,73 @@
import { useMemo } from 'react';
import clsx from 'clsx';
import { useMediaQuery } from 'usehooks-ts';
import Button from 'src/components/ui/Button';
import CouponCode from './CouponCode';
import StockChangedModal from './StockChangedModal';
import { useCartContext } from 'src/context/CartContext';
import { getFinalAmount, getSubtotal } from '../utils';
const OrderSummarySection = ({ className }) => {
const isMobileAndBelow = useMediaQuery('(max-width: 767px)');
const { cartItems, discount, checkingStock } = useCartContext();
const subtotal = useMemo(() => getSubtotal(cartItems), [cartItems]);
const finalAmount = useMemo(
() => getFinalAmount(subtotal, discount),
[subtotal, discount]
);
return (
<section
aria-describedby="cart-summary"
className={clsx(
'flex flex-col gap-8',
'w-full h-fit',
'bg-white',
'p-[15px] md:p-[31px] rounded-lg',
'border border-neutral-200',
className
)}>
<h2 className="font-semibold text-2xl text-neutral-900">Order Summary</h2>
<div
className={clsx(
'flex flex-col',
'divide-y divide-dashed divide-neutral-300'
)}>
<dl className={clsx('flex flex-col gap-4', 'pb-[31.5px]')}>
<div className="flex items-center gap-2 justify-between">
<span className="text-neutral-600">Subtotal</span>
<span className="font-semibold text-lg">${subtotal}</span>
</div>
<div className="flex items-center gap-2 justify-between">
<span className="text-neutral-600">Shipping</span>
<span className="font-semibold text-lg">FREE</span>
</div>
<CouponCode />
</dl>
<div className={clsx('flex flex-col gap-8', 'pt-[31.5px]')}>
<div className="flex gap-4 justify-between">
<span className="font-medium text-2xl">Total</span>
<span className="font-semibold text-4xl">${finalAmount}</span>
</div>
<Button
type="submit"
label="Checkout"
isDisabled={checkingStock}
size={isMobileAndBelow ? 'xl' : '2xl'}
/>
</div>
</div>
<StockChangedModal />
</section>
);
};
export default OrderSummarySection;

View File

@ -0,0 +1,86 @@
import clsx from 'clsx';
import { RiArrowRightLine } from 'react-icons/ri';
import ConfirmModal from 'src/components/ui/ConfirmModal';
import { useCartContext } from 'src/context/CartContext';
import { COLORS, SIZE } from 'src/constants';
const StockChangedModal = () => {
const {
stockChangedItems,
cartItems,
showStockChangedModal,
acknowledgeStockChanged,
} = useCartContext();
if (!showStockChangedModal) {
return null;
}
return (
<ConfirmModal
isOpen={showStockChangedModal}
className={clsx('p-8', 'w-[592px]')}
actionBtnSize="xl"
primaryActionLabel="Ok"
onAction={() => acknowledgeStockChanged(cartItems, stockChangedItems)}>
<div className="flex flex-col gap-8">
<div className={clsx('flex flex-col gap-2')}>
<h2 className="font-semibold text-xl">Change of stock</h2>
<span className="text-sm text-neutral-600">
While you were browsing, certain stocks have become unavailable:
</span>
</div>
<ul
className={clsx(
'divide-y divide-dashed divide-neutral-300',
'max-h-[500px] overflow-y-auto'
)}>
{stockChangedItems.map(item => {
const { unit, product, stock, cartQuantity } = item;
return (
<li
className={clsx(
'flex gap-6',
'py-[31.5px] first:pt-0 last:pb-0'
)}>
<img
src={unit.image_url}
className="size-20 rounded-lg object-cover"
alt={`${SIZE[unit.size]?.long ?? unit.size} ${
product.name
} in ${unit.color}`}
/>
<div className={clsx('flex flex-col gap-2', 'font-medium')}>
<span className="text-xl">{product.name}</span>
<span className="text-neutral-600">
{COLORS[unit.color].label}
{unit.size && (
<>
{' • '}
{SIZE[unit.size]?.long ?? unit.size}
</>
)}
</span>
<div
className={clsx(
'flex items-center gap-2',
'text-neutral-600'
)}>
<span>Quantity: {cartQuantity}</span>
<RiArrowRightLine className="size-3" />
<span>{stock}</span>
</div>
</div>
</li>
);
})}
</ul>
</div>
</ConfirmModal>
);
};
export default StockChangedModal;

View File

@ -0,0 +1,3 @@
import CartPage from './CartPage';
export default CartPage;

View File

@ -0,0 +1,111 @@
export const getSubtotal = items => {
const totalAmount = items.reduce((acc, item) => {
const price = !!item.total_sale_price
? item.total_sale_price
: item.total_list_price;
return acc + price;
}, 0);
return totalAmount.toFixed(2);
};
export const getFinalAmount = (subtotal, discount) => {
if (discount) {
const discountAmount = discount.discount_amount
? discount.discount_amount
: subtotal * (discount.discount_percentage / 100);
return (subtotal - discountAmount).toFixed(2);
}
return subtotal;
};
// Fake stock change data
export const getStockChangedData = items => {
const products = [
{
product: {
product_id: 'stepsoft-socks',
name: 'StepSoft Socks',
},
unit: {
sku: 'ss-orange-xs',
size: 'xs',
color: 'orange',
image_url:
'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/stepsoft-socks/stepsoft-socks-1.jpg',
},
stock: 5,
},
{
product: {
product_id: 'elemental-sneakers',
name: 'Elemental Sneakers',
},
unit: {
sku: 'es-beige-6',
size: '6',
color: 'beige',
image_url:
'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/elemental-sneakers/elemental-sneakers-3.jpg',
},
stock: 3,
},
{
product: {
product_id: 'azure-attitude-shades',
name: 'Azure Attitude Shades',
},
unit: {
sku: 'aas-blue',
size: null,
color: 'blue',
image_url:
'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/azure-attitude-shades/azure-attitude-shades-1.jpg',
},
stock: 2,
},
];
const filteredProducts = products.reduce((acc, item) => {
const product = items.find(
cartItem =>
cartItem.product.product_id === item.product.product_id &&
cartItem.unit.sku === item.unit.sku &&
cartItem.quantity > item.stock
);
if (product) {
acc.push({
...item,
cartQuantity: product ? product.quantity : item.quantity,
});
}
return acc;
}, []);
return new Promise(resolve => {
setTimeout(() => resolve(filteredProducts), 250);
});
};
export const mergeSampleAndStorageCartItems = sampleCartItems => {
// Retrieve cart from localStorage
const storedCartItems = JSON.parse(localStorage.getItem('cart')) || [];
const mergedMap = new Map();
// Add items from the sample cart items to the map
sampleCartItems.forEach(item => {
mergedMap.set(item.unit.sku, item);
});
// Add items from the local storage to the map (overwrites duplicates from sampleCartItems)
storedCartItems.forEach(item => {
mergedMap.set(item.unit.sku, item);
});
// Convert the map back to an array
return Array.from(mergedMap.values());
};
export const formatPrice = price =>
Number.isInteger(price) ? price : price.toFixed(2);