[web] projects/challenge/solution: storefront page solution (#787)

This commit is contained in:
Nitesh Seram 2024-08-12 10:56:29 +05:30 committed by GitHub
parent e3db5e6068
commit f62c6c233e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
90 changed files with 4381 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@ -0,0 +1,85 @@
@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-modal {
z-index: 1055;
}
.z-toast {
z-index: 1090;
}
/* 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);
}
.shadow-input {
box-shadow:
0px 0px 0px 1px #444ce7,
0px 1px 2px rgba(16, 24, 40, 0.05),
0px 0px 0px 4px rgba(68, 76, 231, 0.12);
}
/* Custom background */
.bg-collection {
background: linear-gradient(
60deg,
rgba(0, 0, 0, 0.4) -9.37%,
rgba(0, 0, 0, 0.132) 100%
);
}
.bg-collection-hover {
background: linear-gradient(
360deg,
rgba(0, 0, 0, 0.6) -9.37%,
rgba(0, 0, 0, 0.198) 100%
);
}

View File

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

View File

@ -0,0 +1,19 @@
{
"name": "@gfe-challenges/storefront-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"
}
}

View File

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

View File

@ -0,0 +1,32 @@
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';
import StorefrontPage from './pages/Storefront';
function App() {
return (
<ToastContextProvider>
<CartContextProvider>
<Routes>
<Route path="/" element={<Layout />}>
<Route path="/" element={<StorefrontPage />} />
<Route path="/products" element={<ProductListingPage />} />
<Route path="/latest" element={<LatestArrivalsPage />} />
<Route
path="/products/:productId"
element={<ProductDetailPage />}
/>
</Route>
</Routes>
</CartContextProvider>
</ToastContextProvider>
);
}
export default App;

View File

@ -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;

View File

@ -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;

View File

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

View File

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

View File

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

View File

@ -0,0 +1,67 @@
import clsx from 'clsx';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import Link from 'src/components/ui/Link';
const variantClasses = {
primary: clsx('max-w-[594px] h-[580px]'),
secondary: clsx('max-w-[594px] h-[337px] md:h-[276px]'),
};
const CollectionCard = ({
imageUrl,
name,
description,
id,
variant = 'primary',
}) => {
const navigate = useNavigate();
const redirectUrl = `/products?collectionId=${id}`;
const handleKeyDown = useCallback(
event => {
if (event.key === 'Enter') {
navigate(redirectUrl);
}
},
[navigate, redirectUrl]
);
return (
<div
className={clsx(
'relative',
'group',
'rounded-lg overflow-hidden',
'focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]'
)}
onKeyDown={handleKeyDown}
tabIndex={0}>
<img
loading="lazy"
src={imageUrl}
alt={`${name}'s banner`}
className={clsx('object-cover w-full', variantClasses[variant])}
/>
<Link
tabIndex={-1}
to={redirectUrl}
variant="unstyled"
className="absolute inset-0 bg-collection hover:bg-collection-hover transition-all duration-300">
<div
className={clsx(
'absolute inset-x-4 bottom-4',
'flex flex-col',
'text-white'
)}>
<span className="text-sm">{name}</span>
<span className="font-medium text-lg">{description}</span>
</div>
</Link>
</div>
);
};
export default CollectionCard;

View File

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

View File

