[web] projects/challenge/solution: product details section solution (#779)

This commit is contained in:
Nitesh Seram 2024-08-12 10:54:17 +05:30 committed by GitHub
parent 9572f6d6e3
commit 2b95cb64bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1463 additions and 2 deletions

View File

@ -0,0 +1,122 @@
{
"files": {
"/jsconfig.json": {
"code": "{\n \"compilerOptions\": {\n \"baseUrl\": \".\"\n },\n \"include\": [\"src\"]\n}\n"
},
"/package.json": {
"code": "{\n \"name\": \"@gfe-challenges/product-details-section-solution\",\n \"homepage\": \"/products/voyager-hoodie\",\n \"version\": \"0.0.1\",\n \"dependencies\": {\n \"react-dom\": \"^18.3.1\",\n \"react-router-dom\": \"^6.23.1\",\n \"usehooks-ts\": \"^3.1.0\",\n \"clsx\": \"^2.1.1\",\n \"react\": \"^18.3.1\",\n \"react-scripts\": \"5.0.1\",\n \"react-icons\": \"^5.2.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": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"theme-color\" content=\"#000000\" />\n <title>Product Detail</title>\n </head>\n <body>\n <noscript>You need to enable JavaScript to run this app.</noscript>\n <div id=\"root\"></div>\n </body>\n</html>\n"
},
"/src/App.js": {
"code": "import { Routes, Route } from 'react-router-dom';\n\nimport ProductDetailPage from './pages/ProductDetail';\n\nfunction App() {\n return (\n <Routes>\n <Route path=\"/products/:productId\" element={<ProductDetailPage />} />\n </Routes>\n );\n}\n\nexport default App;\n"
},
"/src/components/CartControl/CartControl.jsx": {
"code": "import clsx from 'clsx';\nimport { RiAddFill, RiSubtractFill } from 'react-icons/ri';\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 <div\n className={clsx(\n 'h-9 w-[125px]',\n 'flex items-center justify-center gap-3',\n 'px-[5px] py-0.5',\n 'rounded-md border border-neutral-200 bg-neutral-50',\n )}\n role=\"group\"\n aria-label=\"Product Quantity control\">\n <button\n type=\"button\"\n className={clsx(\n 'flex items-center justify-center rounded',\n 'text-neutral-600 disabled:text-neutral-400',\n 'cursor-pointer disabled:pointer-events-none',\n )}\n disabled={disabledDecrement}\n onClick={decrement}\n aria-label=\"Decrease quantity\">\n <RiSubtractFill className=\"size-5 shrink-0 p-0.5\" />\n </button>\n <span\n className=\"flex-1 text-center text-sm font-medium text-neutral-600\"\n aria-live=\"polite\">\n {quantity}\n </span>\n <Tooltip content=\"Insufficient stock\" show={disabledIncrement}>\n <button\n type=\"button\"\n className={clsx(\n 'flex items-center justify-center rounded',\n 'text-neutral-600 disabled:text-neutral-400',\n 'cursor-pointer disabled:pointer-events-none',\n )}\n disabled={disabledIncrement}\n onClick={increment}\n aria-label=\"Increase quantity\">\n <RiAddFill className=\"size-5 shrink-0 p-0.5\" />\n </button>\n </Tooltip>\n </div>\n );\n};\n\nexport default CartControl;\n"
},
"/src/components/CartControl/index.js": {
"code": "import CartControl from './CartControl';\n\nexport default CartControl;\n"
},
"/src/components/ColorSwatches/ColorSwatches.jsx": {
"code": "import clsx from 'clsx';\n\nconst ColorSwatches = ({ color, selectedColor, onClick, outOfStock }) => {\n return (\n <label\n key={color}\n aria-label={color}\n className={clsx(\n 'size-[56.67px] flex items-center justify-center',\n 'rounded-full',\n outOfStock ? 'pointer-events-none' : 'cursor-pointer',\n )}>\n <input\n type=\"radio\"\n name=\"color-choice\"\n value={color}\n checked={selectedColor === color}\n className=\"sr-only\"\n aria-checked={selectedColor === color}\n onChange={() => onClick(color)}\n disabled={outOfStock}\n />\n <div\n aria-hidden=\"true\"\n className={clsx(\n 'flex items-center justify-center',\n 'size-[38px] rounded-full',\n selectedColor === color\n ? 'border-2 border-white outline outline-1 outline-indigo-600'\n : [\n 'hover:border-2 hover:border-indigo-200',\n 'focus:border-none focus:outline-none focus:ring-[9.33px] focus:ring-indigo-600/[.12]',\n ],\n )}\n style={{ backgroundColor: color }}\n tabIndex={selectedColor === color || outOfStock ? -1 : 0}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n onClick(color);\n }\n }}>\n {selectedColor === color && !outOfStock && (\n <svg\n width=\"28\"\n height=\"28\"\n viewBox=\"0 0 28 28\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\">\n <path\n d=\"M11.6673 17.6993L22.3918 6.97485L24.0417 8.62477L11.6673 20.9991L4.24268 13.5745L5.89259 11.9246L11.6673 17.6993Z\"\n fill=\"white\"\n />\n </svg>\n )}\n\n {outOfStock && (\n <svg\n width=\"34\"\n height=\"34\"\n viewBox=\"0 0 34 34\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\">\n <rect\n x=\"32.3999\"\n y=\"0.199951\"\n width=\"2.1\"\n height=\"44.8\"\n transform=\"rotate(45 32.3999 0.199951)\"\n fill=\"#525252\"\n />\n </svg>\n )}\n </div>\n </label>\n );\n};\n\nexport default ColorSwatches;\n"
},
"/src/components/ColorSwatches/index.js": {
"code": "import ColorSwatches from './ColorSwatches';\n\nexport default ColorSwatches;\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 <div>\n <AccordionItemContext.Provider value={{ id, isOpen, setIsOpen }}>\n {children}\n </AccordionItemContext.Provider>\n </div>\n );\n};\n\nconst AccordionTrigger = ({ children }) => {\n const { id, isOpen, setIsOpen } = useContext(AccordionItemContext);\n const Icon = isOpen ? RiIndeterminateCircleLine : RiAddCircleLine;\n return (\n <button\n className={clsx(\n 'w-full',\n 'flex gap-6 justify-between items-center',\n 'rounded',\n 'focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]',\n 'text-left text-lg text-neutral-900 font-medium'\n )}\n onClick={() => setIsOpen(!isOpen)}\n aria-expanded={isOpen}\n aria-controls={`accordion-content-${id}`}\n id={`accordion-header-${id}`}>\n <span>{children}</span>\n <Icon className=\"size-6 text-neutral-400\" />\n </button>\n );\n};\n\nconst AccordionContent = ({ children }) => {\n const contentRef = useRef(null);\n const { id, isOpen } = useContext(AccordionItemContext);\n\n return (\n <div\n id={`accordion-content-${id}`}\n role=\"region\"\n aria-labelledby={`accordion-header-${id}`}\n className={clsx(\n 'overflow-hidden transition-max-height duration-300',\n 'pr-12',\n isOpen && 'mt-2'\n )}\n style={{\n maxHeight: isOpen ? `${contentRef.current?.scrollHeight}px` : '0',\n }}\n ref={contentRef}>\n {children}\n </div>\n );\n};\n\nconst Accordion = ({ children }) => {\n return (\n <div className=\"w-full\">\n {children.map((item, index) => (\n <div key={item.props.id}>\n {item}\n {index !== children.length - 1 && (\n <div className=\"h-[1px] bg-neutral-200 mt-8 mb-[23px]\" />\n )}\n </div>\n ))}\n </div>\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 <div\n className={clsx(\n commonClasses,\n sizeClasses[size],\n variantClasses[variant],\n className\n )}>\n {label}\n </div>\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 'src/components/ui/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 '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('text-indigo-700', 'hover:bg-neutral-50 focus:bg-neutral-50'),\n danger: clsx(\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};\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};\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 outine-none border-none cursor-pointer',\n 'focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]',\n 'transition-colors',\n 'text-nowrap'\n );\n\n if (href) {\n return (\n <Link\n to={href}\n variant={variant}\n disabled={isDisabled}\n className={clsx(commonClasses, spacingClasses[size], className)}\n {...props}>\n {StartIcon && (\n <StartIcon\n className={clsx('size-6 shrink-0', iconClassName)}\n aria-hidden=\"true\"\n />\n )}\n {label}\n {EndIcon && (\n <EndIcon\n className={clsx('size-6 shrink-0', iconClassName)}\n aria-hidden=\"true\"\n />\n )}\n </Link>\n );\n }\n\n const children = isLabelHidden ? (\n (\n <StartIcon\n className={clsx('shrink-0', iconSizeClasses[size], iconClassName)}\n aria-hidden=\"true\"\n />\n ) || (\n <EndIcon\n className={clsx('shrink-0', iconSizeClasses[size], iconClassName)}\n aria-hidden=\"true\"\n />\n )\n ) : (\n <>\n {StartIcon && (\n <StartIcon\n className={clsx('size-6 shrink-0', iconClassName)}\n aria-hidden=\"true\"\n />\n )}\n {label}\n {EndIcon && (\n <EndIcon\n className={clsx('size-6 shrink-0', iconClassName)}\n aria-hidden=\"true\"\n />\n )}\n </>\n );\n\n return (\n <button\n className={clsx(\n commonClasses,\n heightClasses[size],\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 className\n )}\n disabled={isDisabled}\n {...props}>\n {children}\n </button>\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/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 { 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 ),\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 ),\n};\n\nconst Link = ({\n children,\n disabled,\n className,\n variant = 'primary',\n ...props\n}) => {\n return (\n <RouterLink\n {...props}\n className={clsx(\n 'font-medium px-0.5 rounded',\n linkVariantClasses[variant],\n {\n 'pointer-events-none text-neutral-400': disabled,\n },\n className\n )}>\n {children}\n </RouterLink>\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 Star from './Star';\nimport clsx from 'clsx';\n\nconst Rating = ({ value, max = 5, onChange }) => {\n const [hoveredIndex, setHoveredIndex] = useState(null);\n\n const readOnlyMode = !onChange;\n\n return (\n <div className=\"flex items-center gap-1\">\n {Array.from({ length: max }).map((_, index) => (\n <span\n key={index}\n tabIndex={readOnlyMode ? -1 : 0}\n onMouseEnter={() => !readOnlyMode && setHoveredIndex(index)}\n onMouseLeave={() => !readOnlyMode && setHoveredIndex(null)}\n className={clsx(!readOnlyMode && 'cursor-pointer')}\n onClick={() => onChange?.(index + 1)}>\n <Star\n filled={\n hoveredIndex != null ? index <= hoveredIndex : value >= index + 1\n }\n halfFilled={value < index + 1 && value > index}\n />\n </span>\n ))}\n </div>\n );\n};\n\nexport default Rating;\n"
},
"/src/components/ui/Rating/Star.jsx": {
"code": "const Star = ({ filled, halfFilled }) => {\n return filled ? (\n <svg\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 20 20\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\">\n <g clipPath=\"url(#clip0_28_1325)\">\n <path\n 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\"\n fill=\"#FACC15\"\n />\n </g>\n <defs>\n <clipPath id=\"clip0_28_1325\">\n <rect width=\"20\" height=\"20\" fill=\"white\" />\n </clipPath>\n </defs>\n </svg>\n ) : halfFilled ? (\n <svg\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 20 20\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\">\n <g clipPath=\"url(#clip0_3052_704)\">\n <path\n 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\"\n fill=\"#E5E7EB\"\n />\n <g clipPath=\"url(#clip1_3052_704)\">\n <path\n 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\"\n fill=\"#FACC15\"\n />\n </g>\n </g>\n <defs>\n <clipPath id=\"clip0_3052_704\">\n <rect width=\"20\" height=\"20\" fill=\"white\" />\n </clipPath>\n <clipPath id=\"clip1_3052_704\">\n <rect width=\"10\" height=\"20\" fill=\"white\" />\n </clipPath>\n </defs>\n </svg>\n ) : (\n <svg\n width=\"20\"\n height=\"20\"\n viewBox=\"0 0 20 20\"\n fill=\"none\"\n xmlns=\"http://www.w3.org/2000/svg\">\n <g clipPath=\"url(#clip0_3052_707)\">\n <path\n 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\"\n fill=\"#E5E7EB\"\n />\n </g>\n <defs>\n <clipPath id=\"clip0_3052_707\">\n <rect width=\"20\" height=\"20\" fill=\"white\" />\n </clipPath>\n </defs>\n </svg>\n );\n};\n\nexport default Star;\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 <div\n className=\"relative flex items-center\"\n onMouseEnter={() => show && setVisible(true)}\n onMouseLeave={() => show && setVisible(false)}>\n {children}\n {visible && (\n <div\n className={clsx(\n 'absolute py-2 px-3 rounded-lg shadow-lg min-w-max max-w-xs',\n 'bg-neutral-950',\n 'text-white text-xs font-medium',\n positions[position]\n )}>\n {content}\n <div className={clsx('absolute', arrowPositions[position])} />\n </div>\n )}\n </div>\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"
},
"/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 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"
},
"/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';\n\nconst root = ReactDOM.createRoot(document.getElementById('root'));\nroot.render(\n <React.StrictMode>\n <Router>\n <App />\n </Router>\n </React.StrictMode>,\n);\n"
},
"/src/pages/ProductDetail/components/AvailableColors.jsx": {
"code": "import { useMemo } from 'react';\n\nimport ColorSwatches from 'src/components/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 <fieldset aria-label=\"Choose a color\">\n <legend className=\"text-sm text-neutral-500\">Available Colors</legend>\n <div className=\"mt-4 flex flex-wrap gap-4\">\n {colors.map((color) => (\n <ColorSwatches\n key={color}\n color={COLORS[color].value}\n outOfStock={unavailableColors.includes(color)}\n selectedColor={COLORS[selectedColor].value}\n onClick={() => setSelectedColor(color)}\n />\n ))}\n </div>\n </fieldset>\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';\nimport { useMemo } from 'react';\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 = useMemo(\n () =>\n getUnavailableSizes({\n product,\n color: selectedColor,\n }),\n [product, selectedColor],\n );\n\n return (\n <fieldset aria-label=\"Choose a size\">\n <legend className=\"text-sm text-neutral-500\">Available Sizes</legend>\n\n <div className={clsx('mt-4', 'flex flex-wrap gap-4')}>\n {sizes.map((size) => {\n const outOfStock = unavailableSizes.includes(size);\n\n return (\n <label\n key={size}\n aria-label={size}\n className={clsx(\n outOfStock ? 'pointer-events-none' : 'cursor-pointer',\n )}>\n <input\n type=\"radio\"\n name=\"size-choice\"\n value={size}\n className=\"sr-only\"\n disabled={outOfStock}\n tabIndex={-1}\n aria-checked={selectedSize === size}\n onChange={() => setSelectedSize(size)}\n />\n <span\n aria-hidden=\"true\"\n tabIndex={selectedSize === size || outOfStock ? -1 : 0}\n className={clsx(\n 'h-12 w-16',\n 'flex items-center justify-center gap-1.5',\n 'px-5 py-3',\n 'font-medium uppercase',\n 'rounded border',\n 'focus:outline-none',\n outOfStock\n ? [\n 'text-neutral-400',\n 'pointer-events-none',\n 'bg-neutral-100',\n ]\n : [\n 'text-neutral-900',\n 'cursor-pointer',\n 'bg-white hover:bg-neutral-50 focus:bg-neutral-50',\n ],\n selectedSize === size\n ? 'border-indigo-600'\n : 'border-neutral-200',\n outOfStock && selectedSize !== size && 'border-none',\n )}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n setSelectedSize(size);\n }\n }}>\n {SIZE_MAP[size]}\n </span>\n </label>\n );\n })}\n </div>\n </fieldset>\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 <section aria-labelledby=\"product-faq\" className=\"mt-10\">\n <Accordion>\n {info.map((item) => (\n <AccordionItem key={item.title} id={item.title}>\n <AccordionTrigger>{item.title}</AccordionTrigger>\n <AccordionContent>\n <ul className=\"ml-4 list-disc pl-2\">\n {item.description.map((descItem) => (\n <li key={descItem} className=\"text-neutral-600\">\n {descItem}\n </li>\n ))}\n </ul>\n </AccordionContent>\n </AccordionItem>\n ))}\n </Accordion>\n </section>\n );\n};\n\nexport default InfoSection;\n"
},
"/src/pages/ProductDetail/components/ProductDetail.jsx": {
"code": "import ProductImages from './ProductImages';\nimport ProductMetadata from './ProductMetadata';\n\nimport { useProductDetailsContext } from './ProductDetailsContext';\n\nconst ProductDetail = () => {\n const { isProductLoading, product } = useProductDetailsContext();\n\n if (isProductLoading || !product) {\n return <div>Loading...</div>;\n }\n\n return (\n <>\n <div className=\"col-span-4 md:col-span-6\">\n <ProductImages />\n </div>\n <div className=\"col-span-4 md:col-span-6\">\n <ProductMetadata />\n </div>\n </>\n );\n};\n\nexport default ProductDetail;\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 const value = useMemo(() => {\n return {\n product,\n isProductLoading,\n selectedColor,\n setSelectedColor,\n selectedSize,\n setSelectedSize,\n itemQuantity,\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 <ProductDetailsContext.Provider value={value}>\n {children}\n </ProductDetailsContext.Provider>\n );\n};\n\nexport default ProductDetailsContextProvider;\n"
},
"/src/pages/ProductDetail/components/ProductImages.jsx": {
"code": "import { useMemo, 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 = useMemo(\n () => getSelectedColorImages({ product, color: selectedColor }),\n [product, selectedColor],\n );\n\n return (\n <div className=\"flex flex-col gap-6\">\n <img\n src={product.images[selectedPreview].image_url}\n alt=\"Selected preview\"\n loading=\"lazy\"\n className=\"h-[400px] w-full rounded-lg object-cover md:h-[800px]\"\n />\n <div className=\"flex gap-4 overflow-x-auto\">\n {images.map((image, index) => (\n <img\n key={image.image_url + index}\n src={image.image_url}\n alt={`Preview ${index + 1}`}\n loading=\"lazy\"\n onClick={() => setSelectedPreview(index)}\n className={clsx(\n 'block shrink-0 rounded-lg',\n 'h-[120px] w-20 object-cover md:h-[190px] md:w-[188px] lg:w-40',\n 'cursor-pointer',\n index === selectedPreview && 'border-[3px] border-indigo-600',\n )}\n />\n ))}\n </div>\n </div>\n );\n};\n\nexport default ProductImages;\n"
},
"/src/pages/ProductDetail/components/ProductMetadata.jsx": {
"code": "import clsx from 'clsx';\nimport { useMediaQuery } from 'usehooks-ts';\nimport { useMemo } from 'react';\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 { getInventoryData } from '../utils';\n\nconst ProductMetadata = () => {\n const isMobileAndBelow = useMediaQuery('(max-width: 767px)');\n const { product, itemQuantity, selectedColor, selectedSize } =\n useProductDetailsContext();\n\n const { name, description, reviews, rating } = product;\n const inventoryData = useMemo(\n () =>\n getInventoryData({ product, color: selectedColor, size: selectedSize }),\n [product, selectedColor, selectedSize],\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 return (\n <div>\n <section\n className={clsx('flex flex-col gap-8')}\n aria-labelledby=\"information-heading\">\n <div className=\"flex flex-col items-start\">\n <h1 className=\"text-3xl font-semibold md:text-5xl\">{name}</h1>\n <div className=\"mt-5\">\n <div className=\"inline-flex items-end gap-2\">\n <span className=\"text-3xl font-medium text-neutral-600\">\n ${hasDiscount ? sale_price : list_price}\n </span>\n {hasDiscount && (\n <span className=\"text-lg font-medium text-neutral-400 line-through\">\n ${list_price}\n </span>\n )}\n </div>\n </div>\n {hasDiscount && (\n <div className=\"mt-2\">\n <Badge\n label={`${discount_percentage}% OFF`}\n size=\"lg\"\n variant=\"warning\"\n />\n </div>\n )}\n\n <div className={clsx('flex flex-wrap items-center gap-2', 'mt-3')}>\n <div className=\"text-xl text-neutral-900\">{roundedRating ?? 0}</div>\n <Rating value={roundedRating ?? 0} />\n {reviews > 0 ? (\n <Button\n label={`See all ${reviews} reviews`}\n href=\"#\"\n variant=\"primary\"\n className=\"text-sm\"\n />\n ) : (\n <div className=\"flex gap-[2px]\">\n <span className=\"text-sm text-neutral-900\">\n No reviews yet.\n </span>\n <Button\n label=\"Be the first.\"\n href=\"#\"\n variant=\"primary\"\n className=\"text-sm\"\n />\n </div>\n )}\n </div>\n </div>\n\n <p className=\"text-neutral-600\">{description}</p>\n </section>\n\n <section aria-labelledby=\"product-options\" className=\"mt-8\">\n <form className=\"flex flex-col gap-8\">\n <AvailableColors />\n <AvailableSizes />\n <ProductQuantity availableStock={stock} />\n\n {/* Out of stock message */}\n {stock === 0 && (\n <div className=\"text-xl font-semibold text-neutral-900\">\n Sorry, this item is out of stock\n </div>\n )}\n\n <Button\n label=\"Add to Cart\"\n size={isMobileAndBelow ? 'xl' : '2xl'}\n isDisabled={itemQuantity === 0 || stock === 0}\n />\n </form>\n </section>\n\n <InfoSection />\n </div>\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 <fieldset aria-label=\"Choose a color\">\n <legend className=\"text-sm text-neutral-500\">Quantity</legend>\n <div className=\"mt-4\">\n <CartControl\n quantity={itemQuantity}\n decrement={decrementQuantity}\n increment={incrementQuantity}\n availableStock={availableStock}\n />\n </div>\n </fieldset>\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 clsx from 'clsx';\n\nimport ProductDetail from './components/ProductDetail';\nimport ProductDetailsContextProvider from './components/ProductDetailsContext';\n\nconst ProductDetailPage = () => {\n return (\n <main className=\"mx-auto min-h-screen max-w-[1440px] p-4\">\n <div\n className={clsx(\n 'min-h-[calc(100vh_-_32px)] rounded-md bg-white',\n 'shadow-sm md:shadow-md lg:shadow-lg',\n )}>\n <ProductDetailsContextProvider>\n <div\n className={clsx(\n 'w-full',\n 'px-4 py-12 md:py-16 lg:p-24',\n 'grid grid-cols-4 gap-x-4 gap-y-12 md:grid-cols-6 md:gap-x-8 lg:grid-cols-12',\n )}>\n <ProductDetail />\n </div>\n </ProductDetailsContextProvider>\n </div>\n </main>\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"
}
},
"workspace": {
"visibleFiles": [
"/src/App.js",
"/src/index.css",
"/src/pages/ProductDetail/ProductDetailPage.jsx"
],
"activeFile": "/src/App.js",
"environment": "create-react-app",
"externalResources": [
"https://cdn.tailwindcss.com"
],
"startRoute": "/products/voyager-hoodie"
}
}

