[web] projects/challenge/solution: shopping cart section solution (#789)
This commit is contained in:
parent
03f650dd65
commit
edfdf72cd7
115
apps/web/src/__generated__/projects/challenges/shopping-cart-section/solutions/react.json
generated
Normal file
115
apps/web/src/__generated__/projects/challenges/shopping-cart-section/solutions/react.json
generated
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -19,6 +19,8 @@
|
|||
"performance": 5
|
||||
},
|
||||
"skills": [],
|
||||
"solutionFrameworks": ["react"],
|
||||
"solutionFrameworkDefault": "react",
|
||||
"resources": [
|
||||
"design-files",
|
||||
"image-assets",
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import CartControl from './CartControl';
|
||||
|
||||
export default CartControl;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import Badge from './Badge';
|
||||
|
||||
export default Badge;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import Button from './Button';
|
||||
|
||||
export default Button;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import ConfirmModal from './ConfirmModal';
|
||||
|
||||
export default ConfirmModal;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import Link from './Link';
|
||||
|
||||
export default Link;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import Tag from './Tag';
|
||||
|
||||
export default Tag;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import TextInput from './TextInput';
|
||||
|
||||
export default TextInput;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import Tooltip from './Tooltip';
|
||||
|
||||
export default Tooltip;
|
||||
|
|
@ -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' },
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>,
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import CartPage from './CartPage';
|
||||
|
||||
export default CartPage;
|
||||
|
|
@ -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);
|
||||
Loading…
Reference in New Issue