@ -0,0 +1,143 @@
import clsx from 'clsx';
import {
RiFacebookBoxLine,
RiGithubLine,
RiInstagramLine,
RiTwitterXLine,
RiYoutubeLine,
} from 'react-icons/ri';
import Link from '../ui/Link';
import NewsletterForm from './NewsletterForm';
import { CATEGORY_OPTIONS, COLLECTIONS_OPTIONS } from 'src/constants';
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">
Well 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">
{CATEGORY_OPTIONS.items.map((category) => (
<Link to={category.href} key={category.name} variant="gray">
{category.name}
</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">
{COLLECTIONS_OPTIONS.items.map((collection) => (
<Link to={collection.href} key={collection.name} variant="gray">
{collection.name}
</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">
&copy; {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;

View File

@ -0,0 +1,74 @@
import { useState } from 'react';
import Button from 'src/components/ui/Button';
import TextInput from 'src/components/ui/TextInput';
import { useToast } from 'src/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;

View File

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

View File

@ -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 'src/components/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;

View File

@ -0,0 +1,50 @@
import clsx from 'clsx';
import Link from 'src/components/ui/Link';
import CartButton from 'src/components/CartButton';
import MobileNavMenu from 'src/components/Navbar/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-14',
'px-4 py-3 md:px-8 xl:px-0',
'flex items-center justify-between gap-4 lg:gap-[103px]',
className,
)}>
<Link 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;

View File

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

View File

@ -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;

View File

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

View File

@ -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;

View File

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

View File

@ -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:
'Heres 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;

View File

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

View File

@ -0,0 +1,12 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export default function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
}

View File

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

View File

@ -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 };

View File

@ -0,0 +1,4 @@
import * as Accordion from './Accordion';
export * from './Accordion';
export default Accordion;

View File

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

View File

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

View File

@ -0,0 +1,200 @@
import clsx from 'clsx';
import Link from '../Link';
const paddingClasses = {
md: 'px-3.5 py-2.5',
lg: 'px-4 py-2.5',
xl: 'px-5 py-3',
'2xl': 'px-6 py-4',
};
// We need this because secondary button has border
const secondaryVariantPaddingClasses = {
md: 'px-[13px] py-[9px]',
lg: 'px-[15px] py-[9px]',
xl: 'px-[19px] py-[11px]',
'2xl': 'px-[23px] py-[15px]',
};
const fontSizeClasses = {
md: 'text-sm',
lg: 'text-base',
xl: 'text-base',
'2xl': 'text-lg',
};
const spacingClasses = {
md: 'gap-x-1.5',
lg: 'gap-x-2',
xl: 'gap-x-2',
'2xl': 'gap-x-3',
};
const heightClasses = {
md: 'h-10',
lg: 'h-11',
xl: 'h-12',
'2xl': 'h-15',
};
const iconOnlySizeClasses = {
md: 'size-10',
lg: 'size-11',
xl: 'size-12',
'2xl': 'size-14',
};
const iconSizeClasses = {
md: 'size-5',
lg: 'size-5',
xl: 'size-5',
'2xl': 'size-6',
};
const variantClasses = {
primary: clsx(
'border-none',
'bg-indigo-700',
'shadow-custom',
'text-white',
'hover:bg-indigo-800 focus:bg-indigo-800'
),
secondary: clsx(
'border border-neutral-200',
'bg-white',
'shadow-custom',
'text-neutral-900',
'hover:bg-neutral-50 focus:bg-neutral-50'
),
tertiary: clsx(
'border-none',
'text-indigo-700',
'hover:bg-neutral-50 focus:bg-neutral-50'
),
danger: clsx(
'border-none',
'bg-red-600',
'text-white',
'hover:bg-red-700 focus:bg-red-700 focus:outline-none focus-visible:ring-4 focus-visible:ring-red-600/[.12]'
),
link: clsx(
'text-indigo-700',
'hover:text-indigo-800 focus:text-indigo-800',
'rounded focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]'
),
};
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="unstyled"
disabled={isDisabled}
className={clsx(commonClasses, className)}
{...props}>
{StartIcon && (
<StartIcon
className={clsx('size-5 p-0.5 shrink-0', iconClassName)}
aria-hidden="true"
/>
)}
{label}
{EndIcon && (
<EndIcon
className={clsx('size-5 p-0.5 shrink-0', iconClassName)}
aria-hidden="true"
/>
)}
</Link>
);
}
const children = isLabelHidden ? (
(
<StartIcon
className={clsx('shrink-0', iconSizeClasses[size], iconClassName)}
aria-hidden="true"
/>
) || (
<EndIcon
className={clsx('shrink-0', iconSizeClasses[size], iconClassName)}
aria-hidden="true"
/>
)
) : (
<>
{StartIcon && (
<StartIcon
className={clsx('size-5 p-0.5 shrink-0', iconClassName)}
aria-hidden="true"
/>
)}
{label}
{EndIcon && (
<EndIcon
className={clsx('size-5 p-0.5 shrink-0', iconClassName)}
aria-hidden="true"
/>
)}
</>
);
return (
<button
className={clsx(commonClasses, className)}
disabled={isDisabled}
{...props}>
{children}
</button>
);
};
export default Button;

View File

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

View File

@ -0,0 +1,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;

View File

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

View File

@ -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;

View File

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

View File

@ -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 };

View File

@ -0,0 +1,4 @@
import * as Dropdown from './Dropdown';
export * from './Dropdown';
export default Dropdown;

View File

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

View File

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

View File

@ -0,0 +1,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;

View File

@ -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;

View File

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

View File

@ -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;

View File

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

View File

@ -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;

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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;

View File

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

View File

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

View File

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

View File