View File

@ -45,6 +45,7 @@ export default function ProjectsChallengeSolutionSection({ solution }: Props) {
<ProjectsChallengeSolutionWorkspace
activeTabScrollIntoView={true}
defaultFiles={files}
startRoute={workspace?.startRoute}
/>
<SandpackTimeoutLogger instance="projects.official_solutions" />
</SandpackProvider>

View File

@ -45,8 +45,10 @@ const UserInterfaceCodingWorkspaceTilesPanelRoot =
function ProjectsChallengeSolutionWorkspaceImpl({
defaultFiles,
startRoute,
}: Readonly<{
defaultFiles: SandpackFiles;
startRoute?: string;
}>) {
const copyRef = useQuestionLogEventCopyContents<HTMLDivElement>();
const { dispatch } = useUserInterfaceCodingWorkspaceTilesContext();
@ -150,7 +152,11 @@ function ProjectsChallengeSolutionWorkspaceImpl({
},
preview: {
contents: (
<SandpackPreview showNavigator={true} showOpenInCodeSandbox={false} />
<SandpackPreview
showNavigator={true}
showOpenInCodeSandbox={false}
startRoute={startRoute}
/>
),
icon: CodingWorkspaceTabIcons.browser.icon,
label: 'Browser',
@ -281,9 +287,11 @@ function ProjectsChallengeSolutionWorkspaceImpl({
export default function ProjectsChallengeSolutionWorkspace({
activeTabScrollIntoView = true,
defaultFiles,
startRoute,
}: Readonly<{
activeTabScrollIntoView?: boolean;
defaultFiles: SandpackFiles;
startRoute?: string;
}>) {
const { sandpack } = useSandpack();
const { activeFile, visibleFiles } = sandpack;
@ -335,7 +343,10 @@ export default function ProjectsChallengeSolutionWorkspace({
<TilesProvider
activeTabScrollIntoView={activeTabScrollIntoView}
defaultValue={layout}>
<ProjectsChallengeSolutionWorkspaceImpl defaultFiles={defaultFiles} />
<ProjectsChallengeSolutionWorkspaceImpl
defaultFiles={defaultFiles}
startRoute={startRoute}
/>
</TilesProvider>
);
}

View File

@ -94,6 +94,7 @@ export type ProjectsChallengeSolutionWorkspace = Readonly<{
activeFile?: string;
environment: SandboxEnvironment;
externalResources?: Array<string>;
startRoute?: string;
visibleFiles?: Array<string>;
}>;

View File

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

View File

@ -0,0 +1,11 @@
{
"visibleFiles": [
"/src/App.js",
"/src/index.css",
"/src/pages/ProductDetail/ProductDetailPage.jsx"
],
"activeFile": "/src/App.js",
"environment": "create-react-app",
"externalResources": ["https://cdn.tailwindcss.com"],
"startRoute": "/products/voyager-hoodie"
}

View File

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

View File

@ -0,0 +1,20 @@
{
"name": "@gfe-challenges/product-details-section-solution",
"homepage": "/products/voyager-hoodie",
"version": "0.0.1",
"dependencies": {
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"usehooks-ts": "^3.1.0",
"clsx": "^2.1.1",
"react": "^18.3.1",
"react-scripts": "5.0.1",
"react-icons": "^5.2.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
}
}

View File

@ -0,0 +1,13 @@
<!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" />
<title>Product Detail</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,13 @@
import { Routes, Route } from 'react-router-dom';
import ProductDetailPage from './pages/ProductDetail';
function App() {
return (
<Routes>
<Route path="/products/:productId" element={<ProductDetailPage />} />
</Routes>
);
}
export default App;

View File

@ -0,0 +1,54 @@
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(
'h-9 w-[125px]',
'flex items-center justify-center gap-3',
'px-[5px] py-0.5',
'rounded-md border border-neutral-200 bg-neutral-50',
)}
role="group"
aria-label="Product Quantity control">
<button
type="button"
className={clsx(
'flex items-center justify-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 shrink-0 p-0.5" />
</button>
<span
className="flex-1 text-center text-sm font-medium text-neutral-600"
aria-live="polite">
{quantity}
</span>
<Tooltip content="Insufficient stock" show={disabledIncrement}>
<button
type="button"
className={clsx(
'flex items-center justify-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 shrink-0 p-0.5" />
</button>
</Tooltip>
</div>
);
};
export default CartControl;

View File

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

View File

@ -0,0 +1,78 @@
import clsx from 'clsx';
const ColorSwatches = ({ color, selectedColor, onClick, outOfStock }) => {
return (
<label
key={color}
aria-label={color}
className={clsx(
'size-[56.67px] flex items-center justify-center',
'rounded-full',
outOfStock ? 'pointer-events-none' : 'cursor-pointer',
)}>
<input
type="radio"
name="color-choice"
value={color}
checked={selectedColor === color}
className="sr-only"
aria-checked={selectedColor === color}
onChange={() => onClick(color)}
disabled={outOfStock}
/>
<div
aria-hidden="true"
className={clsx(
'flex items-center justify-center',
'size-[38px] rounded-full',
selectedColor === color
? 'border-2 border-white outline outline-1 outline-indigo-600'
: [
'hover:border-2 hover:border-indigo-200',
'focus:border-none focus:outline-none focus:ring-[9.33px] focus:ring-indigo-600/[.12]',
],
)}
style={{ backgroundColor: color }}
tabIndex={selectedColor === color || outOfStock ? -1 : 0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onClick(color);
}
}}>
{selectedColor === color && !outOfStock && (
<svg
width="28"
height="28"
viewBox="0 0 28 28"
fill="none"
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"
fill="white"
/>
</svg>
)}
{outOfStock && (
<svg
width="34"
height="34"
viewBox="0 0 34 34"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect
x="32.3999"
y="0.199951"
width="2.1"
height="44.8"
transform="rotate(45 32.3999 0.199951)"
fill="#525252"
/>
</svg>
)}
</div>
</label>
);
};
export default ColorSwatches;

View File

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

View File

@ -0,0 +1,79 @@
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',
'rounded',
'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,190 @@
import clsx from 'clsx';
import Link from 'src/components/ui/Link';
const paddingClasses = {
md: 'px-3.5 py-2.5',
lg: 'px-4 py-2.5',
xl: 'px-5 py-3',
'2xl': 'px-6 py-4',
};
// We need this because secondary button has border
const secondaryVariantPaddingClasses = {
md: 'px-[13px] py-[9px]',
lg: 'px-[15px] py-[9px]',
xl: 'px-[19px] py-[11px]',
'2xl': 'px-[23px] py-[15px]',
};
const fontSizeClasses = {
md: 'text-sm',
lg: 'text-base',
xl: 'text-base',
'2xl': 'text-lg',
};
const spacingClasses = {
md: 'gap-x-1.5',
lg: 'gap-x-2',
xl: 'gap-x-2',
'2xl': 'gap-x-3',
};
const heightClasses = {
md: 'h-10',
lg: 'h-11',
xl: 'h-12',
'2xl': 'h-15',
};
const iconOnlySizeClasses = {
md: 'size-10',
lg: 'size-11',
xl: 'size-12',
'2xl': 'size-14',
};
const iconSizeClasses = {
md: 'size-5',
lg: 'size-5',
xl: 'size-5',
'2xl': 'size-6',
};
const variantClasses = {
primary: clsx(
'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('text-indigo-700', 'hover:bg-neutral-50 focus:bg-neutral-50'),
danger: clsx(
'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]'
),
};
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'),
};
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 outine-none border-none cursor-pointer',
'focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-600/[.12]',
'transition-colors',
'text-nowrap'
);
if (href) {
return (
<Link
to={href}
variant={variant}
disabled={isDisabled}
className={clsx(commonClasses, spacingClasses[size], className)}
{...props}>
{StartIcon && (
<StartIcon
className={clsx('size-6 shrink-0', iconClassName)}
aria-hidden="true"
/>
)}
{label}
{EndIcon && (
<EndIcon
className={clsx('size-6 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-6 shrink-0', iconClassName)}
aria-hidden="true"
/>
)}
{label}
{EndIcon && (
<EndIcon
className={clsx('size-6 shrink-0', iconClassName)}
aria-hidden="true"
/>
)}
</>
);
return (
<button
className={clsx(
commonClasses,
heightClasses[size],
variant === 'secondary'
? secondaryVariantPaddingClasses[size]
: paddingClasses[size],
fontSizeClasses[size],
spacingClasses[size],
isLabelHidden && iconOnlySizeClasses[size],
variantClasses[variant],
variantDisabledClasses[variant],
isDisabled && 'pointer-events-none',
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,40 @@
import clsx from 'clsx';
import { 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]'
),
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]'
),
};
const Link = ({
children,
disabled,
className,
variant = 'primary',
...props
}) => {
return (
<RouterLink
{...props}
className={clsx(
'font-medium px-0.5 rounded',
linkVariantClasses[variant],
{
'pointer-events-none text-neutral-400': disabled,
},
className
)}>
{children}
</RouterLink>
);
};
export default Link;

View File

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

View File

@ -0,0 +1,32 @@
import { useState } from 'react';
import Star from './Star';
import clsx from 'clsx';
const Rating = ({ value, max = 5, onChange }) => {
const [hoveredIndex, setHoveredIndex] = useState(null);
const readOnlyMode = !onChange;
return (
<div className="flex items-center gap-1">
{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')}
onClick={() => onChange?.(index + 1)}>
<Star
filled={
hoveredIndex != null ? index <= hoveredIndex : value >= index + 1
}
halfFilled={value < index + 1 && value > index}
/>
</span>
))}
</div>
);
};
export default Rating;

View File

@ -0,0 +1,71 @@
const Star = ({ filled, halfFilled }) => {
return filled ? (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_28_1325)">
<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>
<defs>
<clipPath id="clip0_28_1325">
<rect width="20" height="20" fill="white" />
</clipPath>
</defs>
</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"
xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_3052_707)">
<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>
<defs>
<clipPath id="clip0_3052_707">
<rect width="20" height="20" fill="white" />
</clipPath>
</defs>
</svg>
);
};
export default Star;

View File

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

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

View File

@ -0,0 +1,32 @@
@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 shadow */
.shadow-custom {
box-shadow:
0px 1px 2px 0 rgb(0 0 0 / 0.06),
0px 1px 3px 0 rgb(0 0 0 / 0.1);
}

View File

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

View File

@ -0,0 +1,29 @@
import clsx from 'clsx';
import ProductDetail from './components/ProductDetail';
import ProductDetailsContextProvider from './components/ProductDetailsContext';
const ProductDetailPage = () => {
return (
<main className="mx-auto min-h-screen max-w-[1440px] p-4">
<div
className={clsx(
'min-h-[calc(100vh_-_32px)] rounded-md bg-white',
'shadow-sm md:shadow-md lg:shadow-lg',
)}>
<ProductDetailsContextProvider>
<div
className={clsx(
'w-full',
'px-4 py-12 md:py-16 lg:p-24',
'grid grid-cols-4 gap-x-4 gap-y-12 md:grid-cols-6 md:gap-x-8 lg:grid-cols-12',
)}>
<ProductDetail />
</div>
</ProductDetailsContextProvider>
</div>
</main>
);
};
export default ProductDetailPage;

View File

@ -0,0 +1,35 @@
import { useMemo } from 'react';
import ColorSwatches from 'src/components/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="mt-4 flex flex-wrap gap-4">
{colors.map((color) => (
<ColorSwatches
key={color}
color={COLORS[color].value}
outOfStock={unavailableColors.includes(color)}
selectedColor={COLORS[selectedColor].value}
onClick={() => setSelectedColor(color)}
/>
))}
</div>
</fieldset>
);
};
export default AvailableColors;

