[web] projects/challenge/solution: product details page solution (#784)
This commit is contained in:
parent
0c0e5c69f3
commit
dbdfe0eabb
244
apps/web/src/__generated__/projects/challenges/product-details-page/solutions/react.json
generated
Normal file
244
apps/web/src/__generated__/projects/challenges/product-details-page/solutions/react.json
generated
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -25,6 +25,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/ProductDetail/ProductDetailPage.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/product-details-page-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>Product details page</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
import Layout from './Layout';
|
||||
import ProductListingPage from 'src/pages/ProductListing';
|
||||
import ProductDetailPage from './pages/ProductDetail';
|
||||
|
||||
import ToastContextProvider from './context/ToastContext';
|
||||
import LatestArrivalsPage from './pages/LatestArrivals';
|
||||
import CartContextProvider from './context/CartContext';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ToastContextProvider>
|
||||
<CartContextProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route path="/products" element={<ProductListingPage />} />
|
||||
<Route path="/latest" element={<LatestArrivalsPage />} />
|
||||
<Route
|
||||
path="/products/:productId"
|
||||
element={<ProductDetailPage />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</CartContextProvider>
|
||||
</ToastContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import clsx from 'clsx';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import Toast from 'src/components/ui/Toast';
|
||||
import Footer from 'src/components/Footer';
|
||||
|
||||
import { useToastContext } from './context/ToastContext';
|
||||
import Navbar from './components/Navbar';
|
||||
|
||||
const Layout = () => {
|
||||
const { toast } = useToastContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar className="mt-4" />
|
||||
<main className="min-h-screen p-4 max-w-[1440px] mx-auto">
|
||||
{toast.show && <Toast type={toast.type} message={toast.message} />}
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-md bg-white min-h-[calc(100vh_-_32px)]',
|
||||
'shadow-sm md:shadow-md lg:shadow-lg',
|
||||
'text-neutral-900'
|
||||
)}>
|
||||
<Outlet />
|
||||
<Footer />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import clsx from 'clsx';
|
||||
import { RiShoppingBag3Line } from 'react-icons/ri';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
import { useCartContext } from 'src/context/CartContext';
|
||||
|
||||
const CartButton = ({ disabled }) => {
|
||||
const { cartItems } = useCartContext();
|
||||
const count = cartItems.length;
|
||||
|
||||
return (
|
||||
<RouterLink
|
||||
aria-label="Cart button"
|
||||
className={clsx(
|
||||
'text-neutral-600 rounded relative',
|
||||
'focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]',
|
||||
{
|
||||
'pointer-events-none text-neutral-400': disabled,
|
||||
}
|
||||
)}>
|
||||
<RiShoppingBag3Line className="size-6" aria-hidden="true" />
|
||||
|
||||
{count > 0 && (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute -top-1.5 -right-1.5 rounded-full text-xs text-center font-semibold h-[18px] w-[18px]',
|
||||
'flex items-center justify-center',
|
||||
{
|
||||
'bg-indigo-700 text-white': !disabled,
|
||||
'bg-neutral-100 text-neutral-400': disabled,
|
||||
}
|
||||
)}>
|
||||
{count}
|
||||
</div>
|
||||
)}
|
||||
</RouterLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartButton;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import CartButton from './CartButton';
|
||||
|
||||
export default CartButton;
|
||||
|
|
@ -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,176 @@
|
|||
import clsx from 'clsx';
|
||||
import {
|
||||
RiFacebookBoxLine,
|
||||
RiGithubLine,
|
||||
RiInstagramLine,
|
||||
RiTwitterXLine,
|
||||
RiYoutubeLine,
|
||||
} from 'react-icons/ri';
|
||||
|
||||
import Link from '../ui/Link';
|
||||
import NewsletterForm from './NewsletterForm';
|
||||
|
||||
const shopCategories = [
|
||||
{
|
||||
title: 'Unisex',
|
||||
href: '/products?categoryId=unisex',
|
||||
},
|
||||
{
|
||||
title: 'Women',
|
||||
href: '/products?categoryId=women',
|
||||
},
|
||||
{
|
||||
title: 'Men',
|
||||
href: '/products?categoryId=men',
|
||||
},
|
||||
];
|
||||
|
||||
const shopCollections = [
|
||||
{
|
||||
title: 'Latest arrivals',
|
||||
href: '/products?collectionId=latest',
|
||||
},
|
||||
{
|
||||
title: 'Urban Oasis',
|
||||
href: '/products?collectionId=urban',
|
||||
},
|
||||
{
|
||||
title: 'Cozy Comfort',
|
||||
href: '/products?collectionId=cozy',
|
||||
},
|
||||
{
|
||||
title: 'Fresh Fusion',
|
||||
href: '/products?collectionId=fresh',
|
||||
},
|
||||
];
|
||||
|
||||
const footerSocials = [
|
||||
{
|
||||
icon: RiYoutubeLine,
|
||||
url: 'https://youtube.com',
|
||||
name: "Link to Stylenest's youtube profile",
|
||||
},
|
||||
{
|
||||
icon: RiInstagramLine,
|
||||
url: 'https://instagram.com',
|
||||
name: "Link to Stylenest's instagram profile",
|
||||
},
|
||||
{
|
||||
icon: RiFacebookBoxLine,
|
||||
url: 'https://facebook.com',
|
||||
name: "Link to Stylenest's facebook profile",
|
||||
},
|
||||
{
|
||||
icon: RiGithubLine,
|
||||
url: 'https://github.com',
|
||||
name: "Link to Stylenest's github profile",
|
||||
},
|
||||
{
|
||||
icon: RiTwitterXLine,
|
||||
url: 'https://twitter.com',
|
||||
name: "Link to Stylenest's twitter profile",
|
||||
},
|
||||
];
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer
|
||||
className={clsx(
|
||||
'',
|
||||
'flex flex-col gap-12 md:gap-16',
|
||||
'px-4 py-12 md:py-16 lg:p-24',
|
||||
)}>
|
||||
<div className="grid grid-cols-4 gap-x-4 md:grid-cols-6 md:gap-x-8 lg:grid-cols-12 lg:gap-y-[66px]">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col gap-2',
|
||||
'col-span-4 md:col-span-6 lg:col-span-8',
|
||||
)}>
|
||||
<div className="text-xl font-semibold text-neutral-900">
|
||||
Join our newsletter
|
||||
</div>
|
||||
<div className="text-neutral-600">
|
||||
We’ll send you a nice letter once per week. No spam.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-4 mt-8 md:col-span-6 md:mt-5 lg:col-span-4 lg:mt-0">
|
||||
<NewsletterForm />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col gap-6 md:gap-8',
|
||||
'col-span-4 mt-12 md:col-span-3 md:mt-16 lg:col-span-4 lg:mt-0',
|
||||
)}>
|
||||
<div>
|
||||
<img
|
||||
src="https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/logo.svg"
|
||||
alt="Stylenest's Logo"
|
||||
className="block h-8 w-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-neutral-600">
|
||||
Craft stunning style journeys that weave more joy into every thread.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3 hidden md:block lg:hidden" />
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col gap-4',
|
||||
'col-span-4 mt-8 md:col-span-3 md:mt-12 lg:col-start-7 lg:mt-0',
|
||||
)}>
|
||||
<div className="text-sm text-neutral-500">SHOP CATEGORIES</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{shopCategories.map((category) => (
|
||||
<Link to={category.href} key={category.title} variant="gray">
|
||||
{category.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col gap-4',
|
||||
'col-span-4 mt-8 md:col-span-3 md:mt-12 lg:mt-0',
|
||||
)}>
|
||||
<div className="text-sm text-neutral-500">SHOP COLLECTIONS</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{shopCollections.map((collection) => (
|
||||
<Link to={collection.href} key={collection.title} variant="gray">
|
||||
{collection.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col gap-8 md:flex-row md:items-center lg:justify-between',
|
||||
'border-t border-neutral-200 pt-[31px]',
|
||||
)}>
|
||||
<div className="text-neutral-500">
|
||||
© {new Date().getFullYear()} StyleNest, Inc. All rights reserved.
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
{footerSocials.map(({ icon: Icon, url, name }) => (
|
||||
<Link
|
||||
key={name}
|
||||
to={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="!px-0 !text-neutral-400">
|
||||
<Icon className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { useState } from 'react';
|
||||
import Button from 'src/components/ui/Button';
|
||||
import TextInput from 'src/components/ui/TextInput';
|
||||
import { useToast } from '../../context/ToastContext';
|
||||
|
||||
const EMAIL_REGEX = /^[^@]+@[^@]+\.[^@]+$/;
|
||||
|
||||
const NewsletterForm = () => {
|
||||
const toast = useToast();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const onSubmit = async event => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!email.match(EMAIL_REGEX)) {
|
||||
setErrorMessage('Please enter a valid email address.');
|
||||
return;
|
||||
} else if (!email) {
|
||||
setErrorMessage('Email address is required.');
|
||||
return;
|
||||
} else {
|
||||
setErrorMessage('');
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
}), // Send the data in JSON format
|
||||
};
|
||||
|
||||
// Make the request
|
||||
const res = await fetch(
|
||||
'https://www.greatfrontend.com/api/projects/challenges/newsletter',
|
||||
requestOptions
|
||||
);
|
||||
const result = await res.json();
|
||||
|
||||
if (result) {
|
||||
setEmail('');
|
||||
if (result.message) {
|
||||
toast.success(result.message);
|
||||
} else if (result.error) {
|
||||
toast.error(result.error);
|
||||
}
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col md:flex-row gap-4 w-full">
|
||||
<TextInput
|
||||
placeholder="Enter your email"
|
||||
errorMessage={errorMessage}
|
||||
onChange={value => setEmail(value)}
|
||||
value={email}
|
||||
required
|
||||
/>
|
||||
<Button label="Subscribe" type="submit" isDisabled={submitting} />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsletterForm;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import Footer from './Footer';
|
||||
|
||||
export default Footer;
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { RiCloseLine, RiMenuLine } from 'react-icons/ri';
|
||||
|
||||
import Link from '../ui/Link';
|
||||
|
||||
const MobileNavMenu = ({ links }) => {
|
||||
const [openMenu, setOpenMenu] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpenMenu(!openMenu)}
|
||||
aria-label="Open mobile menu"
|
||||
aria-expanded={openMenu ? 'true' : 'false'}
|
||||
aria-controls="nav-slideout-menu"
|
||||
type="button"
|
||||
className={clsx(
|
||||
'block rounded text-neutral-600 lg:hidden',
|
||||
'focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]',
|
||||
)}>
|
||||
<RiMenuLine className="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{/* Mobile nav menu */}
|
||||
{openMenu &&
|
||||
createPortal(
|
||||
<nav
|
||||
id="nav-slideout-menu"
|
||||
className={clsx(
|
||||
'z-fixed fixed inset-0 max-w-[400px] bg-white px-4 py-6 lg:hidden',
|
||||
'flex flex-col gap-6',
|
||||
'animate-slideout',
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<img
|
||||
src="https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/logo.svg"
|
||||
alt="Logo"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setOpenMenu(false)}
|
||||
aria-label="Close mobile menu"
|
||||
type="button"
|
||||
className={clsx(
|
||||
'rounded text-neutral-600',
|
||||
'focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]',
|
||||
)}>
|
||||
<RiCloseLine className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
to={link.href}
|
||||
onClick={() => setOpenMenu(false)}
|
||||
className="px-3 py-2 text-sm"
|
||||
variant="gray"
|
||||
type="nav">
|
||||
{link.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileNavMenu;
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import Link from '../ui/Link';
|
||||
import CartButton from '../CartButton';
|
||||
import MobileNavMenu from './MobileNavMenu';
|
||||
|
||||
const links = [
|
||||
{
|
||||
name: 'Shop all',
|
||||
href: '/products',
|
||||
},
|
||||
{
|
||||
name: 'Latest arrivals',
|
||||
href: '/latest',
|
||||
},
|
||||
];
|
||||
|
||||
const Navbar = ({ className }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'z-sticky sticky top-0',
|
||||
'mx-auto max-w-[1216px]',
|
||||
'h-[68px] lg:h-[60px]',
|
||||
'px-4 py-3 md:px-8 xl:px-0',
|
||||
'flex items-center justify-between gap-4 lg:gap-20',
|
||||
className,
|
||||
)}>
|
||||
<Link className="w-[163px]" variant="unstyled" to="/">
|
||||
<img
|
||||
src="https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/logo.svg"
|
||||
alt="Stylenest's Logo"
|
||||
/>
|
||||
</Link>
|
||||
<nav className={clsx('hidden flex-1 gap-8', 'lg:flex')}>
|
||||
{links.map((link) => (
|
||||
<Link to={link.href} variant="gray" type="nav" end>
|
||||
{link.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex items-center gap-4">
|
||||
<CartButton />
|
||||
<MobileNavMenu links={links} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import Navbar from './Navbar';
|
||||
|
||||
export default Navbar;
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import clsx from 'clsx';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import Link from 'src/components/ui/Link';
|
||||
import ColorSwatches from 'src/components/ui/ColorSwatches';
|
||||
|
||||
import { COLORS } from 'src/constants';
|
||||
|
||||
import { getUnavailableColors } from 'src/pages/ProductDetail/utils';
|
||||
|
||||
const ProductCard = ({ product }) => {
|
||||
const navigate = useNavigate();
|
||||
const { images, name, inventory, colors } = product;
|
||||
const { discount_percentage, sale_price, list_price, color } = inventory[0];
|
||||
|
||||
const hasDiscount = !!discount_percentage;
|
||||
|
||||
const unavailableColors = useMemo(
|
||||
() => getUnavailableColors(product),
|
||||
[product]
|
||||
);
|
||||
|
||||
const redirectUrl = `/products/${product.product_id}`;
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
event => {
|
||||
if (event.key === 'Enter') {
|
||||
navigate(redirectUrl);
|
||||
}
|
||||
},
|
||||
[navigate, redirectUrl]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={clsx(
|
||||
'w-full relative',
|
||||
'group',
|
||||
'flex flex-col gap-4',
|
||||
'rounded-lg',
|
||||
'outline-none',
|
||||
'focus:ring-4 focus:ring-indigo-600/[.12]'
|
||||
)}>
|
||||
<img
|
||||
src={images[0].image_url}
|
||||
alt={`${name}'s product preview`}
|
||||
loading="lazy"
|
||||
className={clsx(
|
||||
'h-[225px] md:h-[300px] w-full object-cover rounded-lg'
|
||||
)}
|
||||
/>
|
||||
<div className={clsx('flex flex-col', 'min-h-[152px]')}>
|
||||
<span className="text-xs text-neutral-600 mb-0.5">
|
||||
{COLORS[color]?.label}
|
||||
</span>
|
||||
<Link
|
||||
to={redirectUrl}
|
||||
tabIndex={-1}
|
||||
variant="unstyled"
|
||||
className={clsx(
|
||||
'font-medium text-lg text-neutral-900',
|
||||
'group-hover:text-indigo-700'
|
||||
)}>
|
||||
<span aria-hidden={true} className="absolute inset-0" />
|
||||
{name}
|
||||
</Link>
|
||||
<div className="flex gap-2 items-center mt-3">
|
||||
<span className="text-lg text-neutral-500">
|
||||
${hasDiscount ? sale_price : list_price}
|
||||
</span>
|
||||
{hasDiscount && (
|
||||
<span className="text-xs line-through text-neutral-600">
|
||||
${list_price}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap mt-3">
|
||||
{colors.map(color => (
|
||||
<ColorSwatches
|
||||
key={color}
|
||||
color={COLORS[color].value}
|
||||
size="sm"
|
||||
outOfStock={unavailableColors.includes(color)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCard;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import ProductCard from './ProductCard';
|
||||
|
||||
export default ProductCard;
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import clsx from 'clsx';
|
||||
import ProductCard from 'src/components/ProductCard';
|
||||
|
||||
const ProductGridSection = ({ products }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-4 md:grid-cols-6 lg:grid-cols-12 gap-x-4 md:gap-x-8 gap-y-8">
|
||||
{products.map(product => (
|
||||
<div
|
||||
key={product.product_id}
|
||||
className={clsx('col-span-4 md:col-span-3')}>
|
||||
<ProductCard product={product} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductGridSection;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import ProductGridSection from './ProductGridSection';
|
||||
|
||||
export default ProductGridSection;
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
RiColorFilterLine,
|
||||
RiHandHeartLine,
|
||||
RiInfinityFill,
|
||||
RiPaintLine,
|
||||
RiPlantLine,
|
||||
RiPriceTag2Line,
|
||||
RiRainbowLine,
|
||||
RiRecycleLine,
|
||||
RiScales2Line,
|
||||
RiShapesLine,
|
||||
RiShieldStarLine,
|
||||
RiShirtLine,
|
||||
RiStackLine,
|
||||
RiTShirtLine,
|
||||
RiWaterFlashLine,
|
||||
RiWindyLine,
|
||||
} from 'react-icons/ri';
|
||||
|
||||
import Tabs from '../ui/Tabs';
|
||||
|
||||
const TABS = [
|
||||
{ label: 'Sustainability', value: 'sustainability' },
|
||||
{ label: 'Comfort', value: 'comfort' },
|
||||
{ label: 'Durability', value: 'durability' },
|
||||
{ label: 'Versatility', value: 'versatility' },
|
||||
];
|
||||
|
||||
const specifications = [
|
||||
{
|
||||
value: 'sustainability',
|
||||
title: 'Eco-Friendly Choice',
|
||||
description:
|
||||
'With our sustainable approach, we curate clothing that makes a statement of care—care for the planet, and for the art of fashion.',
|
||||
img: {
|
||||
desktop:
|
||||
'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/yellow-desktop.jpg',
|
||||
tablet:
|
||||
'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/yellow-tablet.jpg',
|
||||
mobile:
|
||||
'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/yellow-mobile.jpg',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
label: 'Recycled Materials',
|
||||
icon: RiRecycleLine,
|
||||
},
|
||||
{
|
||||
label: 'Low Impact Dye',
|
||||
icon: RiPaintLine,
|
||||
},
|
||||
{
|
||||
label: 'Carbon Neutral',
|
||||
icon: RiPlantLine,
|
||||
},
|
||||
{
|
||||
label: 'Water Conservation',
|
||||
icon: RiWaterFlashLine,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'comfort',
|
||||
title: 'Uncompromised Comfort',
|
||||
description:
|
||||
'Our garments are a sanctuary of softness, tailored to drape gracefully and allow for freedom of movement.',
|
||||
img: {
|
||||
desktop:
|
||||
'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/black-desktop.jpg',
|
||||
tablet:
|
||||
'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/black-tablet.jpg',
|
||||
mobile:
|
||||
'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/black-mobile.jpg',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
label: 'Ergonomic Fits',
|
||||
icon: RiTShirtLine,
|
||||
},
|
||||
{
|
||||
label: 'Soft-to-the-Touch Fabrics',
|
||||
icon: RiHandHeartLine,
|
||||
},
|
||||
{
|
||||
label: 'Breathable Weaves',
|
||||
icon: RiWindyLine,
|
||||
},
|
||||
{
|
||||
label: 'Thoughtful Design',
|
||||
icon: RiColorFilterLine,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'durability',
|
||||
title: 'Built to Last',
|
||||
description:
|
||||
'Here’s to apparel that you can trust to look as good as new, wear after wear, year after year.',
|
||||
img: {
|
||||
desktop:
|
||||
'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/chair-desktop.jpg',
|
||||
tablet:
|
||||
'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/chair-tablet.jpg',
|
||||
mobile:
|
||||
'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/chair-mobile.jpg',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
label: 'Reinforced Construction',
|
||||
icon: RiStackLine,
|
||||
},
|
||||
{
|
||||
label: 'Quality Control',
|
||||
icon: RiScales2Line,
|
||||
},
|
||||
{
|
||||
label: 'Material Resilience',
|
||||
icon: RiShieldStarLine,
|
||||
},
|
||||
{
|
||||
label: 'Warranty and Repair',
|
||||
icon: RiPriceTag2Line,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
value: 'versatility',
|
||||
title: 'Versatile by Design',
|
||||
description:
|
||||
'Our pieces are a celebration of versatility, offering a range of styles that are as perfect for a business meeting as they are for a casual brunch. ',
|
||||
img: {
|
||||
desktop:
|
||||
'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/clothes-desktop.jpg',
|
||||
tablet:
|
||||
'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/clothes-tablet.jpg',
|
||||
mobile:
|
||||
'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/clothes-mobile.jpg',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
label: 'Adaptive Styles',
|
||||
icon: RiRainbowLine,
|
||||
},
|
||||
{
|
||||
label: 'Functional Fashion',
|
||||
icon: RiShirtLine,
|
||||
},
|
||||
{
|
||||
label: 'Timeless Aesthetics',
|
||||
icon: RiInfinityFill,
|
||||
},
|
||||
{
|
||||
label: 'Mix-and-Match Potential',
|
||||
icon: RiShapesLine,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const ProductSpecificationSection = () => {
|
||||
const [selectedSpecification, setSelectedSpecification] =
|
||||
useState('sustainability');
|
||||
|
||||
const data = specifications.find(
|
||||
(specification) => specification.value === selectedSpecification,
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={clsx(
|
||||
'px-4 py-12 md:py-16 lg:px-28 lg:py-24',
|
||||
'flex flex-col gap-16',
|
||||
)}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<h2 className="text-3xl font-semibold text-neutral-900 md:text-5xl">
|
||||
Discover timeless elegance
|
||||
</h2>
|
||||
<p className="text-lg text-neutral-600">
|
||||
Step into a world where quality meets quintessential charm with our
|
||||
collection. Every thread weaves a promise of unparalleled quality,
|
||||
ensuring that each garment is not just a part of your wardrobe, but a
|
||||
piece of art. Here's the essence of what makes our apparel the
|
||||
hallmark for those with an eye for excellence and a heart for the
|
||||
environment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
<Tabs
|
||||
label="Select specification"
|
||||
value={selectedSpecification}
|
||||
tabs={TABS}
|
||||
onSelect={setSelectedSpecification}
|
||||
/>
|
||||
<div className="flex flex-col gap-8 lg:flex-row">
|
||||
<picture className="shrink-0">
|
||||
<source srcSet={data.img.desktop} media="(min-width: 1024px)" />
|
||||
<source srcSet={data.img.tablet} media="(min-width: 768px)" />
|
||||
<img
|
||||
loading="lazy"
|
||||
src={data.img.mobile}
|
||||
className={clsx(
|
||||
'h-[180px] md:h-96 lg:h-64',
|
||||
'w-full lg:w-[367px]',
|
||||
'rounded-lg object-cover',
|
||||
)}
|
||||
alt={`${selectedSpecification}'s banner`}
|
||||
/>
|
||||
</picture>
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-2xl font-medium text-neutral-900">
|
||||
{data.title}
|
||||
</h3>
|
||||
<p className=" text-neutral-600">{data.description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-wrap',
|
||||
'gap-4 md:gap-x-12 md:gap-y-8 lg:gap-8',
|
||||
)}>
|
||||
{data.items.map(({ label, icon: Icon }) => (
|
||||
<div
|
||||
className="flex w-full items-center gap-2 md:w-80 md:gap-4 lg:w-[282px]"
|
||||
key={label}>
|
||||
<div
|
||||
className={clsx(
|
||||
'size-12 rounded-full bg-white shadow',
|
||||
'flex items-center justify-center',
|
||||
'shrink-0',
|
||||
)}>
|
||||
<Icon className="size-6 text-indigo-700" />
|
||||
</div>
|
||||
<span className="text-neutral-600">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductSpecificationSection;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import ProductSpecificationSection from './ProductSpecificationSection';
|
||||
|
||||
export default ProductSpecificationSection;
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import clsx from 'clsx';
|
||||
import { useState, useRef, createContext, useContext } from 'react';
|
||||
import { RiAddCircleLine, RiIndeterminateCircleLine } from 'react-icons/ri';
|
||||
|
||||
const AccordionItemContext = createContext();
|
||||
|
||||
const AccordionItem = ({ children, id }) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AccordionItemContext.Provider value={{ id, isOpen, setIsOpen }}>
|
||||
{children}
|
||||
</AccordionItemContext.Provider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccordionTrigger = ({ children }) => {
|
||||
const { id, isOpen, setIsOpen } = useContext(AccordionItemContext);
|
||||
const Icon = isOpen ? RiIndeterminateCircleLine : RiAddCircleLine;
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
'w-full',
|
||||
'flex gap-6 justify-between items-center',
|
||||
'focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]',
|
||||
'text-left text-lg text-neutral-900 font-medium'
|
||||
)}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={`accordion-content-${id}`}
|
||||
id={`accordion-header-${id}`}>
|
||||
<span>{children}</span>
|
||||
<Icon className="size-6 text-neutral-400" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const AccordionContent = ({ children }) => {
|
||||
const contentRef = useRef(null);
|
||||
const { id, isOpen } = useContext(AccordionItemContext);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`accordion-content-${id}`}
|
||||
role="region"
|
||||
aria-labelledby={`accordion-header-${id}`}
|
||||
className={clsx(
|
||||
'overflow-hidden transition-max-height duration-300',
|
||||
'pr-12',
|
||||
isOpen && 'mt-2'
|
||||
)}
|
||||
style={{
|
||||
maxHeight: isOpen ? `${contentRef.current?.scrollHeight}px` : '0',
|
||||
}}
|
||||
ref={contentRef}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Accordion = ({ children }) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{children.map((item, index) => (
|
||||
<div key={item.props.id}>
|
||||
{item}
|
||||
{index !== children.length - 1 && (
|
||||
<div className="h-[1px] bg-neutral-200 mt-8 mb-[23px]" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import * as Accordion from './Accordion';
|
||||
|
||||
export * from './Accordion';
|
||||
export default Accordion;
|
||||
|
|
@ -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,200 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import Link from 'src/components/ui/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]'
|
||||
),
|
||||
};
|
||||
|
||||
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'),
|
||||
};
|
||||
|
||||
const Button = ({
|
||||
label,
|
||||
className,
|
||||
isDisabled,
|
||||
startIcon: StartIcon,
|
||||
endIcon: EndIcon,
|
||||
isLabelHidden,
|
||||
size = 'md',
|
||||
variant = 'primary',
|
||||
iconClassName,
|
||||
href,
|
||||
...props
|
||||
}) => {
|
||||
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',
|
||||
variant !== 'link' && heightClasses[size],
|
||||
variant !== 'link' &&
|
||||
(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={variant}
|
||||
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
|
||||
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,46 @@
|
|||
import { useId } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const CheckboxInput = ({ value, defaultValue, disabled, label, onChange }) => {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-6 items-center justify-center">
|
||||
<input
|
||||
checked={value}
|
||||
className={clsx(
|
||||
'size-4',
|
||||
'rounded',
|
||||
'text-indigo-600',
|
||||
'border border-neutral-300',
|
||||
'bg-transparent',
|
||||
['disabled:!bg-neutral-200', 'disabled:cursor-not-allowed'],
|
||||
'focus:ring-4 focus:ring-offset-0 focus:ring-indigo-600/[.12] focus:outline-none focus:border-indigo-600'
|
||||
)}
|
||||
defaultChecked={defaultValue}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
type="checkbox"
|
||||
onChange={event => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(event.target.checked, event);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={clsx(
|
||||
'block',
|
||||
disabled ? 'text-neutral-400' : 'text-neutral-600'
|
||||
)}>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckboxInput;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import CheckboxInput from './CheckboxInput';
|
||||
|
||||
export default CheckboxInput;
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
const outerSizeClasses = {
|
||||
md: 'size-[56.67px]',
|
||||
sm: 'size-6',
|
||||
};
|
||||
|
||||
const innerSizeClasses = {
|
||||
md: 'size-[38px]',
|
||||
sm: 'size-4',
|
||||
};
|
||||
|
||||
const ringSizeClasses = {
|
||||
md: 'focus:ring-[9.33px]',
|
||||
sm: 'focus:ring-4',
|
||||
};
|
||||
|
||||
const strokeLineClasses = {
|
||||
md: 'h-0.5 w-11',
|
||||
sm: 'h-px w-5',
|
||||
};
|
||||
|
||||
const ColorSwatches = ({
|
||||
color,
|
||||
selected,
|
||||
onClick,
|
||||
outOfStock,
|
||||
size = 'md',
|
||||
type = 'radio',
|
||||
}) => {
|
||||
const readOnly = !onClick || outOfStock;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={color}
|
||||
aria-label={color}
|
||||
className={clsx(
|
||||
'flex items-center justify-center',
|
||||
'rounded-full',
|
||||
outerSizeClasses[size],
|
||||
readOnly ? 'pointer-events-none' : 'cursor-pointer'
|
||||
)}>
|
||||
<input
|
||||
type={type}
|
||||
name="color-choice"
|
||||
value={color}
|
||||
checked={selected}
|
||||
className="sr-only"
|
||||
onChange={() => {
|
||||
if (!onClick) {
|
||||
return;
|
||||
}
|
||||
onClick(color);
|
||||
}}
|
||||
tabIndex={-1}
|
||||
disabled={outOfStock}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
'relative',
|
||||
'flex items-center justify-center',
|
||||
'rounded-full',
|
||||
innerSizeClasses[size],
|
||||
color === '#fff' && 'border border-neutral-200',
|
||||
selected
|
||||
? 'outline outline-1 outline-indigo-600 border-2 border-white'
|
||||
: !readOnly && [
|
||||
'hover:border-2 hover:border-indigo-200',
|
||||
'focus:outline-none focus:border-none focus:ring-indigo-600/[.12]',
|
||||
ringSizeClasses[size],
|
||||
]
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
tabIndex={selected || outOfStock || readOnly ? -1 : 0}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onClick(color);
|
||||
}
|
||||
}}>
|
||||
{selected && !outOfStock && (
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 28 28"
|
||||
fill="none"
|
||||
className={clsx(color === '#fff' ? 'fill-black' : 'fill-white')}
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.6673 17.6993L22.3918 6.97485L24.0417 8.62477L11.6673 20.9991L4.24268 13.5745L5.89259 11.9246L11.6673 17.6993Z" />
|
||||
</svg>
|
||||
)}
|
||||
{outOfStock && (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute bg-neutral-600 -rotate-45',
|
||||
strokeLineClasses[size]
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorSwatches;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import ColorSwatches from './ColorSwatches';
|
||||
|
||||
export default ColorSwatches;
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import clsx from 'clsx';
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { RiArrowDownSLine } from 'react-icons/ri';
|
||||
|
||||
import Button from '../Button';
|
||||
|
||||
const DropdownContext = createContext();
|
||||
|
||||
const DropdownItem = ({ children, isSelected, onSelect }) => {
|
||||
const { setIsOpen, isOpen } = useContext(DropdownContext);
|
||||
const handleOptionClick = () => {
|
||||
setIsOpen(false);
|
||||
if (onSelect) {
|
||||
onSelect();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleOptionClick}
|
||||
className={clsx(
|
||||
'block text-sm',
|
||||
'cursor-pointer',
|
||||
'rounded',
|
||||
'hover:bg-neutral-50',
|
||||
'border-none outline-none',
|
||||
'focus:ring focus:ring-indigo-200',
|
||||
'p-2',
|
||||
isSelected ? 'text-indigo-700 font-medium' : 'text-neutral-600'
|
||||
)}
|
||||
role="menuitem"
|
||||
tabIndex={isOpen ? 0 : -1}
|
||||
id="menu-item-0">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Dropdown = ({ children }) => {
|
||||
const id = useId();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = event => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative inline-block text-left" ref={dropdownRef}>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
label="Sort by"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
id={id}
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
variant="secondary"
|
||||
endIcon={RiArrowDownSLine}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute right-0 z-dropdown mt-2 w-56 origin-top-right max-h-50',
|
||||
'border border-[#e6e6e6]',
|
||||
'rounded-lg bg-white shadow-lg',
|
||||
'transition ease-in-out duration-300 transform origin-top',
|
||||
isOpen ? 'scale-y-100 opacity-100' : 'scale-y-0 opacity-0'
|
||||
)}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby={id}
|
||||
tabIndex={-1}>
|
||||
<div className="flex flex-col gap-2 p-2" role="none">
|
||||
<DropdownContext.Provider value={{ isOpen, setIsOpen }}>
|
||||
{children}
|
||||
</DropdownContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Dropdown, DropdownItem };
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import * as Dropdown from './Dropdown';
|
||||
|
||||
export * from './Dropdown';
|
||||
export default Dropdown;
|
||||
|
|
@ -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,37 @@
|
|||
import { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import Star from './Star';
|
||||
|
||||
const Rating = ({ value, max = 5, onChange, selected, showHover }) => {
|
||||
const [hoveredIndex, setHoveredIndex] = useState(null);
|
||||
|
||||
const readOnlyMode = !onChange;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 group star-rating">
|
||||
{Array.from({ length: max }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
tabIndex={readOnlyMode ? -1 : 0}
|
||||
onMouseEnter={() => !readOnlyMode && setHoveredIndex(index)}
|
||||
onMouseLeave={() => !readOnlyMode && setHoveredIndex(null)}
|
||||
className={clsx(
|
||||
!readOnlyMode && 'cursor-pointer',
|
||||
selected ? 'text-yellow-500' : 'text-yellow-400'
|
||||
)}
|
||||
onClick={() => onChange?.(index + 1)}>
|
||||
<Star
|
||||
filled={
|
||||
hoveredIndex != null ? index <= hoveredIndex : value >= index + 1
|
||||
}
|
||||
halfFilled={value < index + 1 && value > index}
|
||||
className={clsx(showHover && 'group-hover:stroke-indigo-200')}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Rating;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
const Star = ({ filled, halfFilled, className }) => {
|
||||
return filled ? (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
className={clsx('stroke-current fill-current', className)}
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
className="star-icon"
|
||||
d="M9 0.80198L11.0661 5.76946C11.2101 6.11569 11.5357 6.35226 11.9095 6.38223L17.2723 6.81216L13.1864 10.3122C12.9016 10.5561 12.7773 10.9389 12.8643 11.3037L14.1126 16.5368L9.52125 13.7325C9.20124 13.537 8.79876 13.537 8.47874 13.7325L3.88743 16.5368L5.13574 11.3037C5.22275 10.9389 5.09838 10.5561 4.81359 10.3122L0.727691 6.81216L6.0905 6.38223C6.46429 6.35226 6.7899 6.11569 6.93391 5.76946L9 0.80198Z"
|
||||
/>
|
||||
</svg>
|
||||
) : halfFilled ? (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_3052_704)">
|
||||
<path
|
||||
d="M9.53834 1.60996C9.70914 1.19932 10.2909 1.19932 10.4617 1.60996L12.5278 6.57744C12.5998 6.75056 12.7626 6.86885 12.9495 6.88383L18.3123 7.31376C18.7556 7.3493 18.9354 7.90256 18.5976 8.19189L14.5117 11.6919C14.3693 11.8139 14.3071 12.0053 14.3506 12.1876L15.5989 17.4208C15.7021 17.8534 15.2315 18.1954 14.8519 17.9635L10.2606 15.1592C10.1006 15.0615 9.89938 15.0615 9.73937 15.1592L5.14806 17.9635C4.76851 18.1954 4.29788 17.8534 4.40108 17.4208L5.64939 12.1876C5.69289 12.0053 5.6307 11.8139 5.48831 11.6919L1.40241 8.19189C1.06464 7.90256 1.24441 7.3493 1.68773 7.31376L7.05054 6.88383C7.23744 6.86885 7.40024 6.75056 7.47225 6.57744L9.53834 1.60996Z"
|
||||
fill="#E5E7EB"
|
||||
/>
|
||||
<g clipPath="url(#clip1_3052_704)">
|
||||
<path
|
||||
d="M9.53834 1.60996C9.70914 1.19932 10.2909 1.19932 10.4617 1.60996L12.5278 6.57744C12.5998 6.75056 12.7626 6.86885 12.9495 6.88383L18.3123 7.31376C18.7556 7.3493 18.9354 7.90256 18.5976 8.19189L14.5117 11.6919C14.3693 11.8139 14.3071 12.0053 14.3506 12.1876L15.5989 17.4208C15.7021 17.8534 15.2315 18.1954 14.8519 17.9635L10.2606 15.1592C10.1006 15.0615 9.89938 15.0615 9.73937 15.1592L5.14806 17.9635C4.76851 18.1954 4.29788 17.8534 4.40108 17.4208L5.64939 12.1876C5.69289 12.0053 5.6307 11.8139 5.48831 11.6919L1.40241 8.19189C1.06464 7.90256 1.24441 7.3493 1.68773 7.31376L7.05054 6.88383C7.23744 6.86885 7.40024 6.75056 7.47225 6.57744L9.53834 1.60996Z"
|
||||
fill="#FACC15"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3052_704">
|
||||
<rect width="20" height="20" fill="white" />
|
||||
</clipPath>
|
||||
<clipPath id="clip1_3052_704">
|
||||
<rect width="10" height="20" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
className={clsx('stroke-gray-200 fill-gray-200', className)}
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 0.80198L11.0661 5.76946C11.2101 6.11569 11.5357 6.35226 11.9095 6.38223L17.2723 6.81216L13.1864 10.3122C12.9016 10.5561 12.7773 10.9389 12.8643 11.3037L14.1126 16.5368L9.52125 13.7325C9.20124 13.537 8.79876 13.537 8.47874 13.7325L3.88743 16.5368L5.13574 11.3037C5.22275 10.9389 5.09838 10.5561 4.81359 10.3122L0.727691 6.81216L6.0905 6.38223C6.46429 6.35226 6.7899 6.11569 6.93391 5.76946L9 0.80198Z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Star;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import StarRating from './Rating';
|
||||
|
||||
export default StarRating;
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { createPortal } from 'react-dom';
|
||||
import clsx from 'clsx';
|
||||
import { RiCloseLine } from 'react-icons/ri';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const SlideOut = ({
|
||||
children,
|
||||
isShown,
|
||||
trigger,
|
||||
title,
|
||||
onClose,
|
||||
className,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (isShown) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'auto';
|
||||
};
|
||||
}, [isShown]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
|
||||
{isShown &&
|
||||
createPortal(
|
||||
<div
|
||||
className={clsx(
|
||||
'fixed inset-0 z-modal lg:hidden',
|
||||
'bg-neutral-950 bg-opacity-70',
|
||||
'flex items-center justify-center'
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div
|
||||
id="slideout"
|
||||
className={clsx(
|
||||
'fixed inset-0 z-fixed bg-white max-w-[360px]',
|
||||
'animate-slideout',
|
||||
'overflow-auto',
|
||||
className
|
||||
)}>
|
||||
<div
|
||||
className={clsx(
|
||||
'z-sticky sticky top-0 bg-white p-6',
|
||||
'flex flex-col gap-6'
|
||||
)}>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center',
|
||||
!!title ? 'justify-between' : 'justify-end'
|
||||
)}>
|
||||
{title}
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close sideout"
|
||||
type="button"
|
||||
className={clsx(
|
||||
'text-neutral-600 rounded',
|
||||
'focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]'
|
||||
)}>
|
||||
<RiCloseLine className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-[1px] bg-neutral-200 w-full" />
|
||||
</div>
|
||||
<div className="px-6 pb-6">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SlideOut;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import SlideOut from './SlideOut';
|
||||
|
||||
export default SlideOut;
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
const Tabs = ({ label, tabs, value, onSelect }) => {
|
||||
return (
|
||||
<div className="isolate w-full overflow-x-auto overflow-y-hidden">
|
||||
<div
|
||||
className={clsx('flex items-center', ['border-b border-neutral-300'])}>
|
||||
<nav aria-label={label} className={clsx('flex grow gap-6')}>
|
||||
{tabs.map(tabItem => {
|
||||
const { label: tabItemLabel, value: tabItemValue } = tabItem;
|
||||
const isSelected = tabItemValue === value;
|
||||
const commonProps = {
|
||||
children: (
|
||||
<span
|
||||
className={clsx(
|
||||
'flex items-center transition-all',
|
||||
'font-medium',
|
||||
isSelected ? 'text-indigo-700' : 'text-neutral-600'
|
||||
)}>
|
||||
{tabItemLabel}
|
||||
</span>
|
||||
),
|
||||
className: clsx(
|
||||
'px-2 pb-[11px] whitespace-nowrap z-10 transition',
|
||||
isSelected ? '-mb-px' : '',
|
||||
isSelected && 'border border-x-0 border-t-0 border-b-indigo-600'
|
||||
),
|
||||
onClick: () => onSelect?.(tabItemValue),
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
key={String(tabItemValue)}
|
||||
type="button"
|
||||
{...commonProps}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tabs;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import Tabs from './Tabs';
|
||||
|
||||
export default Tabs;
|
||||
|
|
@ -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,41 @@
|
|||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
|
||||
const Toast = ({ type, message }) => {
|
||||
const badge = (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center',
|
||||
'px-2.5 py-0.5',
|
||||
'h-6',
|
||||
'bg-white',
|
||||
'shadow',
|
||||
'rounded-full',
|
||||
'text-sm',
|
||||
type === 'error' && 'text-red-800',
|
||||
type === 'success' && 'text-green-700'
|
||||
)}>
|
||||
{type === 'error' ? 'Error' : 'Success'}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx('z-toast fixed inset-x-0 top-10')}>
|
||||
<div
|
||||
className={clsx(
|
||||
'mx-4 md:mx-auto md:w-max',
|
||||
'flex items-center gap-3',
|
||||
'p-1 pr-2.5',
|
||||
'rounded-full',
|
||||
'text-sm font-medium',
|
||||
type === 'success' && 'bg-green-50 text-green-700',
|
||||
type === 'error' && 'bg-red-50 text-red-600'
|
||||
)}>
|
||||
{badge}
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import Toast from './Toast';
|
||||
|
||||
export default Toast;
|
||||
|
|
@ -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,156 @@
|
|||
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 COLLECTIONS_OPTIONS = {
|
||||
title: 'Collections',
|
||||
key: 'collection',
|
||||
items: [
|
||||
{
|
||||
name: 'Latest arrivals',
|
||||
value: 'latest',
|
||||
},
|
||||
{
|
||||
name: 'Urban Oasis',
|
||||
value: 'urban',
|
||||
},
|
||||
{
|
||||
name: 'Cozy Comfort',
|
||||
value: 'cozy',
|
||||
},
|
||||
{
|
||||
name: 'Fresh Fusion',
|
||||
value: 'fresh',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const CATEGORY_OPTIONS = {
|
||||
title: 'Category',
|
||||
key: 'category',
|
||||
items: [
|
||||
{
|
||||
name: 'Unisex',
|
||||
value: 'unisex',
|
||||
},
|
||||
{
|
||||
name: 'Women',
|
||||
value: 'women',
|
||||
},
|
||||
{
|
||||
name: 'Men',
|
||||
value: 'men',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const COLORS_OPTIONS = {
|
||||
title: 'Colors',
|
||||
key: 'color',
|
||||
items: [
|
||||
{
|
||||
color: COLORS.white.value,
|
||||
value: 'white',
|
||||
},
|
||||
{
|
||||
color: COLORS.black.value,
|
||||
value: 'black',
|
||||
},
|
||||
{
|
||||
color: COLORS.red.value,
|
||||
value: 'red',
|
||||
},
|
||||
{
|
||||
color: COLORS.orange.value,
|
||||
value: 'orange',
|
||||
},
|
||||
{
|
||||
color: COLORS.yellow.value,
|
||||
value: 'yellow',
|
||||
},
|
||||
{
|
||||
color: COLORS.green.value,
|
||||
value: 'green',
|
||||
},
|
||||
{
|
||||
color: COLORS.blue.value,
|
||||
value: 'blue',
|
||||
},
|
||||
{
|
||||
color: COLORS.brown.value,
|
||||
value: 'brown',
|
||||
},
|
||||
{
|
||||
color: COLORS.beige.value,
|
||||
value: 'beige',
|
||||
},
|
||||
{
|
||||
color: COLORS.pink.value,
|
||||
value: 'pink',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const RATING_OPTIONS = {
|
||||
title: 'Rating',
|
||||
key: 'rating',
|
||||
items: [
|
||||
{
|
||||
value: 5,
|
||||
name: '5 star rating',
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
name: '4 star rating',
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
name: '3 star rating',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
name: '2 star rating',
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
name: '1 star rating',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const SORT_OPTIONS = [
|
||||
{
|
||||
name: 'Newest',
|
||||
value: 'created',
|
||||
direction: 'desc',
|
||||
},
|
||||
{
|
||||
name: 'Best rating',
|
||||
value: 'rating',
|
||||
direction: 'desc',
|
||||
},
|
||||
{
|
||||
name: 'Most popular',
|
||||
value: 'popularity',
|
||||
direction: 'desc',
|
||||
},
|
||||
{
|
||||
name: 'Price: Low to high',
|
||||
value: 'price',
|
||||
direction: 'asc',
|
||||
},
|
||||
{
|
||||
name: 'Price: High to low',
|
||||
value: 'price',
|
||||
direction: 'desc',
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import {
|
||||
createContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useContext,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
|
||||
const CartContext = createContext();
|
||||
|
||||
export const useCartContext = () => useContext(CartContext);
|
||||
|
||||
const CartContextProvider = ({ children }) => {
|
||||
const [cartItems, setCartItems] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Retrieve cart from localStorage
|
||||
const storedCart = JSON.parse(localStorage.getItem('cart')) || [];
|
||||
setCartItems(storedCart);
|
||||
}, []);
|
||||
|
||||
const addToCart = useCallback(
|
||||
item => {
|
||||
const existingItem = cartItems.find(
|
||||
cartItem =>
|
||||
cartItem.id === item.id &&
|
||||
cartItem.color === item.color &&
|
||||
cartItem.size === item.size
|
||||
);
|
||||
|
||||
let updatedCart;
|
||||
if (existingItem) {
|
||||
updatedCart = cartItems.map(cartItem =>
|
||||
cartItem.id === item.id &&
|
||||
cartItem.color === item.color &&
|
||||
cartItem.size === item.size
|
||||
? { ...cartItem, quantity: item.quantity }
|
||||
: cartItem
|
||||
);
|
||||
} else {
|
||||
updatedCart = [...cartItems, item];
|
||||
}
|
||||
|
||||
setCartItems(updatedCart);
|
||||
localStorage.setItem('cart', JSON.stringify(updatedCart));
|
||||
},
|
||||
[cartItems]
|
||||
);
|
||||
|
||||
const removeFromCart = useCallback(
|
||||
(itemId, color, size) => {
|
||||
const updatedCart = cartItems.filter(
|
||||
item =>
|
||||
!(item.id === itemId && item.color === color && item.size === size)
|
||||
);
|
||||
setCartItems(updatedCart);
|
||||
localStorage.setItem('cart', JSON.stringify(updatedCart));
|
||||
},
|
||||
[cartItems]
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ cartItems, addToCart, removeFromCart }),
|
||||
[cartItems, addToCart, removeFromCart]
|
||||
);
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
};
|
||||
|
||||
export default CartContextProvider;
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
const ToastContext = createContext({
|
||||
toast: {
|
||||
show: false,
|
||||
type: '',
|
||||
message: '',
|
||||
},
|
||||
showToast: () => {},
|
||||
});
|
||||
|
||||
export const useToast = () => {
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const error = message => showToast('error', message);
|
||||
const success = message => showToast('success', message);
|
||||
|
||||
return { error, success };
|
||||
};
|
||||
|
||||
export const useToastContext = () => useContext(ToastContext);
|
||||
|
||||
const ToastContextProvider = ({ children }) => {
|
||||
const [toast, setToast] = useState({
|
||||
show: false,
|
||||
type: '',
|
||||
message: '',
|
||||
});
|
||||
|
||||
const showToast = useCallback((type, message) => {
|
||||
setToast({
|
||||
show: true,
|
||||
type,
|
||||
message,
|
||||
});
|
||||
setTimeout(() => {
|
||||
setToast({
|
||||
show: false,
|
||||
type: '',
|
||||
message: '',
|
||||
});
|
||||
}, 10000);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => {
|
||||
return {
|
||||
toast,
|
||||
showToast,
|
||||
};
|
||||
}, [toast, showToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>{children}</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastContextProvider;
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
@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-sticky {
|
||||
z-index: 1020;
|
||||
}
|
||||
.z-fixed {
|
||||
z-index: 1030;
|
||||
}
|
||||
.z-dropdown {
|
||||
z-index: 1000;
|
||||
}
|
||||
.z-toast {
|
||||
z-index: 1090;
|
||||
}
|
||||
.z-modal {
|
||||
z-index: 1055;
|
||||
}
|
||||
|
||||
/* Custom animations and keyframes */
|
||||
@keyframes slideout {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slideout {
|
||||
animation: slideout 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* 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,58 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import Button from 'src/components/ui/Button';
|
||||
import ProductGridSection from 'src/components/ProductGridSection';
|
||||
|
||||
const LatestArrivalsPage = () => {
|
||||
const [products, setProducts] = useState([]);
|
||||
const [isProductsLoading, setIsProductsLoading] = useState(true);
|
||||
|
||||
const getLatestArrivalProducts = async () => {
|
||||
setIsProductsLoading(true);
|
||||
|
||||
const data = await fetch(
|
||||
`https://www.greatfrontend.com/api/projects/challenges/e-commerce/products?collection=latest`
|
||||
);
|
||||
const result = await data.json();
|
||||
|
||||
if (!result.error) {
|
||||
setProducts(result.data);
|
||||
}
|
||||
setIsProductsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getLatestArrivalProducts();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'px-4 py-12 md:py-[72px] lg:px-24 lg:py-[104px]',
|
||||
'flex flex-col gap-8',
|
||||
'h-full'
|
||||
)}>
|
||||
<div className="flex justify-between items-center md:items-start">
|
||||
<div className="font-semibold text-2xl md:text-3xl">
|
||||
Latest Arrivals
|
||||
</div>
|
||||
<Button
|
||||
label="View all"
|
||||
variant="secondary"
|
||||
href="/products?collectionId=latest"
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
{isProductsLoading ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<ProductGridSection products={products} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LatestArrivalsPage;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import LatestArrivalsPage from './LatestArrivalsPage';
|
||||
|
||||
export default LatestArrivalsPage;
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import ProductSpecificationSection from 'src/components/ProductSpecificationSection';
|
||||
|
||||
import ProductCollectionSection from './components/ProductCollectionSection';
|
||||
import ProductDetailSection from './components/ProductDetailSection';
|
||||
import ProductDetailsContextProvider from './components/ProductDetailsContext';
|
||||
|
||||
const ProductDetailPage = () => {
|
||||
return (
|
||||
<>
|
||||
<ProductDetailsContextProvider>
|
||||
<ProductDetailSection />
|
||||
</ProductDetailsContextProvider>
|
||||
<ProductSpecificationSection />
|
||||
<ProductCollectionSection />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailPage;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import ColorSwatches from 'src/components/ui/ColorSwatches';
|
||||
import { useProductDetailsContext } from './ProductDetailsContext';
|
||||
import { COLORS } from 'src/constants';
|
||||
import { getUnavailableColors } from '../utils';
|
||||
|
||||
const AvailableColors = () => {
|
||||
const { selectedColor, setSelectedColor, product } =
|
||||
useProductDetailsContext();
|
||||
const { colors } = product;
|
||||
const unavailableColors = useMemo(
|
||||
() => getUnavailableColors(product),
|
||||
[product]
|
||||
);
|
||||
|
||||
return (
|
||||
<fieldset aria-label="Choose a color">
|
||||
<legend className="text-sm text-neutral-500">Available Colors</legend>
|
||||
<div className="flex gap-4 flex-wrap mt-4">
|
||||
{colors.map(color => (
|
||||
<ColorSwatches
|
||||
key={color}
|
||||
color={COLORS[color].value}
|
||||
outOfStock={unavailableColors.includes(color)}
|
||||
selected={selectedColor === color}
|
||||
onClick={() => setSelectedColor(color)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvailableColors;
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import { useProductDetailsContext } from './ProductDetailsContext';
|
||||
import { getUnavailableSizes } from '../utils';
|
||||
|
||||
const SIZE_MAP = {
|
||||
xs: 'XS',
|
||||
sm: 'S',
|
||||
md: 'M',
|
||||
lg: 'L',
|
||||
xl: 'XL',
|
||||
};
|
||||
|
||||
const AvailableSizes = () => {
|
||||
const { selectedSize, setSelectedSize, selectedColor, product } =
|
||||
useProductDetailsContext();
|
||||
const { sizes } = product;
|
||||
const unavailableSizes = getUnavailableSizes({
|
||||
product,
|
||||
color: selectedColor,
|
||||
});
|
||||
|
||||
return (
|
||||
<fieldset aria-label="Choose a size">
|
||||
<legend className="text-sm text-neutral-500">Available Sizes</legend>
|
||||
|
||||
<div className={clsx('mt-4', 'flex gap-4 flex-wrap')}>
|
||||
{sizes.map(size => {
|
||||
const outOfStock = unavailableSizes.includes(size);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={size}
|
||||
aria-label={size}
|
||||
className={clsx(
|
||||
outOfStock ? 'pointer-events-none' : 'cursor-pointer'
|
||||
)}>
|
||||
<input
|
||||
type="radio"
|
||||
name="size-choice"
|
||||
value={size}
|
||||
className="sr-only"
|
||||
disabled={outOfStock}
|
||||
tabIndex={-1}
|
||||
aria-checked={selectedSize === size}
|
||||
onChange={() => setSelectedSize(size)}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
tabIndex={selectedSize === size || outOfStock ? -1 : 0}
|
||||
className={clsx(
|
||||
'w-16 h-12',
|
||||
'flex justify-center items-center gap-1.5',
|
||||
'px-5 py-3',
|
||||
'uppercase font-medium',
|
||||
'rounded border',
|
||||
'focus:outline-none',
|
||||
outOfStock
|
||||
? [
|
||||
'text-neutral-400',
|
||||
'pointer-events-none',
|
||||
'bg-neutral-100',
|
||||
]
|
||||
: [
|
||||
'text-neutral-900',
|
||||
'cursor-pointer',
|
||||
'bg-white focus:bg-neutral-50 hover:bg-neutral-50',
|
||||
],
|
||||
selectedSize === size
|
||||
? 'border-indigo-600'
|
||||
: 'border-neutral-200',
|
||||
outOfStock && selectedSize !== size && 'border-none'
|
||||
)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
setSelectedSize(size);
|
||||
}
|
||||
}}>
|
||||
{SIZE_MAP[size] ? SIZE_MAP[size] : size}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvailableSizes;
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from 'src/components/ui/Accordion';
|
||||
|
||||
import { useProductDetailsContext } from './ProductDetailsContext';
|
||||
|
||||
const InfoSection = () => {
|
||||
const { product } = useProductDetailsContext();
|
||||
const { info } = product;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="product-faq" className="mt-10">
|
||||
<Accordion>
|
||||
{info.map(item => (
|
||||
<AccordionItem key={item.title} id={item.title}>
|
||||
<AccordionTrigger>{item.title}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ul className="list-disc ml-4 pl-2 text-neutral-600">
|
||||
{item.description.map(descItem => (
|
||||
<li key={descItem}>{descItem}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfoSection;
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import clsx from 'clsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ProductGridSection from 'src/components/ProductGridSection';
|
||||
|
||||
const ProductCollectionSection = () => {
|
||||
const [collectionProducts, setCollectionsProducts] = useState([]);
|
||||
const [isCollectionProductsLoading, setIsCollectionProductsLoading] =
|
||||
useState(true);
|
||||
|
||||
const getCollectionsProducts = async () => {
|
||||
setIsCollectionProductsLoading(true);
|
||||
|
||||
const data = await fetch(
|
||||
`https://www.greatfrontend.com/api/projects/challenges/e-commerce/products?collection=latest&per_page=4`
|
||||
);
|
||||
const result = await data.json();
|
||||
|
||||
if (!result.error) {
|
||||
setCollectionsProducts(result.data);
|
||||
}
|
||||
setIsCollectionProductsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getCollectionsProducts();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={clsx('px-4 py-12 md:py-16 lg:p-24', 'flex flex-col gap-8')}>
|
||||
<span className="font-semibold text-2xl md:text-3xl">
|
||||
In this collection
|
||||
</span>
|
||||
{isCollectionProductsLoading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<ProductGridSection products={collectionProducts} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCollectionSection;
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import ProductImages from './ProductImages';
|
||||
import ProductMetadata from './ProductMetadata';
|
||||
|
||||
import { useProductDetailsContext } from './ProductDetailsContext';
|
||||
|
||||
const ProductDetail = () => {
|
||||
const { isProductLoading, product } = useProductDetailsContext();
|
||||
|
||||
return (
|
||||
<section
|
||||
className={clsx(
|
||||
'px-4 py-12 md:py-16 lg:p-24',
|
||||
'grid grid-cols-4 md:grid-cols-6 lg:grid-cols-12 gap-x-4 md:gap-x-8 gap-y-12'
|
||||
)}>
|
||||
{isProductLoading || !product ? (
|
||||
<div
|
||||
className={clsx(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
'col-span-4 md:col-span-6 lg:col-span-12'
|
||||
)}>
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="col-span-4 md:col-span-6">
|
||||
<ProductImages />
|
||||
</div>
|
||||
<div className="col-span-4 md:col-span-6">
|
||||
<ProductMetadata />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetail;
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { getUnavailableSizes } from '../utils';
|
||||
|
||||
const ProductDetailsContext = createContext();
|
||||
|
||||
export const useProductDetailsContext = () => useContext(ProductDetailsContext);
|
||||
|
||||
const ProductDetailsContextProvider = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
const { productId } = useParams();
|
||||
const [product, setProduct] = useState(null);
|
||||
const [isProductLoading, setIsProductLoading] = useState(false);
|
||||
const [selectedColor, setSelectedColor] = useState(null);
|
||||
const [selectedSize, setSelectedSize] = useState(null);
|
||||
const [itemQuantity, setItemQuantity] = useState(1);
|
||||
|
||||
const getProduct = useCallback(async () => {
|
||||
setIsProductLoading(true);
|
||||
const data = await fetch(
|
||||
`https://www.greatfrontend.com/api/projects/challenges/e-commerce/products/${productId}`
|
||||
);
|
||||
const result = await data.json();
|
||||
|
||||
if (!result.error) {
|
||||
setProduct(result);
|
||||
setSelectedColor(result.colors[0]);
|
||||
} else {
|
||||
return navigate('/not-found');
|
||||
}
|
||||
setIsProductLoading(false);
|
||||
}, [productId, navigate]);
|
||||
|
||||
const decrementQuantity = useCallback(() => {
|
||||
setItemQuantity(prev => (prev > 1 ? prev - 1 : 1));
|
||||
}, []);
|
||||
|
||||
const incrementQuantity = useCallback(() => {
|
||||
setItemQuantity(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getProduct();
|
||||
}, [getProduct]);
|
||||
|
||||
// Set first available size as the initial selected size
|
||||
useEffect(() => {
|
||||
if (!product || !selectedColor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unavailableSizes = getUnavailableSizes({
|
||||
product,
|
||||
color: selectedColor,
|
||||
});
|
||||
const availableSizes = [...product.sizes].filter(
|
||||
size => !unavailableSizes.includes(size)
|
||||
);
|
||||
if (availableSizes.length > 0) {
|
||||
setSelectedSize(availableSizes[0]);
|
||||
}
|
||||
}, [selectedColor, product]);
|
||||
|
||||
// Reset item quantity to 1 when we change color or size
|
||||
useEffect(() => {
|
||||
setItemQuantity(1);
|
||||
}, [selectedColor, selectedSize]);
|
||||
|
||||
const value = useMemo(() => {
|
||||
return {
|
||||
product,
|
||||
isProductLoading,
|
||||
selectedColor,
|
||||
selectedSize,
|
||||
itemQuantity,
|
||||
setSelectedColor,
|
||||
setSelectedSize,
|
||||
incrementQuantity,
|
||||
decrementQuantity,
|
||||
};
|
||||
}, [
|
||||
product,
|
||||
isProductLoading,
|
||||
selectedColor,
|
||||
setSelectedColor,
|
||||
selectedSize,
|
||||
setSelectedSize,
|
||||
itemQuantity,
|
||||
incrementQuantity,
|
||||
decrementQuantity,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ProductDetailsContext.Provider value={value}>
|
||||
{children}
|
||||
</ProductDetailsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailsContextProvider;
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { useProductDetailsContext } from './ProductDetailsContext';
|
||||
import { getSelectedColorImages } from '../utils';
|
||||
|
||||
const ProductImages = () => {
|
||||
const { product, selectedColor } = useProductDetailsContext();
|
||||
const [selectedPreview, setSelectedPreview] = useState(0);
|
||||
|
||||
const images = getSelectedColorImages({ product, color: selectedColor });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<img
|
||||
src={product.images[selectedPreview].image_url}
|
||||
alt="Selected preview"
|
||||
loading="lazy"
|
||||
className="h-[400px] md:h-[800px] w-full object-cover rounded-lg"
|
||||
/>
|
||||
<div className="flex gap-4 overflow-x-auto">
|
||||
{images.map((image, index) => (
|
||||
<img
|
||||
key={image.image_url + index}
|
||||
src={image.image_url}
|
||||
alt={`Preview ${index + 1}`}
|
||||
loading="lazy"
|
||||
onClick={() => setSelectedPreview(index)}
|
||||
className={clsx(
|
||||
'rounded-lg shrink-0 block',
|
||||
'w-20 h-[120px] md:h-[190px] md:w-[188px] lg:w-40 object-cover',
|
||||
'cursor-pointer',
|
||||
index === selectedPreview && 'border-[3px] border-indigo-600'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductImages;
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import { useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useMediaQuery } from 'usehooks-ts';
|
||||
|
||||
import Badge from 'src/components/ui/Badge';
|
||||
import Button from 'src/components/ui/Button';
|
||||
import Rating from 'src/components/ui/Rating';
|
||||
import AvailableColors from './AvailableColors';
|
||||
import AvailableSizes from './AvailableSizes';
|
||||
import ProductQuantity from './ProductQuantity';
|
||||
import InfoSection from './InfoSection';
|
||||
|
||||
import { useProductDetailsContext } from './ProductDetailsContext';
|
||||
import { useCartContext } from 'src/context/CartContext';
|
||||
import { getInventoryData } from '../utils';
|
||||
|
||||
const ProductMetadata = () => {
|
||||
const isMobileAndBelow = useMediaQuery('(max-width: 767px)');
|
||||
const { product, selectedColor, selectedSize, itemQuantity } =
|
||||
useProductDetailsContext();
|
||||
const { addToCart } = useCartContext();
|
||||
|
||||
const { name, description, reviews, rating } = product;
|
||||
|
||||
const inventoryData = useMemo(
|
||||
() =>
|
||||
getInventoryData({ product, color: selectedColor, size: selectedSize }),
|
||||
[selectedColor, selectedSize, product]
|
||||
);
|
||||
const { discount_percentage, list_price, sale_price, stock } = inventoryData;
|
||||
|
||||
const roundedRating = Math.round(rating * 10) / 10;
|
||||
const hasDiscount = !!discount_percentage;
|
||||
|
||||
const onAddToCart = e => {
|
||||
e.preventDefault();
|
||||
|
||||
const item = {
|
||||
id: product.product_id,
|
||||
quantity: itemQuantity,
|
||||
color: selectedColor,
|
||||
size: selectedSize,
|
||||
};
|
||||
addToCart(item);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section
|
||||
className={clsx('flex flex-col gap-8')}
|
||||
aria-labelledby="information-heading">
|
||||
<div className="flex flex-col items-start">
|
||||
<h1 className="text-3xl md:text-5xl font-semibold">{name}</h1>
|
||||
<div className="mt-5">
|
||||
<div className="inline-flex gap-2 items-end">
|
||||
<span className="text-3xl font-medium text-neutral-600">
|
||||
${hasDiscount ? sale_price : list_price}
|
||||
</span>
|
||||
{hasDiscount && (
|
||||
<span className="text-lg font-medium text-neutral-400 line-through">
|
||||
${list_price}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasDiscount && (
|
||||
<div className="mt-2">
|
||||
<Badge
|
||||
label={`${discount_percentage}% OFF`}
|
||||
size="lg"
|
||||
variant="warning"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={clsx('flex items-center gap-2 flex-wrap', 'mt-3')}>
|
||||
<div className="text-xl text-neutral-900">{roundedRating ?? 0}</div>
|
||||
<Rating value={roundedRating ?? 0} />
|
||||
{reviews > 0 ? (
|
||||
<Button
|
||||
label={`See all ${reviews} reviews`}
|
||||
href="#"
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex gap-[2px]">
|
||||
<span className="text-sm text-neutral-900">
|
||||
No reviews yet.
|
||||
</span>
|
||||
<Button
|
||||
label="Be the first."
|
||||
href="#"
|
||||
variant="link"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-neutral-600">{description}</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="product-options" className="mt-8">
|
||||
<form className="flex flex-col gap-8" onSubmit={onAddToCart}>
|
||||
<AvailableColors />
|
||||
<AvailableSizes />
|
||||
<ProductQuantity availableStock={stock} />
|
||||
|
||||
{/* Out of stock message */}
|
||||
{stock === 0 && (
|
||||
<div className="text-xl font-semibold text-neutral-900">
|
||||
Sorry, this item is out of stock
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
label="Add to Cart"
|
||||
size={isMobileAndBelow ? 'xl' : '2xl'}
|
||||
isDisabled={itemQuantity === 0 || stock === 0}
|
||||
/>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<InfoSection />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductMetadata;
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import CartControl from 'src/components/CartControl';
|
||||
import { useProductDetailsContext } from './ProductDetailsContext';
|
||||
|
||||
const ProductQuantity = ({ availableStock }) => {
|
||||
const { itemQuantity, incrementQuantity, decrementQuantity } =
|
||||
useProductDetailsContext();
|
||||
|
||||
return (
|
||||
<fieldset aria-label="Choose a color">
|
||||
<legend className="text-sm text-neutral-500">Quantity</legend>
|
||||
<div className="mt-4">
|
||||
<CartControl
|
||||
quantity={itemQuantity}
|
||||
decrement={decrementQuantity}
|
||||
increment={incrementQuantity}
|
||||
availableStock={availableStock}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductQuantity;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import ProductDetailPage from './ProductDetailPage';
|
||||
|
||||
export default ProductDetailPage;
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
export const getUnavailableColors = product => {
|
||||
const colorsInStock = new Set();
|
||||
const allColors = new Set(product.colors);
|
||||
|
||||
product.inventory.forEach(item => {
|
||||
if (item.stock > 0) {
|
||||
colorsInStock.add(item.color);
|
||||
}
|
||||
});
|
||||
|
||||
const unavailableColors = [...allColors].filter(
|
||||
color => !colorsInStock.has(color)
|
||||
);
|
||||
|
||||
return unavailableColors;
|
||||
};
|
||||
|
||||
export const getUnavailableSizes = ({ product, color }) => {
|
||||
const sizesInStock = new Set();
|
||||
const allSizes = new Set(product.sizes);
|
||||
|
||||
product.inventory.forEach(item => {
|
||||
if (item.stock > 0 && item.color === color) {
|
||||
sizesInStock.add(item.size);
|
||||
}
|
||||
});
|
||||
|
||||
const unavailableSizes = [...allSizes].filter(
|
||||
size => !sizesInStock.has(size)
|
||||
);
|
||||
|
||||
return unavailableSizes;
|
||||
};
|
||||
|
||||
export const getInventoryData = ({ product, color, size }) => {
|
||||
let data = {};
|
||||
product.inventory.forEach(item => {
|
||||
if (item.size === size && item.color === color) {
|
||||
data = item;
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getSelectedColorImages = ({ product, color }) => {
|
||||
const images = product.images?.filter(image => image.color === color);
|
||||
return images;
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
import Filter from './components/Filter';
|
||||
import ProductListingContextProvider from './components/ProductListingContext';
|
||||
import ProductListingSection from './components/ProductListingSection';
|
||||
import SortByFilter from './components/SortByFilter';
|
||||
|
||||
const ProductListingPage = () => {
|
||||
return (
|
||||
<ProductListingContextProvider>
|
||||
<div
|
||||
className={clsx(
|
||||
'w-full',
|
||||
'px-4 py-12 md:py-[72px] lg:px-24 lg:py-[104px]',
|
||||
'grid grid-cols-4 md:grid-cols-6 lg:grid-cols-12 gap-x-4 md:gap-x-8 gap-y-8'
|
||||
)}>
|
||||
<div
|
||||
className={clsx(
|
||||
'col-span-4 md:col-span-6 lg:col-span-3 lg:pt-4 lg:pr-12',
|
||||
'flex justify-between'
|
||||
)}>
|
||||
<Filter />
|
||||
<div className="block lg:hidden">
|
||||
<SortByFilter />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'col-span-4 md:col-span-6 lg:col-span-9',
|
||||
'flex flex-col items-end gap-8'
|
||||
)}>
|
||||
<div className="hidden lg:block">
|
||||
<SortByFilter />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'w-full h-full',
|
||||
'grid grid-cols-4 md:grid-cols-6 lg:grid-cols-9 gap-8'
|
||||
)}>
|
||||
<ProductListingSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ProductListingContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductListingPage;
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import clsx from 'clsx';
|
||||
import { useState, useRef, createContext, useContext } from 'react';
|
||||
import { RiAddLine, RiSubtractLine } from 'react-icons/ri';
|
||||
|
||||
const AccordionItemContext = createContext();
|
||||
|
||||
const AccordionItem = ({ children, id }) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AccordionItemContext.Provider value={{ id, isOpen, setIsOpen }}>
|
||||
{children}
|
||||
</AccordionItemContext.Provider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccordionTrigger = ({ children }) => {
|
||||
const { id, isOpen, setIsOpen } = useContext(AccordionItemContext);
|
||||
const Icon = isOpen ? RiSubtractLine : RiAddLine;
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
'w-full',
|
||||
'flex gap-6 justify-between items-center',
|
||||
'focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]',
|
||||
'text-left text-neutral-900 font-medium'
|
||||
)}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={`accordion-content-${id}`}
|
||||
id={`accordion-header-${id}`}>
|
||||
<span>{children}</span>
|
||||
<Icon className="m-0.5 size-5 text-neutral-600" aria-hidden={true} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const AccordionContent = ({ children }) => {
|
||||
const contentRef = useRef(null);
|
||||
const { id, isOpen } = useContext(AccordionItemContext);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`accordion-content-${id}`}
|
||||
role="region"
|
||||
aria-labelledby={`accordion-header-${id}`}
|
||||
className={clsx(
|
||||
'overflow-hidden',
|
||||
'pr-12',
|
||||
isOpen && 'mt-6',
|
||||
'transition-max-height ease-in-out duration-300 transform origin-top',
|
||||
isOpen ? 'scale-y-100 opacity-100' : 'scale-y-0 opacity-0'
|
||||
)}
|
||||
style={{
|
||||
maxHeight: isOpen ? `${contentRef.current?.scrollHeight}px` : '0',
|
||||
}}
|
||||
ref={contentRef}>
|
||||
<div className="text-neutral-600">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Accordion = ({ children }) => {
|
||||
const hasMultipleItem = Array.isArray(children);
|
||||
return (
|
||||
<div className="w-full">
|
||||
{!hasMultipleItem
|
||||
? children
|
||||
: children.map((item, index) => (
|
||||
<div key={item.props.id}>
|
||||
{item}
|
||||
<div className="h-[1px] bg-neutral-200 my-6" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import * as Accordion from './Accordion';
|
||||
|
||||
export * from './Accordion';
|
||||
export default Accordion;
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import clsx from 'clsx';
|
||||
import { RiFilterLine } from 'react-icons/ri';
|
||||
import { useState } from 'react';
|
||||
|
||||
import CheckboxInput from 'src/components/ui/CheckboxInput';
|
||||
import ColorSwatches from 'src/components/ui/ColorSwatches';
|
||||
import SlideOut from 'src/components/ui/SlideOut';
|
||||
import Button from 'src/components/ui/Button';
|
||||
import Rating from 'src/components/ui/Rating/Rating';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from 'src/pages/ProductListing/components/Accordion';
|
||||
|
||||
import { useProductListingContext } from './ProductListingContext';
|
||||
import {
|
||||
CATEGORY_OPTIONS,
|
||||
COLLECTIONS_OPTIONS,
|
||||
COLORS_OPTIONS,
|
||||
RATING_OPTIONS,
|
||||
} from 'src/constants';
|
||||
|
||||
const Filter = () => {
|
||||
const {
|
||||
selectedCategory,
|
||||
selectedCollections,
|
||||
selectedColors,
|
||||
selectedRating,
|
||||
filterCount,
|
||||
onSelect,
|
||||
resetFilters,
|
||||
} = useProductListingContext();
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
|
||||
const filterItems = (
|
||||
<div className="flex flex-col items-center">
|
||||
<Accordion>
|
||||
<AccordionItem id={COLLECTIONS_OPTIONS.key}>
|
||||
<AccordionTrigger>{COLLECTIONS_OPTIONS.title}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-4 lg:gap-6">
|
||||
{COLLECTIONS_OPTIONS.items.map(({ name, value }) => (
|
||||
<CheckboxInput
|
||||
label={name}
|
||||
key={value}
|
||||
value={selectedCollections.has(value)}
|
||||
onChange={() => onSelect(COLLECTIONS_OPTIONS.key, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem id={CATEGORY_OPTIONS.key}>
|
||||
<AccordionTrigger>{CATEGORY_OPTIONS.title}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
{CATEGORY_OPTIONS.items.map(({ name, value }) => (
|
||||
<CheckboxInput
|
||||
label={name}
|
||||
key={value}
|
||||
value={selectedCategory.has(value)}
|
||||
onChange={() => onSelect(CATEGORY_OPTIONS.key, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem id={COLORS_OPTIONS.key}>
|
||||
<AccordionTrigger>{COLORS_OPTIONS.title}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{COLORS_OPTIONS.items.map(({ color, value }) => (
|
||||
<ColorSwatches
|
||||
key={value}
|
||||
type="checkbox"
|
||||
color={color}
|
||||
selected={selectedColors.has(value)}
|
||||
onClick={() => onSelect(COLORS_OPTIONS.key, value)}
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem id={RATING_OPTIONS.key}>
|
||||
<AccordionTrigger>{RATING_OPTIONS.title}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-4 lg:gap-6 items-start">
|
||||
{RATING_OPTIONS.items.map(({ value }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={clsx(
|
||||
'rounded',
|
||||
'focus:outline-none focus-visible:ring-2 focus:ring-offset-0 focus:ring-indigo-600/[.12]'
|
||||
)}
|
||||
onClick={() => onSelect(RATING_OPTIONS.key, value)}>
|
||||
<Rating
|
||||
value={value}
|
||||
selected={selectedRating.has(value)}
|
||||
showHover
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
{filterCount > 0 && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
resetFilters();
|
||||
setIsFilterOpen(false);
|
||||
}}
|
||||
label={`Clear All (${filterCount})`}
|
||||
variant="link"
|
||||
size="lg"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="hidden lg:block sticky top-10">{filterItems}</div>
|
||||
<div className="block lg:hidden">
|
||||
<SlideOut
|
||||
isShown={isFilterOpen}
|
||||
title={<span className="text-xl text-neutral-900">Filter</span>}
|
||||
onClose={() => setIsFilterOpen(false)}
|
||||
trigger={
|
||||
<Button
|
||||
label="Filter"
|
||||
variant="secondary"
|
||||
startIcon={RiFilterLine}
|
||||
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
||||
/>
|
||||
}>
|
||||
{filterItems}
|
||||
</SlideOut>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Filter;
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import useProductFilters from './hooks/useProductFilters';
|
||||
import {
|
||||
CATEGORY_OPTIONS,
|
||||
COLLECTIONS_OPTIONS,
|
||||
COLORS_OPTIONS,
|
||||
RATING_OPTIONS,
|
||||
} from 'src/constants';
|
||||
|
||||
const ProductListingContext = createContext();
|
||||
|
||||
export const useProductListingContext = () => useContext(ProductListingContext);
|
||||
|
||||
const ProductListingContextProvider = ({ children }) => {
|
||||
const [products, setProducts] = useState(null);
|
||||
const [isProductsLoading, setIsProductsLoading] = useState(true);
|
||||
const {
|
||||
selectedCollections,
|
||||
selectedCategory,
|
||||
selectedColors,
|
||||
selectedRating,
|
||||
selectedSort,
|
||||
filterCount,
|
||||
onSelect,
|
||||
resetFilters,
|
||||
onSortChange,
|
||||
} = useProductFilters();
|
||||
|
||||
const getProducts = useCallback(
|
||||
async ({ colors, collections, ratings, categories, sort }) => {
|
||||
setIsProductsLoading(true);
|
||||
|
||||
let queryString = '';
|
||||
if (
|
||||
colors.size > 0 ||
|
||||
collections.size > 0 ||
|
||||
ratings.size > 0 ||
|
||||
categories.size > 0
|
||||
) {
|
||||
queryString = [
|
||||
...Array.from(colors).map(
|
||||
color => `${COLORS_OPTIONS.key}=${encodeURIComponent(color)}`
|
||||
),
|
||||
...Array.from(collections).map(
|
||||
collection =>
|
||||
`${COLLECTIONS_OPTIONS.key}=${encodeURIComponent(collection)}`
|
||||
),
|
||||
...Array.from(ratings).map(
|
||||
rating => `${RATING_OPTIONS.key}=${encodeURIComponent(rating)}`
|
||||
),
|
||||
...Array.from(categories).map(
|
||||
category =>
|
||||
`${CATEGORY_OPTIONS.key}=${encodeURIComponent(category)}`
|
||||
),
|
||||
].join('&');
|
||||
}
|
||||
|
||||
queryString = `${queryString ? `${queryString}&` : ''}sort=${
|
||||
sort.value
|
||||
}&direction=${sort.direction}`;
|
||||
|
||||
const data = await fetch(
|
||||
`https://www.greatfrontend.com/api/projects/challenges/e-commerce/products${
|
||||
queryString ? `?${queryString}` : ''
|
||||
}`
|
||||
);
|
||||
const result = await data.json();
|
||||
|
||||
if (!result.error) {
|
||||
setProducts(result.data);
|
||||
}
|
||||
setIsProductsLoading(false);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
getProducts({
|
||||
colors: selectedColors,
|
||||
categories: selectedCategory,
|
||||
collections: selectedCollections,
|
||||
ratings: selectedRating,
|
||||
sort: selectedSort,
|
||||
});
|
||||
}, [
|
||||
getProducts,
|
||||
selectedColors,
|
||||
selectedCategory,
|
||||
selectedCollections,
|
||||
selectedRating,
|
||||
selectedSort,
|
||||
]);
|
||||
|
||||
const value = useMemo(() => {
|
||||
return {
|
||||
products,
|
||||
isProductsLoading,
|
||||
|
||||
selectedCollections,
|
||||
selectedCategory,
|
||||
selectedColors,
|
||||
selectedRating,
|
||||
selectedSort,
|
||||
filterCount,
|
||||
onSelect,
|
||||
resetFilters,
|
||||
onSortChange,
|
||||
};
|
||||
}, [
|
||||
products,
|
||||
isProductsLoading,
|
||||
|
||||
selectedCollections,
|
||||
selectedCategory,
|
||||
selectedColors,
|
||||
selectedRating,
|
||||
selectedSort,
|
||||
filterCount,
|
||||
onSelect,
|
||||
resetFilters,
|
||||
onSortChange,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ProductListingContext.Provider value={value}>
|
||||
{children}
|
||||
</ProductListingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductListingContextProvider;
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { RiTShirt2Line } from 'react-icons/ri';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import ProductCard from 'src/components/ProductCard';
|
||||
import Button from 'src/components/ui/Button';
|
||||
|
||||
import { useProductListingContext } from './ProductListingContext';
|
||||
|
||||
const ProductListingSection = () => {
|
||||
const { products, isProductsLoading, filterCount, resetFilters } =
|
||||
useProductListingContext();
|
||||
|
||||
if (isProductsLoading) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'col-span-4 md:col-span-6 lg:col-span-9',
|
||||
'flex justify-center'
|
||||
)}>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filterCount > 0 && products.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'w-full h-full',
|
||||
'col-span-4 md:col-span-6 lg:col-span-9',
|
||||
'flex items-center justify-center flex-col gap-5'
|
||||
)}>
|
||||
<div
|
||||
className={clsx(
|
||||
'size-12 bg-white rounded-full shadow',
|
||||
'flex items-center justify-center'
|
||||
)}>
|
||||
<RiTShirt2Line className="size-6 text-indigo-700" />
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col items-center gap-2',
|
||||
'text-neutral-900 text-center'
|
||||
)}>
|
||||
<span className="font-medium text-xl">Nothing found just yet</span>
|
||||
<span>
|
||||
Adjust your filters a bit, and let's see what we can find!
|
||||
</span>
|
||||
</div>
|
||||
<Button label="Reset filters" size="lg" onClick={resetFilters} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return products.map(product => (
|
||||
<div key={product.product_id} className={clsx('col-span-4 md:col-span-3')}>
|
||||
<ProductCard product={product} />
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export default ProductListingSection;
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { Dropdown, DropdownItem } from 'src/components/ui/Dropdown';
|
||||
|
||||
import { useProductListingContext } from './ProductListingContext';
|
||||
import { SORT_OPTIONS } from 'src/constants';
|
||||
|
||||
const SortByFilter = () => {
|
||||
const { onSortChange, selectedSort } = useProductListingContext();
|
||||
return (
|
||||
<Dropdown>
|
||||
{SORT_OPTIONS.map(option => (
|
||||
<DropdownItem
|
||||
key={option.value + option.direction}
|
||||
isSelected={
|
||||
option.value === selectedSort.value &&
|
||||
option.direction === selectedSort.direction
|
||||
}
|
||||
onSelect={() =>
|
||||
onSortChange({ value: option.value, direction: option.direction })
|
||||
}>
|
||||
{option.name}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default SortByFilter;
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
CATEGORY_OPTIONS,
|
||||
COLLECTIONS_OPTIONS,
|
||||
COLORS_OPTIONS,
|
||||
RATING_OPTIONS,
|
||||
} from 'src/constants';
|
||||
|
||||
export default function useProductFilters() {
|
||||
const { search } = useLocation();
|
||||
const isMounted = useRef(false);
|
||||
|
||||
const query = new URLSearchParams(search);
|
||||
const collectionId = query.get('collectionId');
|
||||
const categoryId = query.get('categoryId');
|
||||
|
||||
const [selectedCollections, setSelectedCollections] = useState(
|
||||
collectionId ? new Set().add(collectionId) : new Set(),
|
||||
);
|
||||
const [selectedCategory, setSelectedCategory] = useState(
|
||||
categoryId ? new Set().add(categoryId) : new Set(),
|
||||
);
|
||||
const [selectedColors, setSelectedColors] = useState(new Set());
|
||||
const [selectedRating, setSelectedRating] = useState(new Set());
|
||||
const [selectedSort, setSelectedSort] = useState({
|
||||
value: 'created',
|
||||
direction: 'desc',
|
||||
});
|
||||
|
||||
const onSelect = (type, value) => {
|
||||
let newSelectedItems;
|
||||
if (type === COLLECTIONS_OPTIONS.key) {
|
||||
newSelectedItems = new Set(selectedCollections);
|
||||
}
|
||||
if (type === CATEGORY_OPTIONS.key) {
|
||||
newSelectedItems = new Set(selectedCategory);
|
||||
}
|
||||
if (type === COLORS_OPTIONS.key) {
|
||||
newSelectedItems = new Set(selectedColors);
|
||||
}
|
||||
if (type === RATING_OPTIONS.key) {
|
||||
newSelectedItems = new Set(selectedRating);
|
||||
}
|
||||
|
||||
newSelectedItems.has(value)
|
||||
? newSelectedItems.delete(value)
|
||||
: newSelectedItems.add(value);
|
||||
|
||||
if (type === COLLECTIONS_OPTIONS.key) {
|
||||
setSelectedCollections(newSelectedItems);
|
||||
}
|
||||
if (type === CATEGORY_OPTIONS.key) {
|
||||
setSelectedCategory(newSelectedItems);
|
||||
}
|
||||
if (type === COLORS_OPTIONS.key) {
|
||||
setSelectedColors(newSelectedItems);
|
||||
}
|
||||
if (type === RATING_OPTIONS.key) {
|
||||
setSelectedRating(newSelectedItems);
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setSelectedCollections(new Set());
|
||||
setSelectedCategory(new Set());
|
||||
setSelectedColors(new Set());
|
||||
setSelectedRating(new Set());
|
||||
};
|
||||
|
||||
const filterCount =
|
||||
selectedCollections.size +
|
||||
selectedCategory.size +
|
||||
selectedColors.size +
|
||||
selectedRating.size;
|
||||
|
||||
useEffect(() => {
|
||||
// only run after mounted when the collectionId or categoryId query param change
|
||||
if (isMounted.current) {
|
||||
if (collectionId) {
|
||||
// Reset every filters when we query params is changed
|
||||
resetFilters();
|
||||
setSelectedCollections(new Set().add(collectionId));
|
||||
}
|
||||
if (categoryId) {
|
||||
// Reset every filters when we query params is changed
|
||||
resetFilters();
|
||||
setSelectedCategory(new Set().add(categoryId));
|
||||
}
|
||||
}
|
||||
isMounted.current = true;
|
||||
}, [collectionId, categoryId]);
|
||||
|
||||
return {
|
||||
selectedCollections,
|
||||
selectedCategory,
|
||||
selectedColors,
|
||||
selectedRating,
|
||||
selectedSort,
|
||||
filterCount,
|
||||
onSelect,
|
||||
resetFilters,
|
||||
onSortChange: setSelectedSort,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import ProductListingPage from './ProductListingPage';
|
||||
|
||||
export default ProductListingPage;
|
||||
Loading…
Reference in New Issue