@ -0,0 +1,163 @@
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',
href: '/products?collectionId=latest',
},
{
name: 'Urban Oasis',
value: 'urban',
href: '/products?collectionId=urban',
},
{
name: 'Cozy Comfort',
value: 'cozy',
href: '/products?collectionId=cozy',
},
{
name: 'Fresh Fusion',
value: 'fresh',
href: '/products?collectionId=fresh',
},
],
};
export const CATEGORY_OPTIONS = {
title: 'Category',
key: 'category',
items: [
{
name: 'Unisex',
value: 'unisex',
href: '/products?categoryId=unisex',
},
{
name: 'Women',
value: 'women',
href: '/products?categoryId=women',
},
{
name: 'Men',
value: 'men',
href: '/products?categoryId=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',
},
];

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,85 @@
@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-modal {
z-index: 1055;
}
.z-toast {
z-index: 1090;
}
/* 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);
}
.shadow-input {
box-shadow:
0px 0px 0px 1px #444ce7,
0px 1px 2px rgba(16, 24, 40, 0.05),
0px 0px 0px 4px rgba(68, 76, 231, 0.12);
}
/* Custom background */
.bg-collection {
background: linear-gradient(
60deg,
rgba(0, 0, 0, 0.4) -9.37%,
rgba(0, 0, 0, 0.132) 100%
);
}
.bg-collection-hover {
background: linear-gradient(
360deg,
rgba(0, 0, 0, 0.6) -9.37%,
rgba(0, 0, 0, 0.198) 100%
);
}

View File

@ -0,0 +1,16 @@
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';
import ScrollToTop from './components/ScrollToTop';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<Router>
<ScrollToTop />
<App />
</Router>
</React.StrictMode>
);

View File

@ -0,0 +1,7 @@
import LatestArrivalsSection from './components/LatestArrivalsSection';
const LatestArrivalsPage = () => {
return <LatestArrivalsSection className="md:py-[72px] lg:py-[104px]" />;
};
export default LatestArrivalsPage;

View File

@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import Button from 'src/components/ui/Button';
import ProductGridSection from 'src/components/ProductGridSection';
import clsx from 'clsx';
const LatestArrivalsSection = ({ className }) => {
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 (
<section
aria-describedby="latest-arrivals-section"
className={clsx(
'px-4 py-12 md:py-16 lg:p-24',
'flex flex-col gap-8',
'h-full',
className
)}>
<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} />
)}
</section>
);
};
export default LatestArrivalsSection;

View File

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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,48 @@
import clsx from 'clsx';
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import ProductGridSection from 'src/components/ProductGridSection';
const ProductCollectionSection = () => {
const { productId } = useParams();
const [collectionProducts, setCollectionsProducts] = useState([]);
const [isCollectionProductsLoading, setIsCollectionProductsLoading] =
useState(true);
const getCollectionsProducts = useCallback(async () => {
setIsCollectionProductsLoading(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) {
setCollectionsProducts(
result.data.filter(item => item.product_id !== productId).slice(0, 4)
);
}
setIsCollectionProductsLoading(false);
}, [productId]);
useEffect(() => {
getCollectionsProducts();
}, [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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -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;
};

View File

@ -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;

View File

@ -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 };

View File

@ -0,0 +1,4 @@
import * as Accordion from './Accordion';
export * from './Accordion';
export default Accordion;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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,
};
}

View File

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

View File

@ -0,0 +1,17 @@
import LatestArrivalsSection from '../LatestArrivals/components/LatestArrivalsSection';
import HeroSection from './components/HeroSection';
import CollectionsGridSection from './components/CollectionsGridSection';
import FeaturesGridSection from './components/FeaturesGridSection';
const StorefrontPage = () => {
return (
<div>
<HeroSection />
<LatestArrivalsSection />
<CollectionsGridSection />
<FeaturesGridSection />
</div>
);
};
export default StorefrontPage;

View File

@ -0,0 +1,71 @@
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import CollectionCard from 'src/components/CollectionCard';
const CollectionsGridSection = () => {
const [collections, setCollections] = useState([]);
const [isFetching, setIsFetching] = useState(true);
const getLatestArrivalProducts = async () => {
setIsFetching(true);
const data = await fetch(
`https://www.greatfrontend.com/api/projects/challenges/e-commerce/collections`
);
const result = await data.json();
if (!result.error) {
setCollections(result.data);
}
setIsFetching(false);
};
useEffect(() => {
getLatestArrivalProducts();
}, []);
return (
<section
aria-describedby="collections-section"
className={clsx('px-4 py-12 md:py-16 lg:p-24', 'flex flex-col gap-8')}>
<div className="font-semibold text-3xl">Our Collections</div>
{isFetching ? (
<div>Loading...</div>
) : (
<div className={clsx('flex flex-col md:flex-row gap-7')}>
<div className={clsx('flex-1')}>
<CollectionCard
imageUrl={collections[0].image_url}
name={collections[0].name}
description={collections[0].description}
id={collections[0].collection_id}
/>
</div>
<div className={clsx('flex-1', 'flex flex-col gap-7')}>
<div className={clsx('flex-1')}>
<CollectionCard
imageUrl={collections[1].image_url}
name={collections[1].name}
description={collections[1].description}
id={collections[1].collection_id}
variant="secondary"
/>
</div>
<div className={clsx('flex-1')}>
<CollectionCard
imageUrl={collections[2].image_url}
name={collections[2].name}
description={collections[2].description}
id={collections[2].collection_id}
variant="secondary"
/>
</div>
</div>
</div>
)}
</section>
);
};
export default CollectionsGridSection;