View File

@ -0,0 +1,94 @@
import clsx from 'clsx';
import { useProductDetailsContext } from './ProductDetailsContext';
import { getUnavailableSizes } from '../utils';
import { useMemo } from 'react';
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 = useMemo(
() =>
getUnavailableSizes({
product,
color: selectedColor,
}),
[product, selectedColor],
);
return (
<fieldset aria-label="Choose a size">
<legend className="text-sm text-neutral-500">Available Sizes</legend>
<div className={clsx('mt-4', 'flex flex-wrap gap-4')}>
{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(
'h-12 w-16',
'flex items-center justify-center gap-1.5',
'px-5 py-3',
'font-medium uppercase',
'rounded border',
'focus:outline-none',
outOfStock
? [
'text-neutral-400',
'pointer-events-none',
'bg-neutral-100',
]
: [
'text-neutral-900',
'cursor-pointer',
'bg-white hover:bg-neutral-50 focus: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]}
</span>
</label>
);
})}
</div>
</fieldset>
);
};
export default AvailableSizes;

View File

@ -0,0 +1,36 @@
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="ml-4 list-disc pl-2">
{item.description.map((descItem) => (
<li key={descItem} className="text-neutral-600">
{descItem}
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</section>
);
};
export default InfoSection;

View File

@ -0,0 +1,25 @@
import ProductImages from './ProductImages';
import ProductMetadata from './ProductMetadata';
import { useProductDetailsContext } from './ProductDetailsContext';
const ProductDetail = () => {
const { isProductLoading, product } = useProductDetailsContext();
if (isProductLoading || !product) {
return <div>Loading...</div>;
}
return (
<>
<div className="col-span-4 md:col-span-6">
<ProductImages />
</div>
<div className="col-span-4 md:col-span-6">
<ProductMetadata />
</div>
</>
);
};
export default ProductDetail;

View File

@ -0,0 +1,102 @@
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]);
const value = useMemo(() => {
return {
product,
isProductLoading,
selectedColor,
setSelectedColor,
selectedSize,
setSelectedSize,
itemQuantity,
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,45 @@
import { useMemo, 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 = useMemo(
() => getSelectedColorImages({ product, color: selectedColor }),
[product, selectedColor],
);
return (
<div className="flex flex-col gap-6">
<img
src={product.images[selectedPreview].image_url}
alt="Selected preview"
loading="lazy"
className="h-[400px] w-full rounded-lg object-cover md:h-[800px]"
/>
<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(
'block shrink-0 rounded-lg',
'h-[120px] w-20 object-cover md:h-[190px] md:w-[188px] lg:w-40',
'cursor-pointer',
index === selectedPreview && 'border-[3px] border-indigo-600',
)}
/>
))}
</div>
</div>
);
};
export default ProductImages;

View File

@ -0,0 +1,116 @@
import clsx from 'clsx';
import { useMediaQuery } from 'usehooks-ts';
import { useMemo } from 'react';
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 { getInventoryData } from '../utils';
const ProductMetadata = () => {
const isMobileAndBelow = useMediaQuery('(max-width: 767px)');
const { product, itemQuantity, selectedColor, selectedSize } =
useProductDetailsContext();
const { name, description, reviews, rating } = product;
const inventoryData = useMemo(
() =>
getInventoryData({ product, color: selectedColor, size: selectedSize }),
[product, selectedColor, selectedSize],
);
const { discount_percentage, list_price, sale_price, stock } = inventoryData;
const roundedRating = Math.round(rating * 10) / 10;
const hasDiscount = !!discount_percentage;
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 font-semibold md:text-5xl">{name}</h1>
<div className="mt-5">
<div className="inline-flex items-end gap-2">
<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 flex-wrap items-center gap-2', '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="primary"
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="primary"
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">
<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;
};