diff --git a/apps/web/src/__generated__/projects/challenges/storefront-page/solutions/react.json b/apps/web/src/__generated__/projects/challenges/storefront-page/solutions/react.json new file mode 100644 index 000000000..c5f58098b --- /dev/null +++ b/apps/web/src/__generated__/projects/challenges/storefront-page/solutions/react.json @@ -0,0 +1,277 @@ +{ + "files": { + "/index.css": { + "code": "@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap');\n\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nbody {\n font-family:\n 'Noto Sans',\n system-ui,\n -apple-system,\n BlinkMacSystemFont,\n 'Segoe UI',\n Roboto,\n Oxygen,\n Ubuntu,\n Cantarell,\n 'Open Sans',\n 'Helvetica Neue',\n sans-serif;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n text-rendering: optimizeLegibility;\n background: linear-gradient(147.52deg, #f9fafb 8.89%, #d2d6db 100.48%);\n}\n\n/* Custom z-index */\n.z-sticky {\n z-index: 1020;\n}\n.z-fixed {\n z-index: 1030;\n}\n.z-dropdown {\n z-index: 1000;\n}\n.z-modal {\n z-index: 1055;\n}\n.z-toast {\n z-index: 1090;\n}\n\n/* Custom animations and keyframes */\n@keyframes slideout {\n from {\n transform: translateX(-100%);\n }\n to {\n transform: translateX(0%);\n }\n}\n\n.animate-slideout {\n animation: slideout 0.4s ease-out;\n}\n\n/* Custom box shadow */\n.shadow-custom {\n box-shadow:\n 0px 1px 2px 0 rgb(0 0 0 / 0.06),\n 0px 1px 3px 0 rgb(0 0 0 / 0.1);\n}\n.shadow-input {\n box-shadow:\n 0px 0px 0px 1px #444ce7,\n 0px 1px 2px rgba(16, 24, 40, 0.05),\n 0px 0px 0px 4px rgba(68, 76, 231, 0.12);\n}\n\n/* Custom background */\n.bg-collection {\n background: linear-gradient(\n 60deg,\n rgba(0, 0, 0, 0.4) -9.37%,\n rgba(0, 0, 0, 0.132) 100%\n );\n}\n.bg-collection-hover {\n background: linear-gradient(\n 360deg,\n rgba(0, 0, 0, 0.6) -9.37%,\n rgba(0, 0, 0, 0.198) 100%\n );\n}\n" + }, + "/jsconfig.json": { + "code": "{\n \"compilerOptions\": {\n \"baseUrl\": \".\"\n },\n \"include\": [\"src\"]\n}\n" + }, + "/package.json": { + "code": "{\n \"name\": \"@gfe-challenges/storefront-page-solution\",\n \"version\": \"0.0.1\",\n \"dependencies\": {\n \"clsx\": \"^2.1.1\",\n \"react-icons\": \"^5.2.1\",\n \"react-router-dom\": \"^6.23.1\",\n \"usehooks-ts\": \"^3.1.0\",\n \"react\": \"^18.3.1\",\n \"react-dom\": \"^18.3.1\",\n \"react-scripts\": \"5.0.1\"\n },\n \"scripts\": {\n \"start\": \"react-scripts start\",\n \"build\": \"react-scripts build\",\n \"test\": \"react-scripts test\",\n \"eject\": \"react-scripts eject\"\n }\n}\n" + }, + "/public/index.html": { + "code": "\n\n \n \n \n \n \n StyleNest's Storefront\n \n \n \n
\n \n\n" + }, + "/src/App.js": { + "code": "import { Route, Routes } from 'react-router-dom';\n\nimport Layout from './Layout';\nimport ProductListingPage from 'src/pages/ProductListing';\nimport ProductDetailPage from './pages/ProductDetail';\n\nimport ToastContextProvider from './context/ToastContext';\nimport LatestArrivalsPage from './pages/LatestArrivals';\nimport CartContextProvider from './context/CartContext';\nimport StorefrontPage from './pages/Storefront';\n\nfunction App() {\n return (\n \n \n \n }>\n } />\n } />\n } />\n }\n />\n \n \n \n \n );\n}\n\nexport default App;\n" + }, + "/src/components/CartButton/CartButton.jsx": { + "code": "import clsx from 'clsx';\nimport { RiShoppingBag3Line } from 'react-icons/ri';\nimport { Link as RouterLink } from 'react-router-dom';\n\nimport { useCartContext } from 'src/context/CartContext';\n\nconst CartButton = ({ disabled }) => {\n const { cartItems } = useCartContext();\n const count = cartItems.length;\n\n return (\n \n \n\n {count > 0 && (\n \n {count}\n \n )}\n \n );\n};\n\nexport default CartButton;\n" + }, + "/src/components/CartButton/index.js": { + "code": "import CartButton from './CartButton';\n\nexport default CartButton;\n" + }, + "/src/components/CartControl/CartControl.jsx": { + "code": "import clsx from 'clsx';\nimport { RiAddFill, RiSubtractFill } from 'react-icons/ri';\n\nimport Tooltip from '../ui/Tooltip';\n\nconst CartControl = ({ quantity, decrement, increment, availableStock }) => {\n const disabledDecrement = quantity === 1;\n const disabledIncrement = quantity >= availableStock;\n\n return (\n \n \n \n \n \n {quantity}\n \n \n \n \n \n \n \n );\n};\n\nexport default CartControl;\n" + }, + "/src/components/CartControl/index.js": { + "code": "import CartControl from './CartControl';\n\nexport default CartControl;\n" + }, + "/src/components/CollectionCard/CollectionCard.jsx": { + "code": "import clsx from 'clsx';\nimport { useCallback } from 'react';\nimport { useNavigate } from 'react-router-dom';\n\nimport Link from 'src/components/ui/Link';\n\nconst variantClasses = {\n primary: clsx('max-w-[594px] h-[580px]'),\n secondary: clsx('max-w-[594px] h-[337px] md:h-[276px]'),\n};\n\nconst CollectionCard = ({\n imageUrl,\n name,\n description,\n id,\n variant = 'primary',\n}) => {\n const navigate = useNavigate();\n\n const redirectUrl = `/products?collectionId=${id}`;\n\n const handleKeyDown = useCallback(\n event => {\n if (event.key === 'Enter') {\n navigate(redirectUrl);\n }\n },\n [navigate, redirectUrl]\n );\n\n return (\n \n \n \n \n {name}\n {description}\n \n \n \n );\n};\n\nexport default CollectionCard;\n" + }, + "/src/components/CollectionCard/index.js": { + "code": "import CollectionCard from './CollectionCard';\n\nexport default CollectionCard;\n" + }, + "/src/components/Footer/Footer.jsx": { + "code": "import clsx from 'clsx';\nimport {\n RiFacebookBoxLine,\n RiGithubLine,\n RiInstagramLine,\n RiTwitterXLine,\n RiYoutubeLine,\n} from 'react-icons/ri';\n\nimport Link from '../ui/Link';\nimport NewsletterForm from './NewsletterForm';\nimport { CATEGORY_OPTIONS, COLLECTIONS_OPTIONS } from 'src/constants';\n\nconst footerSocials = [\n {\n icon: RiYoutubeLine,\n url: 'https://youtube.com',\n name: \"Link to Stylenest's youtube profile\",\n },\n {\n icon: RiInstagramLine,\n url: 'https://instagram.com',\n name: \"Link to Stylenest's instagram profile\",\n },\n {\n icon: RiFacebookBoxLine,\n url: 'https://facebook.com',\n name: \"Link to Stylenest's facebook profile\",\n },\n {\n icon: RiGithubLine,\n url: 'https://github.com',\n name: \"Link to Stylenest's github profile\",\n },\n {\n icon: RiTwitterXLine,\n url: 'https://twitter.com',\n name: \"Link to Stylenest's twitter profile\",\n },\n];\n\nconst Footer = () => {\n return (\n \n
\n \n
\n Join our newsletter\n
\n
\n We’ll send you a nice letter once per week. No spam.\n
\n
\n\n
\n \n
\n\n \n
\n \n
\n
\n Craft stunning style journeys that weave more joy into every thread.\n
\n \n\n \n\n \n
SHOP COLLECTIONS
\n
\n {COLLECTIONS_OPTIONS.items.map((collection) => (\n \n {collection.name}\n \n ))}\n
\n \n \n\n \n
\n © {new Date().getFullYear()} StyleNest, Inc. All rights reserved.\n
\n
\n {footerSocials.map(({ icon: Icon, url, name }) => (\n \n \n \n ))}\n
\n \n \n );\n};\n\nexport default Footer;\n" + }, + "/src/components/Footer/index.js": { + "code": "import Footer from './Footer';\n\nexport default Footer;\n" + }, + "/src/components/Footer/NewsletterForm.jsx": { + "code": "import { useState } from 'react';\n\nimport Button from 'src/components/ui/Button';\nimport TextInput from 'src/components/ui/TextInput';\n\nimport { useToast } from 'src/context/ToastContext';\n\nconst EMAIL_REGEX = /^[^@]+@[^@]+\\.[^@]+$/;\n\nconst NewsletterForm = () => {\n const toast = useToast();\n\n const [email, setEmail] = useState('');\n const [submitting, setSubmitting] = useState(false);\n const [errorMessage, setErrorMessage] = useState('');\n\n const onSubmit = async event => {\n event.preventDefault();\n\n if (!email.match(EMAIL_REGEX)) {\n setErrorMessage('Please enter a valid email address.');\n return;\n } else if (!email) {\n setErrorMessage('Email address is required.');\n return;\n } else {\n setErrorMessage('');\n }\n\n setSubmitting(true);\n\n const requestOptions = {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n email,\n }), // Send the data in JSON format\n };\n\n // Make the request\n const res = await fetch(\n 'https://www.greatfrontend.com/api/projects/challenges/newsletter',\n requestOptions\n );\n const result = await res.json();\n\n if (result) {\n setEmail('');\n if (result.message) {\n toast.success(result.message);\n } else if (result.error) {\n toast.error(result.error);\n }\n }\n setSubmitting(false);\n };\n\n return (\n \n setEmail(value)}\n value={email}\n required\n />\n \n\n {/* Mobile nav menu */}\n {openMenu &&\n createPortal(\n \n
\n \n setOpenMenu(false)}\n aria-label=\"Close mobile menu\"\n type=\"button\"\n className={clsx(\n 'rounded text-neutral-600',\n 'focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]',\n )}>\n \n \n
\n
\n {links.map((link) => (\n setOpenMenu(false)}\n className=\"px-3 py-2 text-sm\"\n variant=\"gray\"\n type=\"nav\">\n {link.name}\n \n ))}\n
\n ,\n document.body,\n )}\n \n );\n};\n\nexport default MobileNavMenu;\n" + }, + "/src/components/Navbar/Navbar.jsx": { + "code": "import clsx from 'clsx';\n\nimport Link from 'src/components/ui/Link';\nimport CartButton from 'src/components/CartButton';\nimport MobileNavMenu from 'src/components/Navbar/MobileNavMenu';\n\nconst links = [\n {\n name: 'Shop all',\n href: '/products',\n },\n {\n name: 'Latest arrivals',\n href: '/latest',\n },\n];\n\nconst Navbar = ({ className }) => {\n return (\n \n \n \n \n \n
\n \n \n
\n \n );\n};\n\nexport default Navbar;\n" + }, + "/src/components/ProductCard/index.js": { + "code": "import ProductCard from './ProductCard';\n\nexport default ProductCard;\n" + }, + "/src/components/ProductCard/ProductCard.jsx": { + "code": "import clsx from 'clsx';\nimport { useCallback, useMemo } from 'react';\nimport { useNavigate } from 'react-router-dom';\n\nimport Link from 'src/components/ui/Link';\nimport ColorSwatches from 'src/components/ui/ColorSwatches';\n\nimport { COLORS } from 'src/constants';\n\nimport { getUnavailableColors } from 'src/pages/ProductDetail/utils';\n\nconst ProductCard = ({ product }) => {\n const navigate = useNavigate();\n const { images, name, inventory, colors } = product;\n const { discount_percentage, sale_price, list_price, color } = inventory[0];\n\n const hasDiscount = !!discount_percentage;\n\n const unavailableColors = useMemo(\n () => getUnavailableColors(product),\n [product]\n );\n\n const redirectUrl = `/products/${product.product_id}`;\n\n const handleKeyDown = useCallback(\n event => {\n if (event.key === 'Enter') {\n navigate(redirectUrl);\n }\n },\n [navigate, redirectUrl]\n );\n\n return (\n \n \n
\n \n {COLORS[color]?.label}\n \n \n \n {name}\n \n
\n \n ${hasDiscount ? sale_price : list_price}\n \n {hasDiscount && (\n \n ${list_price}\n \n )}\n
\n
\n {colors.map(color => (\n \n ))}\n
\n
\n \n );\n};\n\nexport default ProductCard;\n" + }, + "/src/components/ProductGridSection/index.js": { + "code": "import ProductGridSection from './ProductGridSection';\n\nexport default ProductGridSection;\n" + }, + "/src/components/ProductGridSection/ProductGridSection.jsx": { + "code": "import clsx from 'clsx';\nimport ProductCard from 'src/components/ProductCard';\n\nconst ProductGridSection = ({ products }) => {\n return (\n
\n {products.map(product => (\n \n \n
\n ))}\n \n );\n};\n\nexport default ProductGridSection;\n" + }, + "/src/components/ProductSpecificationSection/index.js": { + "code": "import ProductSpecificationSection from './ProductSpecificationSection';\n\nexport default ProductSpecificationSection;\n" + }, + "/src/components/ProductSpecificationSection/ProductSpecificationSection.jsx": { + "code": "import clsx from 'clsx';\nimport { useState } from 'react';\nimport {\n RiColorFilterLine,\n RiHandHeartLine,\n RiInfinityFill,\n RiPaintLine,\n RiPlantLine,\n RiPriceTag2Line,\n RiRainbowLine,\n RiRecycleLine,\n RiScales2Line,\n RiShapesLine,\n RiShieldStarLine,\n RiShirtLine,\n RiStackLine,\n RiTShirtLine,\n RiWaterFlashLine,\n RiWindyLine,\n} from 'react-icons/ri';\n\nimport Tabs from '../ui/Tabs';\n\nconst TABS = [\n { label: 'Sustainability', value: 'sustainability' },\n { label: 'Comfort', value: 'comfort' },\n { label: 'Durability', value: 'durability' },\n { label: 'Versatility', value: 'versatility' },\n];\n\nconst specifications = [\n {\n value: 'sustainability',\n title: 'Eco-Friendly Choice',\n description:\n 'With our sustainable approach, we curate clothing that makes a statement of care—care for the planet, and for the art of fashion.',\n img: {\n desktop:\n 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/yellow-desktop.jpg',\n tablet:\n 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/yellow-tablet.jpg',\n mobile:\n 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/yellow-mobile.jpg',\n },\n items: [\n {\n label: 'Recycled Materials',\n icon: RiRecycleLine,\n },\n {\n label: 'Low Impact Dye',\n icon: RiPaintLine,\n },\n {\n label: 'Carbon Neutral',\n icon: RiPlantLine,\n },\n {\n label: 'Water Conservation',\n icon: RiWaterFlashLine,\n },\n ],\n },\n {\n value: 'comfort',\n title: 'Uncompromised Comfort',\n description:\n 'Our garments are a sanctuary of softness, tailored to drape gracefully and allow for freedom of movement.',\n img: {\n desktop:\n 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/black-desktop.jpg',\n tablet:\n 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/black-tablet.jpg',\n mobile:\n 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/black-mobile.jpg',\n },\n items: [\n {\n label: 'Ergonomic Fits',\n icon: RiTShirtLine,\n },\n {\n label: 'Soft-to-the-Touch Fabrics',\n icon: RiHandHeartLine,\n },\n {\n label: 'Breathable Weaves',\n icon: RiWindyLine,\n },\n {\n label: 'Thoughtful Design',\n icon: RiColorFilterLine,\n },\n ],\n },\n {\n value: 'durability',\n title: 'Built to Last',\n description:\n 'Here’s to apparel that you can trust to look as good as new, wear after wear, year after year.',\n img: {\n desktop:\n 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/chair-desktop.jpg',\n tablet:\n 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/chair-tablet.jpg',\n mobile:\n 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/chair-mobile.jpg',\n },\n items: [\n {\n label: 'Reinforced Construction',\n icon: RiStackLine,\n },\n {\n label: 'Quality Control',\n icon: RiScales2Line,\n },\n {\n label: 'Material Resilience',\n icon: RiShieldStarLine,\n },\n {\n label: 'Warranty and Repair',\n icon: RiPriceTag2Line,\n },\n ],\n },\n {\n value: 'versatility',\n title: 'Versatile by Design',\n description:\n '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. ',\n img: {\n desktop:\n 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/clothes-desktop.jpg',\n tablet:\n 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/clothes-tablet.jpg',\n mobile:\n 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/clothes-mobile.jpg',\n },\n items: [\n {\n label: 'Adaptive Styles',\n icon: RiRainbowLine,\n },\n {\n label: 'Functional Fashion',\n icon: RiShirtLine,\n },\n {\n label: 'Timeless Aesthetics',\n icon: RiInfinityFill,\n },\n {\n label: 'Mix-and-Match Potential',\n icon: RiShapesLine,\n },\n ],\n },\n];\n\nconst ProductSpecificationSection = () => {\n const [selectedSpecification, setSelectedSpecification] =\n useState('sustainability');\n\n const data = specifications.find(\n (specification) => specification.value === selectedSpecification,\n );\n\n return (\n \n
\n

\n Discover timeless elegance\n

\n

\n Step into a world where quality meets quintessential charm with our\n collection. Every thread weaves a promise of unparalleled quality,\n ensuring that each garment is not just a part of your wardrobe, but a\n piece of art. Here's the essence of what makes our apparel the\n hallmark for those with an eye for excellence and a heart for the\n environment.\n

\n
\n\n
\n \n
\n \n \n \n \n \n\n
\n
\n

\n {data.title}\n

\n

{data.description}

\n
\n \n {data.items.map(({ label, icon: Icon }) => (\n \n \n \n
\n {label}\n
\n ))}\n
\n \n \n \n \n );\n};\n\nexport default ProductSpecificationSection;\n" + }, + "/src/components/ScrollToTop/index.js": { + "code": "import ScrollToTop from './ScrollToTop';\n\nexport default ScrollToTop;\n" + }, + "/src/components/ScrollToTop/ScrollToTop.jsx": { + "code": "import { useEffect } from 'react';\nimport { useLocation } from 'react-router-dom';\n\nexport default function ScrollToTop() {\n const { pathname } = useLocation();\n\n useEffect(() => {\n window.scrollTo(0, 0);\n }, [pathname]);\n\n return null;\n}\n" + }, + "/src/components/ui/Accordion/Accordion.jsx": { + "code": "import clsx from 'clsx';\nimport { useState, useRef, createContext, useContext } from 'react';\nimport { RiAddCircleLine, RiIndeterminateCircleLine } from 'react-icons/ri';\n\nconst AccordionItemContext = createContext();\n\nconst AccordionItem = ({ children, id }) => {\n const [isOpen, setIsOpen] = useState(true);\n\n return (\n
\n \n {children}\n \n
\n );\n};\n\nconst AccordionTrigger = ({ children }) => {\n const { id, isOpen, setIsOpen } = useContext(AccordionItemContext);\n const Icon = isOpen ? RiIndeterminateCircleLine : RiAddCircleLine;\n return (\n setIsOpen(!isOpen)}\n aria-expanded={isOpen}\n aria-controls={`accordion-content-${id}`}\n id={`accordion-header-${id}`}>\n {children}\n \n \n );\n};\n\nconst AccordionContent = ({ children }) => {\n const contentRef = useRef(null);\n const { id, isOpen } = useContext(AccordionItemContext);\n\n return (\n \n {children}\n \n );\n};\n\nconst Accordion = ({ children }) => {\n return (\n
\n {children.map((item, index) => (\n
\n {item}\n {index !== children.length - 1 && (\n
\n )}\n
\n ))}\n
\n );\n};\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent };\n" + }, + "/src/components/ui/Accordion/index.js": { + "code": "import * as Accordion from './Accordion';\n\nexport * from './Accordion';\nexport default Accordion;\n" + }, + "/src/components/ui/Badge/Badge.jsx": { + "code": "import clsx from 'clsx';\n\nconst sizeClasses = {\n sm: clsx('h-5', 'py px-[5px]', 'text-xs'),\n md: clsx('h-6', 'py px-[7px]', 'text-sm'),\n lg: clsx('h-7', 'py-[3px] px-[9px]', 'text-sm'),\n};\n\nconst variantClasses = {\n neutral: clsx('bg-gray-50', 'border-neutral-200', 'text-neutral-600'),\n danger: clsx('bg-red-50', 'border-red-200', 'text-red-600'),\n warning: clsx('bg-amber-50', 'border-amber-200', 'text-amber-700'),\n success: clsx('bg-green-50', 'border-green-200', 'text-green-700'),\n brand: clsx('bg-indigo-50', 'border-indigo-200', 'text-indigo-700'),\n};\n\nconst Badge = ({ label, size = 'md', variant = 'neutral', className }) => {\n const commonClasses = clsx('rounded-full text-center border');\n return (\n \n {label}\n
\n );\n};\n\nexport default Badge;\n" + }, + "/src/components/ui/Badge/index.js": { + "code": "import Badge from './Badge';\n\nexport default Badge;\n" + }, + "/src/components/ui/Button/Button.jsx": { + "code": "import clsx from 'clsx';\n\nimport Link from '../Link';\n\nconst paddingClasses = {\n md: 'px-3.5 py-2.5',\n lg: 'px-4 py-2.5',\n xl: 'px-5 py-3',\n '2xl': 'px-6 py-4',\n};\n\n// We need this because secondary button has border\nconst secondaryVariantPaddingClasses = {\n md: 'px-[13px] py-[9px]',\n lg: 'px-[15px] py-[9px]',\n xl: 'px-[19px] py-[11px]',\n '2xl': 'px-[23px] py-[15px]',\n};\n\nconst fontSizeClasses = {\n md: 'text-sm',\n lg: 'text-base',\n xl: 'text-base',\n '2xl': 'text-lg',\n};\n\nconst spacingClasses = {\n md: 'gap-x-1.5',\n lg: 'gap-x-2',\n xl: 'gap-x-2',\n '2xl': 'gap-x-3',\n};\n\nconst heightClasses = {\n md: 'h-10',\n lg: 'h-11',\n xl: 'h-12',\n '2xl': 'h-15',\n};\n\nconst iconOnlySizeClasses = {\n md: 'size-10',\n lg: 'size-11',\n xl: 'size-12',\n '2xl': 'size-14',\n};\n\nconst iconSizeClasses = {\n md: 'size-5',\n lg: 'size-5',\n xl: 'size-5',\n '2xl': 'size-6',\n};\n\nconst variantClasses = {\n primary: clsx(\n 'border-none',\n 'bg-indigo-700',\n 'shadow-custom',\n 'text-white',\n 'hover:bg-indigo-800 focus:bg-indigo-800'\n ),\n secondary: clsx(\n 'border border-neutral-200',\n 'bg-white',\n 'shadow-custom',\n 'text-neutral-900',\n 'hover:bg-neutral-50 focus:bg-neutral-50'\n ),\n tertiary: clsx(\n 'border-none',\n 'text-indigo-700',\n 'hover:bg-neutral-50 focus:bg-neutral-50'\n ),\n danger: clsx(\n 'border-none',\n 'bg-red-600',\n 'text-white',\n 'hover:bg-red-700 focus:bg-red-700 focus:outline-none focus-visible:ring-4 focus-visible:ring-red-600/[.12]'\n ),\n link: clsx(\n 'text-indigo-700',\n 'hover:text-indigo-800 focus:text-indigo-800',\n 'rounded focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]'\n ),\n};\n\nconst variantDisabledClasses = {\n primary: clsx(\n 'disabled:bg-neutral-100',\n 'disabled:text-neutral-400',\n 'disabled:shadow-none'\n ),\n secondary: clsx(\n 'disabled:bg-neutral-100',\n 'disabled:text-neutral-400',\n 'disabled:shadow-none'\n ),\n tertiary: clsx('disabled:bg-none', 'disabled:text-neutral-400'),\n danger: clsx('disabled:bg-none', 'disabled:text-neutral-400'),\n link: clsx('disabled:text-neutral-400'),\n};\n\nconst Button = ({\n label,\n className,\n isDisabled,\n startIcon: StartIcon,\n endIcon: EndIcon,\n isLabelHidden,\n size = 'md',\n variant = 'primary',\n iconClassName,\n href,\n ...props\n}) => {\n const commonClasses = clsx(\n 'inline-flex items-center justify-center rounded font-medium outline-none cursor-pointer',\n 'focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]',\n 'transition-colors',\n 'text-nowrap',\n variant !== 'link' && heightClasses[size],\n variant !== 'link' &&\n (variant === 'secondary'\n ? secondaryVariantPaddingClasses[size]\n : paddingClasses[size]),\n fontSizeClasses[size],\n spacingClasses[size],\n isLabelHidden && iconOnlySizeClasses[size],\n variantClasses[variant],\n variantDisabledClasses[variant],\n isDisabled && 'pointer-events-none'\n );\n\n if (href) {\n return (\n \n {StartIcon && (\n \n )}\n {label}\n {EndIcon && (\n \n )}\n \n );\n }\n\n const children = isLabelHidden ? (\n (\n \n ) || (\n \n )\n ) : (\n <>\n {StartIcon && (\n \n )}\n {label}\n {EndIcon && (\n \n )}\n \n );\n\n return (\n \n {children}\n \n );\n};\n\nexport default Button;\n" + }, + "/src/components/ui/Button/index.js": { + "code": "import Button from './Button';\n\nexport default Button;\n" + }, + "/src/components/ui/CheckboxInput/CheckboxInput.jsx": { + "code": "import { useId } from 'react';\nimport clsx from 'clsx';\n\nconst CheckboxInput = ({ value, defaultValue, disabled, label, onChange }) => {\n const id = useId();\n\n return (\n
\n
\n {\n if (!onChange) {\n return;\n }\n\n onChange(event.target.checked, event);\n }}\n />\n
\n \n {label}\n \n
\n );\n};\n\nexport default CheckboxInput;\n" + }, + "/src/components/ui/CheckboxInput/index.js": { + "code": "import CheckboxInput from './CheckboxInput';\n\nexport default CheckboxInput;\n" + }, + "/src/components/ui/ColorSwatches/ColorSwatches.jsx": { + "code": "import clsx from 'clsx';\n\nconst outerSizeClasses = {\n md: 'size-[56.67px]',\n sm: 'size-6',\n};\n\nconst innerSizeClasses = {\n md: 'size-[38px]',\n sm: 'size-4',\n};\n\nconst ringSizeClasses = {\n md: 'focus:ring-[9.33px]',\n sm: 'focus:ring-4',\n};\n\nconst strokeLineClasses = {\n md: 'h-0.5 w-11',\n sm: 'h-px w-5',\n};\n\nconst ColorSwatches = ({\n color,\n selected,\n onClick,\n outOfStock,\n size = 'md',\n type = 'radio',\n}) => {\n const readOnly = !onClick || outOfStock;\n\n return (\n \n {\n if (!onClick) {\n return;\n }\n onClick(color);\n }}\n tabIndex={-1}\n disabled={outOfStock}\n />\n {\n if (e.key === 'Enter' || e.key === ' ') {\n onClick(color);\n }\n }}>\n {selected && !outOfStock && (\n \n \n \n )}\n {outOfStock && (\n \n )}\n \n \n );\n};\n\nexport default ColorSwatches;\n" + }, + "/src/components/ui/ColorSwatches/index.js": { + "code": "import ColorSwatches from './ColorSwatches';\n\nexport default ColorSwatches;\n" + }, + "/src/components/ui/Dropdown/Dropdown.jsx": { + "code": "import clsx from 'clsx';\nimport {\n createContext,\n useContext,\n useEffect,\n useId,\n useRef,\n useState,\n} from 'react';\nimport { RiArrowDownSLine } from 'react-icons/ri';\n\nimport Button from '../Button';\n\nconst DropdownContext = createContext();\n\nconst DropdownItem = ({ children, isSelected, onSelect }) => {\n const { setIsOpen, isOpen } = useContext(DropdownContext);\n const handleOptionClick = () => {\n setIsOpen(false);\n if (onSelect) {\n onSelect();\n }\n };\n\n return (\n \n {children}\n \n );\n};\n\nconst Dropdown = ({ children }) => {\n const id = useId();\n const [isOpen, setIsOpen] = useState(false);\n const dropdownRef = useRef(null);\n\n useEffect(() => {\n const handleClickOutside = event => {\n if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {\n setIsOpen(false);\n }\n };\n document.addEventListener('mousedown', handleClickOutside);\n return () => {\n document.removeEventListener('mousedown', handleClickOutside);\n };\n }, []);\n\n return (\n
\n
\n setIsOpen(!isOpen)}\n id={id}\n aria-expanded=\"true\"\n aria-haspopup=\"true\"\n variant=\"secondary\"\n endIcon={RiArrowDownSLine}\n />\n
\n\n \n
\n \n {children}\n \n
\n
\n \n );\n};\n\nexport { Dropdown, DropdownItem };\n" + }, + "/src/components/ui/Dropdown/index.js": { + "code": "import * as Dropdown from './Dropdown';\n\nexport * from './Dropdown';\nexport default Dropdown;\n" + }, + "/src/components/ui/Link/index.js": { + "code": "import Link from './Link';\n\nexport default Link;\n" + }, + "/src/components/ui/Link/Link.jsx": { + "code": "import clsx from 'clsx';\nimport { NavLink, Link as RouterLink } from 'react-router-dom';\n\nconst linkVariantClasses = {\n primary: clsx(\n 'text-indigo-700',\n 'hover:text-indigo-800 focus:text-indigo-800',\n 'rounded focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]',\n 'px-0.5'\n ),\n gray: clsx(\n 'text-neutral-600',\n 'hover:text-neutral-900 focus:text-neutral-900',\n 'rounded focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]',\n 'px-0.5'\n ),\n unstyled: '',\n};\n\nconst activeLinkClasses = {\n primary: 'text-indigo-800',\n gray: 'text-neutral-900',\n unstyled: '',\n};\n\nconst Link = ({\n children,\n disabled,\n className,\n type = 'default',\n variant = 'primary',\n ...props\n}) => {\n const commonClassName = clsx(\n 'font-medium rounded',\n linkVariantClasses[variant],\n {\n 'pointer-events-none text-neutral-400': disabled,\n },\n className\n );\n\n if (type === 'nav') {\n return (\n \n clsx(commonClassName, isActive && activeLinkClasses[variant])\n }>\n {children}\n \n );\n }\n return (\n \n {children}\n \n );\n};\n\nexport default Link;\n" + }, + "/src/components/ui/Rating/index.js": { + "code": "import StarRating from './Rating';\n\nexport default StarRating;\n" + }, + "/src/components/ui/Rating/Rating.jsx": { + "code": "import { useState } from 'react';\nimport clsx from 'clsx';\n\nimport Star from './Star';\n\nconst Rating = ({ value, max = 5, onChange, selected, showHover }) => {\n const [hoveredIndex, setHoveredIndex] = useState(null);\n\n const readOnlyMode = !onChange;\n\n return (\n
\n {Array.from({ length: max }).map((_, index) => (\n !readOnlyMode && setHoveredIndex(index)}\n onMouseLeave={() => !readOnlyMode && setHoveredIndex(null)}\n className={clsx(\n !readOnlyMode && 'cursor-pointer',\n selected ? 'text-yellow-500' : 'text-yellow-400'\n )}\n onClick={() => onChange?.(index + 1)}>\n = index + 1\n }\n halfFilled={value < index + 1 && value > index}\n className={clsx(showHover && 'group-hover:stroke-indigo-200')}\n />\n \n ))}\n
\n );\n};\n\nexport default Rating;\n" + }, + "/src/components/ui/Rating/Star.jsx": { + "code": "import clsx from 'clsx';\n\nconst Star = ({ filled, halfFilled, className }) => {\n return filled ? (\n \n \n \n ) : halfFilled ? (\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n ) : (\n \n \n \n );\n};\n\nexport default Star;\n" + }, + "/src/components/ui/SlideOut/index.js": { + "code": "import SlideOut from './SlideOut';\n\nexport default SlideOut;\n" + }, + "/src/components/ui/SlideOut/SlideOut.jsx": { + "code": "import { createPortal } from 'react-dom';\nimport clsx from 'clsx';\nimport { RiCloseLine } from 'react-icons/ri';\nimport { useEffect } from 'react';\n\nconst SlideOut = ({\n children,\n isShown,\n trigger,\n title,\n onClose,\n className,\n}) => {\n useEffect(() => {\n if (isShown) {\n document.body.style.overflow = 'hidden';\n } else {\n document.body.style.overflow = 'auto';\n }\n return () => {\n document.body.style.overflow = 'auto';\n };\n }, [isShown]);\n\n return (\n <>\n {trigger}\n\n {isShown &&\n createPortal(\n \n \n \n \n {title}\n \n \n \n \n
\n
\n
{children}
\n \n ,\n document.body\n )}\n \n );\n};\n\nexport default SlideOut;\n" + }, + "/src/components/ui/Tabs/index.js": { + "code": "import Tabs from './Tabs';\n\nexport default Tabs;\n" + }, + "/src/components/ui/Tabs/Tabs.jsx": { + "code": "import clsx from 'clsx';\n\nconst Tabs = ({ label, tabs, value, onSelect }) => {\n return (\n
\n \n \n
\n \n );\n};\n\nexport default Tabs;\n" + }, + "/src/components/ui/TextInput/index.js": { + "code": "import TextInput from './TextInput';\n\nexport default TextInput;\n" + }, + "/src/components/ui/TextInput/TextInput.jsx": { + "code": "import clsx from 'clsx';\nimport { useId } from 'react';\n\nconst TextInput = ({\n label,\n placeholder,\n value,\n onChange,\n type,\n id: idParam,\n required,\n isDisabled,\n errorMessage,\n hintMessage,\n startIcon: StartIcon,\n endIcon: EndIcon,\n startIconClassName,\n endIconClassName,\n}) => {\n const generateId = useId();\n const id = idParam ?? generateId;\n const hasError = !!errorMessage;\n\n const messageId = useId();\n\n const hasBottomSection = !!errorMessage || !!hintMessage;\n\n return (\n
\n {label && (\n \n {label}\n \n )}\n
\n {StartIcon && (\n
\n \n
\n )}\n\n onChange(event.target.value, event)}\n required={required}\n disabled={isDisabled}\n className={clsx(\n 'block w-full',\n 'py-[9px] px-[13px]',\n 'outline:none',\n 'border border-neutral-200 disabled:border-neutral-100',\n 'bg-neutral-50',\n 'rounded',\n 'text-sm text-neutral-900 placeholder:text-neutral-500 disabled:text-neutral-400 disabled:placeholder:text-neutral-400',\n 'focus:outline-none',\n 'focus:ring-4 focus:ring-offset-0 focus:ring-indigo-600/[.12] focus:border-indigo-600',\n hasError && 'focus:ring-red-600/[.12] focus:border-red-600',\n 'disabled:pointer-events-none',\n StartIcon && 'pl-[41px]',\n EndIcon && 'pr-[38px]'\n )}\n />\n\n {EndIcon && (\n
\n \n
\n )}\n
\n\n {hasBottomSection && (\n \n {errorMessage || hintMessage}\n
\n )}\n \n );\n};\n\nexport default TextInput;\n" + }, + "/src/components/ui/Toast/index.js": { + "code": "import Toast from './Toast';\n\nexport default Toast;\n" + }, + "/src/components/ui/Toast/Toast.jsx": { + "code": "import clsx from 'clsx';\nimport React from 'react';\n\nconst Toast = ({ type, message }) => {\n const badge = (\n \n {type === 'error' ? 'Error' : 'Success'}\n \n );\n\n return (\n
\n \n {badge}\n {message}\n
\n \n );\n};\n\nexport default Toast;\n" + }, + "/src/components/ui/Tooltip/index.js": { + "code": "import Tooltip from './Tooltip';\n\nexport default Tooltip;\n" + }, + "/src/components/ui/Tooltip/Tooltip.jsx": { + "code": "import clsx from 'clsx';\nimport { useState } from 'react';\n\nconst Tooltip = ({ children, content, position = 'top', show = true }) => {\n const [visible, setVisible] = useState(false);\n\n const positions = {\n top: 'bottom-full left-1/2 transform -translate-x-1/2 mb-2',\n bottom: 'top-full left-1/2 transform -translate-x-1/2 mt-2',\n left: 'right-full top-1/2 transform -translate-y-1/2 mr-2',\n right: 'left-full top-1/2 transform -translate-y-1/2 ml-2',\n };\n\n const arrowPositions = {\n top: 'bottom-[-4px] left-1/2 transform -translate-x-1/2 border-t-neutral-950 border-t-8 border-x-8 border-x-transparent',\n bottom:\n 'top-[-4px] left-1/2 transform -translate-x-1/2 border-b-neutral-950 border-b-8 border-x-8 border-x-transparent',\n left: 'right-[-4px] top-1/2 transform -translate-y-1/2 border-l-neutral-950 border-l-8 border-y-8 border-y-transparent',\n right:\n 'left-[-4px] top-1/2 transform -translate-y-1/2 border-r-neutral-950 border-r-8 border-y-8 border-y-transparent',\n };\n\n return (\n show && setVisible(true)}\n onMouseLeave={() => show && setVisible(false)}>\n {children}\n {visible && (\n \n {content}\n
\n
\n )}\n \n );\n};\n\nexport default Tooltip;\n" + }, + "/src/constants.js": { + "code": "export const COLORS = {\n white: { value: '#fff', label: 'White' },\n black: { value: '#000', label: 'Black' },\n red: { value: '#DC2626', label: 'Red' },\n orange: { value: '#EA580C', label: 'Orange' },\n yellow: { value: '#F59E0B', label: 'Yellow' },\n green: { value: '#10B981', label: 'Green' },\n blue: { value: '#4F46E5', label: 'Blue' },\n brown: { value: '#CA8A04', label: 'Brown' },\n beige: { value: '#d2b08a', label: 'Beige' },\n pink: { value: '#EC4899', label: 'Pink' },\n};\n\nexport const COLLECTIONS_OPTIONS = {\n title: 'Collections',\n key: 'collection',\n items: [\n {\n name: 'Latest arrivals',\n value: 'latest',\n href: '/products?collectionId=latest',\n },\n {\n name: 'Urban Oasis',\n value: 'urban',\n href: '/products?collectionId=urban',\n },\n {\n name: 'Cozy Comfort',\n value: 'cozy',\n href: '/products?collectionId=cozy',\n },\n {\n name: 'Fresh Fusion',\n value: 'fresh',\n href: '/products?collectionId=fresh',\n },\n ],\n};\n\nexport const CATEGORY_OPTIONS = {\n title: 'Category',\n key: 'category',\n items: [\n {\n name: 'Unisex',\n value: 'unisex',\n href: '/products?categoryId=unisex',\n },\n {\n name: 'Women',\n value: 'women',\n href: '/products?categoryId=women',\n },\n {\n name: 'Men',\n value: 'men',\n href: '/products?categoryId=men',\n },\n ],\n};\n\nexport const COLORS_OPTIONS = {\n title: 'Colors',\n key: 'color',\n items: [\n {\n color: COLORS.white.value,\n value: 'white',\n },\n {\n color: COLORS.black.value,\n value: 'black',\n },\n {\n color: COLORS.red.value,\n value: 'red',\n },\n {\n color: COLORS.orange.value,\n value: 'orange',\n },\n {\n color: COLORS.yellow.value,\n value: 'yellow',\n },\n {\n color: COLORS.green.value,\n value: 'green',\n },\n {\n color: COLORS.blue.value,\n value: 'blue',\n },\n {\n color: COLORS.brown.value,\n value: 'brown',\n },\n {\n color: COLORS.beige.value,\n value: 'beige',\n },\n {\n color: COLORS.pink.value,\n value: 'pink',\n },\n ],\n};\n\nexport const RATING_OPTIONS = {\n title: 'Rating',\n key: 'rating',\n items: [\n {\n value: 5,\n name: '5 star rating',\n },\n {\n value: 4,\n name: '4 star rating',\n },\n {\n value: 3,\n name: '3 star rating',\n },\n {\n value: 2,\n name: '2 star rating',\n },\n {\n value: 1,\n name: '1 star rating',\n },\n ],\n};\n\nexport const SORT_OPTIONS = [\n {\n name: 'Newest',\n value: 'created',\n direction: 'desc',\n },\n {\n name: 'Best rating',\n value: 'rating',\n direction: 'desc',\n },\n {\n name: 'Most popular',\n value: 'popularity',\n direction: 'desc',\n },\n {\n name: 'Price: Low to high',\n value: 'price',\n direction: 'asc',\n },\n {\n name: 'Price: High to low',\n value: 'price',\n direction: 'desc',\n },\n];\n" + }, + "/src/context/CartContext.jsx": { + "code": "import {\n createContext,\n useState,\n useEffect,\n useContext,\n useMemo,\n useCallback,\n} from 'react';\n\nconst CartContext = createContext();\n\nexport const useCartContext = () => useContext(CartContext);\n\nconst CartContextProvider = ({ children }) => {\n const [cartItems, setCartItems] = useState([]);\n\n useEffect(() => {\n // Retrieve cart from localStorage\n const storedCart = JSON.parse(localStorage.getItem('cart')) || [];\n setCartItems(storedCart);\n }, []);\n\n const addToCart = useCallback(\n item => {\n const existingItem = cartItems.find(\n cartItem =>\n cartItem.id === item.id &&\n cartItem.color === item.color &&\n cartItem.size === item.size\n );\n\n let updatedCart;\n if (existingItem) {\n updatedCart = cartItems.map(cartItem =>\n cartItem.id === item.id &&\n cartItem.color === item.color &&\n cartItem.size === item.size\n ? { ...cartItem, quantity: item.quantity }\n : cartItem\n );\n } else {\n updatedCart = [...cartItems, item];\n }\n\n setCartItems(updatedCart);\n localStorage.setItem('cart', JSON.stringify(updatedCart));\n },\n [cartItems]\n );\n\n const removeFromCart = useCallback(\n (itemId, color, size) => {\n const updatedCart = cartItems.filter(\n item =>\n !(item.id === itemId && item.color === color && item.size === size)\n );\n setCartItems(updatedCart);\n localStorage.setItem('cart', JSON.stringify(updatedCart));\n },\n [cartItems]\n );\n\n const value = useMemo(\n () => ({ cartItems, addToCart, removeFromCart }),\n [cartItems, addToCart, removeFromCart]\n );\n\n return {children};\n};\n\nexport default CartContextProvider;\n" + }, + "/src/context/ToastContext.jsx": { + "code": "import {\n createContext,\n useCallback,\n useContext,\n useMemo,\n useState,\n} from 'react';\n\nconst ToastContext = createContext({\n toast: {\n show: false,\n type: '',\n message: '',\n },\n showToast: () => {},\n});\n\nexport const useToast = () => {\n const { showToast } = useContext(ToastContext);\n\n const error = message => showToast('error', message);\n const success = message => showToast('success', message);\n\n return { error, success };\n};\n\nexport const useToastContext = () => useContext(ToastContext);\n\nconst ToastContextProvider = ({ children }) => {\n const [toast, setToast] = useState({\n show: false,\n type: '',\n message: '',\n });\n\n const showToast = useCallback((type, message) => {\n setToast({\n show: true,\n type,\n message,\n });\n setTimeout(() => {\n setToast({\n show: false,\n type: '',\n message: '',\n });\n }, 10000);\n }, []);\n\n const value = useMemo(() => {\n return {\n toast,\n showToast,\n };\n }, [toast, showToast]);\n\n return (\n {children}\n );\n};\n\nexport default ToastContextProvider;\n" + }, + "/src/index.css": { + "code": "@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap');\n\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nbody {\n font-family:\n 'Noto Sans',\n system-ui,\n -apple-system,\n BlinkMacSystemFont,\n 'Segoe UI',\n Roboto,\n Oxygen,\n Ubuntu,\n Cantarell,\n 'Open Sans',\n 'Helvetica Neue',\n sans-serif;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n text-rendering: optimizeLegibility;\n background: linear-gradient(147.52deg, #f9fafb 8.89%, #d2d6db 100.48%);\n}\n\n/* Custom z-index */\n.z-sticky {\n z-index: 1020;\n}\n.z-fixed {\n z-index: 1030;\n}\n.z-dropdown {\n z-index: 1000;\n}\n.z-modal {\n z-index: 1055;\n}\n.z-toast {\n z-index: 1090;\n}\n\n/* Custom animations and keyframes */\n@keyframes slideout {\n from {\n transform: translateX(-100%);\n }\n to {\n transform: translateX(0%);\n }\n}\n\n.animate-slideout {\n animation: slideout 0.4s ease-out;\n}\n\n/* Custom box shadow */\n.shadow-custom {\n box-shadow:\n 0px 1px 2px 0 rgb(0 0 0 / 0.06),\n 0px 1px 3px 0 rgb(0 0 0 / 0.1);\n}\n.shadow-input {\n box-shadow:\n 0px 0px 0px 1px #444ce7,\n 0px 1px 2px rgba(16, 24, 40, 0.05),\n 0px 0px 0px 4px rgba(68, 76, 231, 0.12);\n}\n\n/* Custom background */\n.bg-collection {\n background: linear-gradient(\n 60deg,\n rgba(0, 0, 0, 0.4) -9.37%,\n rgba(0, 0, 0, 0.132) 100%\n );\n}\n.bg-collection-hover {\n background: linear-gradient(\n 360deg,\n rgba(0, 0, 0, 0.6) -9.37%,\n rgba(0, 0, 0, 0.198) 100%\n );\n}\n" + }, + "/src/index.js": { + "code": "import React from 'react';\nimport ReactDOM from 'react-dom/client';\nimport { BrowserRouter as Router } from 'react-router-dom';\nimport './index.css';\nimport App from './App';\nimport ScrollToTop from './components/ScrollToTop';\n\nconst root = ReactDOM.createRoot(document.getElementById('root'));\nroot.render(\n \n \n \n \n \n \n);\n" + }, + "/src/Layout.jsx": { + "code": "import clsx from 'clsx';\nimport { Outlet } from 'react-router-dom';\n\nimport Toast from 'src/components/ui/Toast';\nimport Footer from 'src/components/Footer';\n\nimport { useToastContext } from './context/ToastContext';\nimport Navbar from './components/Navbar';\n\nconst Layout = () => {\n const { toast } = useToastContext();\n\n return (\n <>\n \n
\n {toast.show && }\n \n \n
\n \n
\n \n );\n};\n\nexport default Layout;\n" + }, + "/src/pages/LatestArrivals/components/LatestArrivalsSection.jsx": { + "code": "import { useEffect, useState } from 'react';\n\nimport Button from 'src/components/ui/Button';\nimport ProductGridSection from 'src/components/ProductGridSection';\nimport clsx from 'clsx';\n\nconst LatestArrivalsSection = ({ className }) => {\n const [products, setProducts] = useState([]);\n const [isProductsLoading, setIsProductsLoading] = useState(true);\n\n const getLatestArrivalProducts = async () => {\n setIsProductsLoading(true);\n\n const data = await fetch(\n `https://www.greatfrontend.com/api/projects/challenges/e-commerce/products?collection=latest`\n );\n const result = await data.json();\n\n if (!result.error) {\n setProducts(result.data);\n }\n setIsProductsLoading(false);\n };\n\n useEffect(() => {\n getLatestArrivalProducts();\n }, []);\n\n return (\n \n
\n
\n Latest Arrivals\n
\n \n
\n {isProductsLoading ? (\n
\n Loading...\n
\n ) : (\n \n )}\n \n );\n};\n\nexport default LatestArrivalsSection;\n" + }, + "/src/pages/LatestArrivals/index.js": { + "code": "import LatestArrivalsPage from './LatestArrivalsPage';\n\nexport default LatestArrivalsPage;\n" + }, + "/src/pages/LatestArrivals/LatestArrivalsPage.jsx": { + "code": "import LatestArrivalsSection from './components/LatestArrivalsSection';\n\nconst LatestArrivalsPage = () => {\n return ;\n};\n\nexport default LatestArrivalsPage;\n" + }, + "/src/pages/ProductDetail/components/AvailableColors.jsx": { + "code": "import { useMemo } from 'react';\n\nimport ColorSwatches from 'src/components/ui/ColorSwatches';\nimport { useProductDetailsContext } from './ProductDetailsContext';\nimport { COLORS } from 'src/constants';\nimport { getUnavailableColors } from '../utils';\n\nconst AvailableColors = () => {\n const { selectedColor, setSelectedColor, product } =\n useProductDetailsContext();\n const { colors } = product;\n const unavailableColors = useMemo(\n () => getUnavailableColors(product),\n [product]\n );\n\n return (\n
\n Available Colors\n
\n {colors.map(color => (\n setSelectedColor(color)}\n />\n ))}\n
\n
\n );\n};\n\nexport default AvailableColors;\n" + }, + "/src/pages/ProductDetail/components/AvailableSizes.jsx": { + "code": "import clsx from 'clsx';\n\nimport { useProductDetailsContext } from './ProductDetailsContext';\nimport { getUnavailableSizes } from '../utils';\n\nconst SIZE_MAP = {\n xs: 'XS',\n sm: 'S',\n md: 'M',\n lg: 'L',\n xl: 'XL',\n};\n\nconst AvailableSizes = () => {\n const { selectedSize, setSelectedSize, selectedColor, product } =\n useProductDetailsContext();\n const { sizes } = product;\n const unavailableSizes = getUnavailableSizes({\n product,\n color: selectedColor,\n });\n\n return (\n
\n Available Sizes\n\n
\n {sizes.map(size => {\n const outOfStock = unavailableSizes.includes(size);\n\n return (\n \n setSelectedSize(size)}\n />\n {\n if (e.key === 'Enter' || e.key === ' ') {\n setSelectedSize(size);\n }\n }}>\n {SIZE_MAP[size] ? SIZE_MAP[size] : size}\n \n \n );\n })}\n
\n
\n );\n};\n\nexport default AvailableSizes;\n" + }, + "/src/pages/ProductDetail/components/InfoSection.jsx": { + "code": "import {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from 'src/components/ui/Accordion';\n\nimport { useProductDetailsContext } from './ProductDetailsContext';\n\nconst InfoSection = () => {\n const { product } = useProductDetailsContext();\n const { info } = product;\n\n return (\n
\n \n {info.map(item => (\n \n {item.title}\n \n
    \n {item.description.map(descItem => (\n
  • {descItem}
  • \n ))}\n
\n
\n
\n ))}\n
\n
\n );\n};\n\nexport default InfoSection;\n" + }, + "/src/pages/ProductDetail/components/ProductCollectionSection.jsx": { + "code": "import clsx from 'clsx';\nimport { useCallback, useEffect, useState } from 'react';\nimport { useParams } from 'react-router-dom';\n\nimport ProductGridSection from 'src/components/ProductGridSection';\n\nconst ProductCollectionSection = () => {\n const { productId } = useParams();\n const [collectionProducts, setCollectionsProducts] = useState([]);\n const [isCollectionProductsLoading, setIsCollectionProductsLoading] =\n useState(true);\n\n const getCollectionsProducts = useCallback(async () => {\n setIsCollectionProductsLoading(true);\n\n const data = await fetch(\n `https://www.greatfrontend.com/api/projects/challenges/e-commerce/products?collection=latest`\n );\n const result = await data.json();\n\n if (!result.error) {\n setCollectionsProducts(\n result.data.filter(item => item.product_id !== productId).slice(0, 4)\n );\n }\n setIsCollectionProductsLoading(false);\n }, [productId]);\n\n useEffect(() => {\n getCollectionsProducts();\n }, [getCollectionsProducts]);\n\n return (\n \n \n In this collection\n \n {isCollectionProductsLoading ? (\n
Loading...
\n ) : (\n \n )}\n \n );\n};\n\nexport default ProductCollectionSection;\n" + }, + "/src/pages/ProductDetail/components/ProductDetailsContext.jsx": { + "code": "import {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n} from 'react';\nimport { useNavigate, useParams } from 'react-router-dom';\nimport { getUnavailableSizes } from '../utils';\n\nconst ProductDetailsContext = createContext();\n\nexport const useProductDetailsContext = () => useContext(ProductDetailsContext);\n\nconst ProductDetailsContextProvider = ({ children }) => {\n const navigate = useNavigate();\n const { productId } = useParams();\n const [product, setProduct] = useState(null);\n const [isProductLoading, setIsProductLoading] = useState(false);\n const [selectedColor, setSelectedColor] = useState(null);\n const [selectedSize, setSelectedSize] = useState(null);\n const [itemQuantity, setItemQuantity] = useState(1);\n\n const getProduct = useCallback(async () => {\n setIsProductLoading(true);\n const data = await fetch(\n `https://www.greatfrontend.com/api/projects/challenges/e-commerce/products/${productId}`\n );\n const result = await data.json();\n\n if (!result.error) {\n setProduct(result);\n setSelectedColor(result.colors[0]);\n } else {\n return navigate('/not-found');\n }\n setIsProductLoading(false);\n }, [productId, navigate]);\n\n const decrementQuantity = useCallback(() => {\n setItemQuantity(prev => (prev > 1 ? prev - 1 : 1));\n }, []);\n\n const incrementQuantity = useCallback(() => {\n setItemQuantity(prev => prev + 1);\n }, []);\n\n useEffect(() => {\n getProduct();\n }, [getProduct]);\n\n // Set first available size as the initial selected size\n useEffect(() => {\n if (!product || !selectedColor) {\n return;\n }\n\n const unavailableSizes = getUnavailableSizes({\n product,\n color: selectedColor,\n });\n const availableSizes = [...product.sizes].filter(\n size => !unavailableSizes.includes(size)\n );\n if (availableSizes.length > 0) {\n setSelectedSize(availableSizes[0]);\n }\n }, [selectedColor, product]);\n\n // Reset item quantity to 1 when we change color or size\n useEffect(() => {\n setItemQuantity(1);\n }, [selectedColor, selectedSize]);\n\n const value = useMemo(() => {\n return {\n product,\n isProductLoading,\n selectedColor,\n selectedSize,\n itemQuantity,\n setSelectedColor,\n setSelectedSize,\n incrementQuantity,\n decrementQuantity,\n };\n }, [\n product,\n isProductLoading,\n selectedColor,\n setSelectedColor,\n selectedSize,\n setSelectedSize,\n itemQuantity,\n incrementQuantity,\n decrementQuantity,\n ]);\n\n return (\n \n {children}\n \n );\n};\n\nexport default ProductDetailsContextProvider;\n" + }, + "/src/pages/ProductDetail/components/ProductDetailSection.jsx": { + "code": "import clsx from 'clsx';\n\nimport ProductImages from './ProductImages';\nimport ProductMetadata from './ProductMetadata';\n\nimport { useProductDetailsContext } from './ProductDetailsContext';\n\nconst ProductDetail = () => {\n const { isProductLoading, product } = useProductDetailsContext();\n\n return (\n \n {isProductLoading || !product ? (\n \n Loading...\n \n ) : (\n <>\n
\n \n
\n
\n \n
\n \n )}\n \n );\n};\n\nexport default ProductDetail;\n" + }, + "/src/pages/ProductDetail/components/ProductImages.jsx": { + "code": "import { useState } from 'react';\nimport clsx from 'clsx';\n\nimport { useProductDetailsContext } from './ProductDetailsContext';\nimport { getSelectedColorImages } from '../utils';\n\nconst ProductImages = () => {\n const { product, selectedColor } = useProductDetailsContext();\n const [selectedPreview, setSelectedPreview] = useState(0);\n\n const images = getSelectedColorImages({ product, color: selectedColor });\n\n return (\n
\n \n
\n {images.map((image, index) => (\n setSelectedPreview(index)}\n className={clsx(\n 'rounded-lg shrink-0 block',\n 'w-20 h-[120px] md:h-[190px] md:w-[188px] lg:w-40 object-cover',\n 'cursor-pointer',\n index === selectedPreview && 'border-[3px] border-indigo-600'\n )}\n />\n ))}\n
\n
\n );\n};\n\nexport default ProductImages;\n" + }, + "/src/pages/ProductDetail/components/ProductMetadata.jsx": { + "code": "import { useMemo } from 'react';\nimport clsx from 'clsx';\nimport { useMediaQuery } from 'usehooks-ts';\n\nimport Badge from 'src/components/ui/Badge';\nimport Button from 'src/components/ui/Button';\nimport Rating from 'src/components/ui/Rating';\nimport AvailableColors from './AvailableColors';\nimport AvailableSizes from './AvailableSizes';\nimport ProductQuantity from './ProductQuantity';\nimport InfoSection from './InfoSection';\n\nimport { useProductDetailsContext } from './ProductDetailsContext';\nimport { useCartContext } from 'src/context/CartContext';\nimport { getInventoryData } from '../utils';\n\nconst ProductMetadata = () => {\n const isMobileAndBelow = useMediaQuery('(max-width: 767px)');\n const { product, selectedColor, selectedSize, itemQuantity } =\n useProductDetailsContext();\n const { addToCart } = useCartContext();\n\n const { name, description, reviews, rating } = product;\n\n const inventoryData = useMemo(\n () =>\n getInventoryData({ product, color: selectedColor, size: selectedSize }),\n [selectedColor, selectedSize, product]\n );\n const { discount_percentage, list_price, sale_price, stock } = inventoryData;\n\n const roundedRating = Math.round(rating * 10) / 10;\n const hasDiscount = !!discount_percentage;\n\n const onAddToCart = e => {\n e.preventDefault();\n\n const item = {\n id: product.product_id,\n quantity: itemQuantity,\n color: selectedColor,\n size: selectedSize,\n };\n addToCart(item);\n };\n\n return (\n
\n \n
\n

{name}

\n
\n
\n \n ${hasDiscount ? sale_price : list_price}\n \n {hasDiscount && (\n \n ${list_price}\n \n )}\n
\n
\n {hasDiscount && (\n
\n \n
\n )}\n\n
\n
{roundedRating ?? 0}
\n \n {reviews > 0 ? (\n \n ) : (\n
\n \n No reviews yet.\n \n \n
\n )}\n
\n
\n\n

{description}

\n \n\n
\n
\n \n \n \n\n {/* Out of stock message */}\n {stock === 0 && (\n
\n Sorry, this item is out of stock\n
\n )}\n\n \n \n
\n\n \n
\n );\n};\n\nexport default ProductMetadata;\n" + }, + "/src/pages/ProductDetail/components/ProductQuantity.jsx": { + "code": "import CartControl from 'src/components/CartControl';\nimport { useProductDetailsContext } from './ProductDetailsContext';\n\nconst ProductQuantity = ({ availableStock }) => {\n const { itemQuantity, incrementQuantity, decrementQuantity } =\n useProductDetailsContext();\n\n return (\n
\n Quantity\n
\n \n
\n
\n );\n};\n\nexport default ProductQuantity;\n" + }, + "/src/pages/ProductDetail/index.js": { + "code": "import ProductDetailPage from './ProductDetailPage';\n\nexport default ProductDetailPage;\n" + }, + "/src/pages/ProductDetail/ProductDetailPage.jsx": { + "code": "import ProductSpecificationSection from 'src/components/ProductSpecificationSection';\n\nimport ProductCollectionSection from './components/ProductCollectionSection';\nimport ProductDetailSection from './components/ProductDetailSection';\nimport ProductDetailsContextProvider from './components/ProductDetailsContext';\n\nconst ProductDetailPage = () => {\n return (\n <>\n \n \n \n \n \n \n );\n};\n\nexport default ProductDetailPage;\n" + }, + "/src/pages/ProductDetail/utils.js": { + "code": "export const getUnavailableColors = product => {\n const colorsInStock = new Set();\n const allColors = new Set(product.colors);\n\n product.inventory.forEach(item => {\n if (item.stock > 0) {\n colorsInStock.add(item.color);\n }\n });\n\n const unavailableColors = [...allColors].filter(\n color => !colorsInStock.has(color)\n );\n\n return unavailableColors;\n};\n\nexport const getUnavailableSizes = ({ product, color }) => {\n const sizesInStock = new Set();\n const allSizes = new Set(product.sizes);\n\n product.inventory.forEach(item => {\n if (item.stock > 0 && item.color === color) {\n sizesInStock.add(item.size);\n }\n });\n\n const unavailableSizes = [...allSizes].filter(\n size => !sizesInStock.has(size)\n );\n\n return unavailableSizes;\n};\n\nexport const getInventoryData = ({ product, color, size }) => {\n let data = {};\n product.inventory.forEach(item => {\n if (item.size === size && item.color === color) {\n data = item;\n }\n });\n\n return data;\n};\n\nexport const getSelectedColorImages = ({ product, color }) => {\n const images = product.images?.filter(image => image.color === color);\n return images;\n};\n" + }, + "/src/pages/ProductListing/components/Accordion/Accordion.jsx": { + "code": "import clsx from 'clsx';\nimport { useState, useRef, createContext, useContext } from 'react';\nimport { RiAddLine, RiSubtractLine } from 'react-icons/ri';\n\nconst AccordionItemContext = createContext();\n\nconst AccordionItem = ({ children, id }) => {\n const [isOpen, setIsOpen] = useState(true);\n\n return (\n
\n \n {children}\n \n
\n );\n};\n\nconst AccordionTrigger = ({ children }) => {\n const { id, isOpen, setIsOpen } = useContext(AccordionItemContext);\n const Icon = isOpen ? RiSubtractLine : RiAddLine;\n return (\n setIsOpen(!isOpen)}\n aria-expanded={isOpen}\n aria-controls={`accordion-content-${id}`}\n id={`accordion-header-${id}`}>\n {children}\n \n \n );\n};\n\nconst AccordionContent = ({ children }) => {\n const contentRef = useRef(null);\n const { id, isOpen } = useContext(AccordionItemContext);\n\n return (\n \n
{children}
\n \n );\n};\n\nconst Accordion = ({ children }) => {\n const hasMultipleItem = Array.isArray(children);\n return (\n
\n {!hasMultipleItem\n ? children\n : children.map((item, index) => (\n
\n {item}\n
\n
\n ))}\n
\n );\n};\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent };\n" + }, + "/src/pages/ProductListing/components/Accordion/index.js": { + "code": "import * as Accordion from './Accordion';\n\nexport * from './Accordion';\nexport default Accordion;\n" + }, + "/src/pages/ProductListing/components/Filter.jsx": { + "code": "import clsx from 'clsx';\nimport { RiFilterLine } from 'react-icons/ri';\nimport { useState } from 'react';\n\nimport CheckboxInput from 'src/components/ui/CheckboxInput';\nimport ColorSwatches from 'src/components/ui/ColorSwatches';\nimport SlideOut from 'src/components/ui/SlideOut';\nimport Button from 'src/components/ui/Button';\nimport Rating from 'src/components/ui/Rating/Rating';\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from 'src/pages/ProductListing/components/Accordion';\n\nimport { useProductListingContext } from './ProductListingContext';\nimport {\n CATEGORY_OPTIONS,\n COLLECTIONS_OPTIONS,\n COLORS_OPTIONS,\n RATING_OPTIONS,\n} from 'src/constants';\n\nconst Filter = () => {\n const {\n selectedCategory,\n selectedCollections,\n selectedColors,\n selectedRating,\n filterCount,\n onSelect,\n resetFilters,\n } = useProductListingContext();\n const [isFilterOpen, setIsFilterOpen] = useState(false);\n\n const filterItems = (\n
\n \n \n {COLLECTIONS_OPTIONS.title}\n \n
\n {COLLECTIONS_OPTIONS.items.map(({ name, value }) => (\n onSelect(COLLECTIONS_OPTIONS.key, value)}\n />\n ))}\n
\n
\n
\n\n \n {CATEGORY_OPTIONS.title}\n \n
\n {CATEGORY_OPTIONS.items.map(({ name, value }) => (\n onSelect(CATEGORY_OPTIONS.key, value)}\n />\n ))}\n
\n
\n
\n\n \n {COLORS_OPTIONS.title}\n \n
\n {COLORS_OPTIONS.items.map(({ color, value }) => (\n onSelect(COLORS_OPTIONS.key, value)}\n size=\"sm\"\n />\n ))}\n
\n
\n
\n\n \n {RATING_OPTIONS.title}\n \n
\n {RATING_OPTIONS.items.map(({ value }) => (\n onSelect(RATING_OPTIONS.key, value)}>\n \n \n ))}\n
\n
\n
\n
\n {filterCount > 0 && (\n {\n resetFilters();\n setIsFilterOpen(false);\n }}\n label={`Clear All (${filterCount})`}\n variant=\"link\"\n size=\"lg\"\n />\n )}\n
\n );\n\n return (\n
\n
{filterItems}
\n
\n Filter}\n onClose={() => setIsFilterOpen(false)}\n trigger={\n setIsFilterOpen(!isFilterOpen)}\n />\n }>\n {filterItems}\n \n
\n
\n );\n};\n\nexport default Filter;\n" + }, + "/src/pages/ProductListing/components/hooks/useProductFilters.js": { + "code": "import { useEffect, useRef, useState } from 'react';\nimport { useLocation } from 'react-router-dom';\nimport {\n CATEGORY_OPTIONS,\n COLLECTIONS_OPTIONS,\n COLORS_OPTIONS,\n RATING_OPTIONS,\n} from 'src/constants';\n\nexport default function useProductFilters() {\n const { search } = useLocation();\n const isMounted = useRef(false);\n\n const query = new URLSearchParams(search);\n const collectionId = query.get('collectionId');\n const categoryId = query.get('categoryId');\n\n const [selectedCollections, setSelectedCollections] = useState(\n collectionId ? new Set().add(collectionId) : new Set()\n );\n const [selectedCategory, setSelectedCategory] = useState(\n categoryId ? new Set().add(categoryId) : new Set()\n );\n const [selectedColors, setSelectedColors] = useState(new Set());\n const [selectedRating, setSelectedRating] = useState(new Set());\n const [selectedSort, setSelectedSort] = useState({\n value: 'created',\n direction: 'desc',\n });\n\n const onSelect = (type, value) => {\n let newSelectedItems;\n if (type === COLLECTIONS_OPTIONS.key) {\n newSelectedItems = new Set(selectedCollections);\n }\n if (type === CATEGORY_OPTIONS.key) {\n newSelectedItems = new Set(selectedCategory);\n }\n if (type === COLORS_OPTIONS.key) {\n newSelectedItems = new Set(selectedColors);\n }\n if (type === RATING_OPTIONS.key) {\n newSelectedItems = new Set(selectedRating);\n }\n\n newSelectedItems.has(value)\n ? newSelectedItems.delete(value)\n : newSelectedItems.add(value);\n\n if (type === COLLECTIONS_OPTIONS.key) {\n setSelectedCollections(newSelectedItems);\n }\n if (type === CATEGORY_OPTIONS.key) {\n setSelectedCategory(newSelectedItems);\n }\n if (type === COLORS_OPTIONS.key) {\n setSelectedColors(newSelectedItems);\n }\n if (type === RATING_OPTIONS.key) {\n setSelectedRating(newSelectedItems);\n }\n };\n\n const resetFilters = () => {\n setSelectedCollections(new Set());\n setSelectedCategory(new Set());\n setSelectedColors(new Set());\n setSelectedRating(new Set());\n };\n\n const filterCount =\n selectedCollections.size +\n selectedCategory.size +\n selectedColors.size +\n selectedRating.size;\n\n useEffect(() => {\n // only run after mounted when the collectionId or categoryId query param change\n if (isMounted.current) {\n if (collectionId) {\n // Reset every filters when we query params is changed\n resetFilters();\n setSelectedCollections(new Set().add(collectionId));\n }\n if (categoryId) {\n // Reset every filters when we query params is changed\n resetFilters();\n setSelectedCategory(new Set().add(categoryId));\n }\n }\n isMounted.current = true;\n }, [collectionId, categoryId]);\n\n return {\n selectedCollections,\n selectedCategory,\n selectedColors,\n selectedRating,\n selectedSort,\n filterCount,\n onSelect,\n resetFilters,\n onSortChange: setSelectedSort,\n };\n}\n" + }, + "/src/pages/ProductListing/components/ProductListingContext.jsx": { + "code": "import {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n} from 'react';\nimport useProductFilters from './hooks/useProductFilters';\nimport {\n CATEGORY_OPTIONS,\n COLLECTIONS_OPTIONS,\n COLORS_OPTIONS,\n RATING_OPTIONS,\n} from 'src/constants';\n\nconst ProductListingContext = createContext();\n\nexport const useProductListingContext = () => useContext(ProductListingContext);\n\nconst ProductListingContextProvider = ({ children }) => {\n const [products, setProducts] = useState(null);\n const [isProductsLoading, setIsProductsLoading] = useState(true);\n const {\n selectedCollections,\n selectedCategory,\n selectedColors,\n selectedRating,\n selectedSort,\n filterCount,\n onSelect,\n resetFilters,\n onSortChange,\n } = useProductFilters();\n\n const getProducts = useCallback(\n async ({ colors, collections, ratings, categories, sort }) => {\n setIsProductsLoading(true);\n\n let queryString = '';\n if (\n colors.size > 0 ||\n collections.size > 0 ||\n ratings.size > 0 ||\n categories.size > 0\n ) {\n queryString = [\n ...Array.from(colors).map(\n color => `${COLORS_OPTIONS.key}=${encodeURIComponent(color)}`\n ),\n ...Array.from(collections).map(\n collection =>\n `${COLLECTIONS_OPTIONS.key}=${encodeURIComponent(collection)}`\n ),\n ...Array.from(ratings).map(\n rating => `${RATING_OPTIONS.key}=${encodeURIComponent(rating)}`\n ),\n ...Array.from(categories).map(\n category =>\n `${CATEGORY_OPTIONS.key}=${encodeURIComponent(category)}`\n ),\n ].join('&');\n }\n\n queryString = `${queryString ? `${queryString}&` : ''}sort=${\n sort.value\n }&direction=${sort.direction}`;\n\n const data = await fetch(\n `https://www.greatfrontend.com/api/projects/challenges/e-commerce/products${\n queryString ? `?${queryString}` : ''\n }`\n );\n const result = await data.json();\n\n if (!result.error) {\n setProducts(result.data);\n }\n setIsProductsLoading(false);\n },\n []\n );\n\n useEffect(() => {\n getProducts({\n colors: selectedColors,\n categories: selectedCategory,\n collections: selectedCollections,\n ratings: selectedRating,\n sort: selectedSort,\n });\n }, [\n getProducts,\n selectedColors,\n selectedCategory,\n selectedCollections,\n selectedRating,\n selectedSort,\n ]);\n\n const value = useMemo(() => {\n return {\n products,\n isProductsLoading,\n\n selectedCollections,\n selectedCategory,\n selectedColors,\n selectedRating,\n selectedSort,\n filterCount,\n onSelect,\n resetFilters,\n onSortChange,\n };\n }, [\n products,\n isProductsLoading,\n\n selectedCollections,\n selectedCategory,\n selectedColors,\n selectedRating,\n selectedSort,\n filterCount,\n onSelect,\n resetFilters,\n onSortChange,\n ]);\n\n return (\n \n {children}\n \n );\n};\n\nexport default ProductListingContextProvider;\n" + }, + "/src/pages/ProductListing/components/ProductListingSection.jsx": { + "code": "import { RiTShirt2Line } from 'react-icons/ri';\nimport clsx from 'clsx';\n\nimport ProductCard from 'src/components/ProductCard';\nimport Button from 'src/components/ui/Button';\n\nimport { useProductListingContext } from './ProductListingContext';\n\nconst ProductListingSection = () => {\n const { products, isProductsLoading, filterCount, resetFilters } =\n useProductListingContext();\n\n if (isProductsLoading) {\n return (\n \n Loading...\n
\n );\n }\n\n if (filterCount > 0 && products.length === 0) {\n return (\n \n \n \n \n \n Nothing found just yet\n \n Adjust your filters a bit, and let's see what we can find!\n \n \n + + {quantity} + + + + + + ); +}; + +export default CartControl; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/CartControl/index.js b/packages/projects/challenges/storefront-page/solutions/react/src/components/CartControl/index.js new file mode 100644 index 000000000..d2a9d888f --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/CartControl/index.js @@ -0,0 +1,3 @@ +import CartControl from './CartControl'; + +export default CartControl; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/CollectionCard/CollectionCard.jsx b/packages/projects/challenges/storefront-page/solutions/react/src/components/CollectionCard/CollectionCard.jsx new file mode 100644 index 000000000..b8beeaf35 --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/CollectionCard/CollectionCard.jsx @@ -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 ( +
+ {`${name}'s + +
+ {name} + {description} +
+ +
+ ); +}; + +export default CollectionCard; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/CollectionCard/index.js b/packages/projects/challenges/storefront-page/solutions/react/src/components/CollectionCard/index.js new file mode 100644 index 000000000..4419d8bb4 --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/CollectionCard/index.js @@ -0,0 +1,3 @@ +import CollectionCard from './CollectionCard'; + +export default CollectionCard; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/Footer/Footer.jsx b/packages/projects/challenges/storefront-page/solutions/react/src/components/Footer/Footer.jsx new file mode 100644 index 000000000..1dcb3bd5f --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/Footer/Footer.jsx @@ -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 ( +
+
+
+
+ Join our newsletter +
+
+ We’ll send you a nice letter once per week. No spam. +
+
+ +
+ +
+ +
+
+ Stylenest's Logo +
+
+ Craft stunning style journeys that weave more joy into every thread. +
+
+ +
+ +
+
SHOP CATEGORIES
+
+ {CATEGORY_OPTIONS.items.map((category) => ( + + {category.name} + + ))} +
+
+ +
+
SHOP COLLECTIONS
+
+ {COLLECTIONS_OPTIONS.items.map((collection) => ( + + {collection.name} + + ))} +
+
+
+ +
+
+ © {new Date().getFullYear()} StyleNest, Inc. All rights reserved. +
+
+ {footerSocials.map(({ icon: Icon, url, name }) => ( + +
+
+
+ ); +}; + +export default Footer; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/Footer/NewsletterForm.jsx b/packages/projects/challenges/storefront-page/solutions/react/src/components/Footer/NewsletterForm.jsx new file mode 100644 index 000000000..7b215b440 --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/Footer/NewsletterForm.jsx @@ -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 ( +
+ setEmail(value)} + value={email} + required + /> + + + {/* Mobile nav menu */} + {openMenu && + createPortal( + , + document.body, + )} + + ); +}; + +export default MobileNavMenu; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/Navbar/Navbar.jsx b/packages/projects/challenges/storefront-page/solutions/react/src/components/Navbar/Navbar.jsx new file mode 100644 index 000000000..ebba42ffb --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/Navbar/Navbar.jsx @@ -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 ( +
+ + Stylenest's Logo + + +
+ + +
+
+ ); +}; + +export default Navbar; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/Navbar/index.js b/packages/projects/challenges/storefront-page/solutions/react/src/components/Navbar/index.js new file mode 100644 index 000000000..4b0083cfc --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/Navbar/index.js @@ -0,0 +1,3 @@ +import Navbar from './Navbar'; + +export default Navbar; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductCard/ProductCard.jsx b/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductCard/ProductCard.jsx new file mode 100644 index 000000000..a8a639962 --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductCard/ProductCard.jsx @@ -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 ( +
+ {`${name}'s +
+ + {COLORS[color]?.label} + + + + {name} + +
+ + ${hasDiscount ? sale_price : list_price} + + {hasDiscount && ( + + ${list_price} + + )} +
+
+ {colors.map(color => ( + + ))} +
+
+
+ ); +}; + +export default ProductCard; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductCard/index.js b/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductCard/index.js new file mode 100644 index 000000000..038f412b5 --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductCard/index.js @@ -0,0 +1,3 @@ +import ProductCard from './ProductCard'; + +export default ProductCard; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductGridSection/ProductGridSection.jsx b/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductGridSection/ProductGridSection.jsx new file mode 100644 index 000000000..13498dde5 --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductGridSection/ProductGridSection.jsx @@ -0,0 +1,18 @@ +import clsx from 'clsx'; +import ProductCard from 'src/components/ProductCard'; + +const ProductGridSection = ({ products }) => { + return ( +
+ {products.map(product => ( +
+ +
+ ))} +
+ ); +}; + +export default ProductGridSection; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductGridSection/index.js b/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductGridSection/index.js new file mode 100644 index 000000000..146f1be28 --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductGridSection/index.js @@ -0,0 +1,3 @@ +import ProductGridSection from './ProductGridSection'; + +export default ProductGridSection; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductSpecificationSection/ProductSpecificationSection.jsx b/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductSpecificationSection/ProductSpecificationSection.jsx new file mode 100644 index 000000000..37f7eb480 --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductSpecificationSection/ProductSpecificationSection.jsx @@ -0,0 +1,248 @@ +import clsx from 'clsx'; +import { useState } from 'react'; +import { + RiColorFilterLine, + RiHandHeartLine, + RiInfinityFill, + RiPaintLine, + RiPlantLine, + RiPriceTag2Line, + RiRainbowLine, + RiRecycleLine, + RiScales2Line, + RiShapesLine, + RiShieldStarLine, + RiShirtLine, + RiStackLine, + RiTShirtLine, + RiWaterFlashLine, + RiWindyLine, +} from 'react-icons/ri'; + +import Tabs from '../ui/Tabs'; + +const TABS = [ + { label: 'Sustainability', value: 'sustainability' }, + { label: 'Comfort', value: 'comfort' }, + { label: 'Durability', value: 'durability' }, + { label: 'Versatility', value: 'versatility' }, +]; + +const specifications = [ + { + value: 'sustainability', + title: 'Eco-Friendly Choice', + description: + 'With our sustainable approach, we curate clothing that makes a statement of care—care for the planet, and for the art of fashion.', + img: { + desktop: + 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/yellow-desktop.jpg', + tablet: + 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/yellow-tablet.jpg', + mobile: + 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/yellow-mobile.jpg', + }, + items: [ + { + label: 'Recycled Materials', + icon: RiRecycleLine, + }, + { + label: 'Low Impact Dye', + icon: RiPaintLine, + }, + { + label: 'Carbon Neutral', + icon: RiPlantLine, + }, + { + label: 'Water Conservation', + icon: RiWaterFlashLine, + }, + ], + }, + { + value: 'comfort', + title: 'Uncompromised Comfort', + description: + 'Our garments are a sanctuary of softness, tailored to drape gracefully and allow for freedom of movement.', + img: { + desktop: + 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/black-desktop.jpg', + tablet: + 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/black-tablet.jpg', + mobile: + 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/black-mobile.jpg', + }, + items: [ + { + label: 'Ergonomic Fits', + icon: RiTShirtLine, + }, + { + label: 'Soft-to-the-Touch Fabrics', + icon: RiHandHeartLine, + }, + { + label: 'Breathable Weaves', + icon: RiWindyLine, + }, + { + label: 'Thoughtful Design', + icon: RiColorFilterLine, + }, + ], + }, + { + value: 'durability', + title: 'Built to Last', + description: + 'Here’s to apparel that you can trust to look as good as new, wear after wear, year after year.', + img: { + desktop: + 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/chair-desktop.jpg', + tablet: + 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/chair-tablet.jpg', + mobile: + 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/chair-mobile.jpg', + }, + items: [ + { + label: 'Reinforced Construction', + icon: RiStackLine, + }, + { + label: 'Quality Control', + icon: RiScales2Line, + }, + { + label: 'Material Resilience', + icon: RiShieldStarLine, + }, + { + label: 'Warranty and Repair', + icon: RiPriceTag2Line, + }, + ], + }, + { + value: 'versatility', + title: 'Versatile by Design', + description: + 'Our pieces are a celebration of versatility, offering a range of styles that are as perfect for a business meeting as they are for a casual brunch. ', + img: { + desktop: + 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/clothes-desktop.jpg', + tablet: + 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/clothes-tablet.jpg', + mobile: + 'https://vaqybtnqyonvlwtskzmv.supabase.co/storage/v1/object/public/e-commerce-track-images/product-specifications-section/clothes-mobile.jpg', + }, + items: [ + { + label: 'Adaptive Styles', + icon: RiRainbowLine, + }, + { + label: 'Functional Fashion', + icon: RiShirtLine, + }, + { + label: 'Timeless Aesthetics', + icon: RiInfinityFill, + }, + { + label: 'Mix-and-Match Potential', + icon: RiShapesLine, + }, + ], + }, +]; + +const ProductSpecificationSection = () => { + const [selectedSpecification, setSelectedSpecification] = + useState('sustainability'); + + const data = specifications.find( + (specification) => specification.value === selectedSpecification, + ); + + return ( +
+
+

+ Discover timeless elegance +

+

+ 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. +

+
+ +
+ +
+ + + + {`${selectedSpecification}'s + + +
+
+

+ {data.title} +

+

{data.description}

+
+
+ {data.items.map(({ label, icon: Icon }) => ( +
+
+ +
+ {label} +
+ ))} +
+
+
+
+
+ ); +}; + +export default ProductSpecificationSection; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductSpecificationSection/index.js b/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductSpecificationSection/index.js new file mode 100644 index 000000000..f9dee3d8f --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/ProductSpecificationSection/index.js @@ -0,0 +1,3 @@ +import ProductSpecificationSection from './ProductSpecificationSection'; + +export default ProductSpecificationSection; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/ScrollToTop/ScrollToTop.jsx b/packages/projects/challenges/storefront-page/solutions/react/src/components/ScrollToTop/ScrollToTop.jsx new file mode 100644 index 000000000..7d8c6f1a6 --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/ScrollToTop/ScrollToTop.jsx @@ -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; +} diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/ScrollToTop/index.js b/packages/projects/challenges/storefront-page/solutions/react/src/components/ScrollToTop/index.js new file mode 100644 index 000000000..920ae107a --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/ScrollToTop/index.js @@ -0,0 +1,3 @@ +import ScrollToTop from './ScrollToTop'; + +export default ScrollToTop; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/ui/Accordion/Accordion.jsx b/packages/projects/challenges/storefront-page/solutions/react/src/components/ui/Accordion/Accordion.jsx new file mode 100644 index 000000000..7e61b7c30 --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/ui/Accordion/Accordion.jsx @@ -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 ( +
+ + {children} + +
+ ); +}; + +const AccordionTrigger = ({ children }) => { + const { id, isOpen, setIsOpen } = useContext(AccordionItemContext); + const Icon = isOpen ? RiIndeterminateCircleLine : RiAddCircleLine; + return ( + + ); +}; + +const AccordionContent = ({ children }) => { + const contentRef = useRef(null); + const { id, isOpen } = useContext(AccordionItemContext); + + return ( +
+ {children} +
+ ); +}; + +const Accordion = ({ children }) => { + return ( +
+ {children.map((item, index) => ( +
+ {item} + {index !== children.length - 1 && ( +
+ )} +
+ ))} +
+ ); +}; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/ui/Accordion/index.js b/packages/projects/challenges/storefront-page/solutions/react/src/components/ui/Accordion/index.js new file mode 100644 index 000000000..11a94c76e --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/ui/Accordion/index.js @@ -0,0 +1,4 @@ +import * as Accordion from './Accordion'; + +export * from './Accordion'; +export default Accordion; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/ui/Badge/Badge.jsx b/packages/projects/challenges/storefront-page/solutions/react/src/components/ui/Badge/Badge.jsx new file mode 100644 index 000000000..97f6f47c9 --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/ui/Badge/Badge.jsx @@ -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 ( +
+ {label} +
+ ); +}; + +export default Badge; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/ui/Badge/index.js b/packages/projects/challenges/storefront-page/solutions/react/src/components/ui/Badge/index.js new file mode 100644 index 000000000..0979058c5 --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/ui/Badge/index.js @@ -0,0 +1,3 @@ +import Badge from './Badge'; + +export default Badge; diff --git a/packages/projects/challenges/storefront-page/solutions/react/src/components/ui/Button/Button.jsx b/packages/projects/challenges/storefront-page/solutions/react/src/components/ui/Button/Button.jsx new file mode 100644 index 000000000..3d45ef12b --- /dev/null +++ b/packages/projects/challenges/storefront-page/solutions/react/src/components/ui/Button/Button.jsx @@ -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 ( + + {StartIcon && ( +