View File

@ -0,0 +1,83 @@
import clsx from 'clsx';
import { RiExchangeLine, RiShieldCheckLine, RiTruckLine } from 'react-icons/ri';
const FEATURES = [
{
title: 'Complimentary Shipping',
description:
'Enjoy the convenience of free shipping for all orders. We believe in transparent pricing, and the price you see is the price you pay—no surprise fees',
icon: RiTruckLine,
},
{
title: '2-Year Quality Promise',
description:
"Shop with confidence knowing that we stand behind our products. Should any issue arise within the first two years, rest assured we're here to help with a hassle-free replacement.",
icon: RiShieldCheckLine,
},
{
title: 'Easy Exchanges',
description:
"If your purchase isn't quite right, pass it on to a friend who might love it, and let us know. We're happy to facilitate an exchange to ensure you have the perfect item to complement your lifestyle.",
icon: RiExchangeLine,
},
];
const FeaturesGridSection = () => {
return (
<section
aria-describedby="features-section"
className={clsx(
'px-4 py-12 md:py-16 lg:p-24',
'flex flex-col gap-12 md:gap-16'
)}>
<header className={clsx('flex flex-col gap-5 lg:px-40', 'text-center')}>
<div className="flex flex-col justify-center gap-3 lg:px-10">
<span className="font-semibold text-indigo-700">
Elevate Your Experience
</span>
<h2 className="font-semibold text-3xl md:text-5xl">
Our Commitment to Exceptional Service
</h2>
</div>
<p className="text-xl text-neutral-600">
We pride ourselves on a foundation of exceptional customer service,
where every interaction is a testament to our dedication to
excellence.
</p>
</header>
<ul
className={clsx(
'grid grid-cols-4 md:grid-cols-6 lg:grid-cols-12',
'gap-x-4 gap-y-8 md:gap-x-8'
)}>
{FEATURES.map(({ title, description, icon: Icon }) => (
<li
key={title}
className={clsx(
'col-span-4 md:col-span-6 lg:col-span-4',
'flex flex-col justify-center items-center gap-5'
)}>
<div
aria-hidden="true"
className={clsx(
'w-12 h-12 bg-white rounded-full shadow-custom',
'flex items-center justify-center'
)}>
<Icon className="size-6 text-indigo-700" />
</div>
<div className="flex flex-col justify-center gap-2 self-stretch">
<span className="font-semibold text-xl text-center text-neutral-900">
{title}
</span>
<span className="font-normal text-base text-center text-neutral-600">
{description}
</span>
</div>
</li>
))}
</ul>
</section>
);
};
export default FeaturesGridSection;

View File

@ -0,0 +1,53 @@
import clsx from 'clsx';
import Button from 'src/components/ui/Button';
import { useMediaQuery } from 'usehooks-ts';
const HeroSection = () => {
const isMobileAndBelow = useMediaQuery('(max-width: 767px)');
return (
<section
aria-describedby="hero-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 gap-y-12 md:gap-x-8 md:gap-y-8',
)}>
<div
className={clsx(
'col-span-4 md:col-span-6 lg:col-span-5',
'flex flex-col justify-center gap-8 md:gap-16',
)}>
<div className="flex flex-col justify-center gap-4 md:gap-6">
<span className="text-4xl font-semibold md:text-5xl lg:text-6xl">
Summer styles are finally here
</span>
<span className="text-xl text-neutral-600">
This year, our new summer collection will be your haven from the
world's harsh elements.
</span>
</div>
<div className="w-[151.5px] md:w-[213px] lg:w-[175.5px]">
<Button
href="/products"
label="Shop now"
size={isMobileAndBelow ? 'xl' : '2xl'}
className="w-full"
/>
</div>
</div>
<img
src="https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/banner.jpg"
alt="Storefront hero banner"
className={clsx(
'object-cover',
'rounded-lg',
'col-span-4 md:col-span-6 lg:col-span-7',
'h-[264px] w-full md:h-[526px]',
)}
/>
</section>
);
};
export default HeroSection;

View File

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