diff --git a/package-lock.json b/package-lock.json index d34b2b5..eb4498a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "ISC", "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.9.6" }, "devDependencies": { "@commitlint/cli": "^20.1.0", @@ -182,6 +183,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -819,6 +821,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -862,6 +865,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1655,6 +1659,7 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -2932,8 +2937,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3028,6 +3032,7 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3045,6 +3050,7 @@ "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3055,6 +3061,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3112,6 +3119,7 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -3448,6 +3456,7 @@ "integrity": "sha512-oWtNM89Np+YsQO3ttT5i1Aer/0xbzQzp66NzuJn/U16bB7MnvSzdLKXgk1kkMLYyKSSzA2ajzqMkYheaE9opuQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.10", "fflate": "^0.8.2", @@ -3484,6 +3493,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3574,7 +3584,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -3715,6 +3724,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -4100,6 +4110,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -4113,6 +4132,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -4346,8 +4366,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dot-prop": { "version": "5.3.0", @@ -4675,6 +4694,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6342,7 +6362,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6381,6 +6400,7 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -8911,6 +8931,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9600,7 +9621,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9709,6 +9729,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9718,6 +9739,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9730,8 +9752,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -9743,6 +9764,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz", + "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz", + "integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==", + "license": "MIT", + "dependencies": { + "react-router": "7.9.6" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/read-package-up": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", @@ -9988,6 +10047,7 @@ "integrity": "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -10538,6 +10598,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -11142,6 +11208,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11296,6 +11363,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11493,6 +11561,7 @@ "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -11586,6 +11655,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11599,6 +11669,7 @@ "integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.10", "@vitest/mocker": "4.0.10", @@ -11969,6 +12040,7 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 5b17b0f..420410a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ }, "dependencies": { "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-router-dom": "^7.9.6" }, "devDependencies": { "@commitlint/cli": "^20.1.0", diff --git a/src/App.css b/src/App.css index cfd784a..a104019 100644 --- a/src/App.css +++ b/src/App.css @@ -8,7 +8,8 @@ } .hero { - background: radial-gradient(circle at top left, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.1)), + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.1)), var(--card); border-radius: 32px; display: grid; @@ -65,7 +66,10 @@ font-size: 0.95rem; font-weight: 600; cursor: pointer; - transition: transform 200ms ease, box-shadow 200ms ease, background 200ms ease; + transition: + transform 200ms ease, + box-shadow 200ms ease, + background 200ms ease; } .btn--primary { @@ -132,6 +136,12 @@ justify-content: space-between; } +.products-page { + max-width: 1400px; + margin: 0 auto; + padding: 1rem 2rem 2rem; +} + .product-grid { display: flex; flex-direction: column; @@ -140,10 +150,22 @@ .product-grid__items { display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 2rem; } +@media (min-width: 1200px) { + .product-grid__items { + grid-template-columns: repeat(4, 1fr); + } +} + +@media (min-width: 900px) and (max-width: 1199px) { + .product-grid__items { + grid-template-columns: repeat(3, 1fr); + } +} + .product-card { background: var(--card); border-radius: 28px; @@ -152,7 +174,17 @@ display: flex; flex-direction: column; min-height: 100%; - transition: transform 200ms ease, box-shadow 200ms ease; + transition: + transform 200ms ease, + box-shadow 200ms ease; + text-decoration: none; + color: inherit; + cursor: pointer; +} + +.product-card:hover { + transform: translateY(-2px); + box-shadow: 0 12px 32px rgba(15, 23, 42, 0.1); } .product-card__media { @@ -233,7 +265,16 @@ text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.75rem; - transition: transform 200ms ease, box-shadow 200ms ease; + transition: + transform 200ms ease, + box-shadow 200ms ease; + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.product-card__cta--highlight { + animation: buttonColorFade 1200ms ease-out forwards; } .product-card--highlight { @@ -241,10 +282,17 @@ box-shadow: 0 10px 20px rgba(241, 84, 53, 0.08); } -.product-card__cta--pulse { - animation: ctaPulse 500ms ease; - background: linear-gradient(120deg, rgba(241, 84, 53, 0.95), rgba(241, 84, 53, 0.8)); - box-shadow: 0 8px 18px rgba(241, 84, 53, 0.18); +.product-card__checkmark { + display: inline-flex; + align-items: center; + animation: checkmarkFade 1200ms ease-out forwards; + margin-right: -0.25rem; +} + +.product-card__checkmark svg { + width: 16px; + height: 16px; + stroke: currentColor; } @keyframes cardPulse { @@ -262,15 +310,43 @@ } } -@keyframes ctaPulse { +@keyframes checkmarkFade { 0% { - transform: scale(1); + opacity: 0; + transform: translateX(-4px) scale(0.8); + } + 20% { + opacity: 1; + transform: translateX(0) scale(1); } - 35% { - transform: scale(1.03); + 70% { + opacity: 1; + transform: translateX(0) scale(1); } 100% { - transform: scale(1); + opacity: 0; + transform: translateX(4px) scale(0.8); + } +} + +@keyframes buttonColorFade { + 0% { + background-color: rgba(15, 23, 42, 0.85); + } + 15% { + background-color: rgba(34, 197, 94, 0.9); + } + 50% { + background-color: rgba(34, 197, 94, 0.9); + } + 70% { + background-color: rgba(34, 197, 94, 0.75); + } + 85% { + background-color: rgba(34, 197, 94, 0.5); + } + 100% { + background-color: rgba(15, 23, 42, 0.85); } } @@ -396,6 +472,12 @@ gap: 1rem; } +.cart-line__quantity-group { + display: flex; + align-items: center; + gap: 0.75rem; +} + .quantity { display: inline-flex; align-items: center; @@ -418,6 +500,38 @@ font-weight: 600; } +.cart-line__remove { + border: none; + background: transparent; + color: var(--muted-strong); + cursor: pointer; + padding: 0.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + transition: + color 200ms ease, + background-color 200ms ease, + transform 200ms ease; +} + +.cart-line__remove:hover { + color: var(--accent); + background-color: rgba(241, 84, 53, 0.1); + transform: scale(1.1); +} + +.cart-line__remove:active { + transform: scale(0.95); +} + +.cart-line__remove svg { + width: 16px; + height: 16px; + stroke: currentColor; +} + .cart-line__total { font-weight: 600; } @@ -452,6 +566,390 @@ text-align: center; } +.product-page { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; +} + +.product-page__back { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--muted-strong); + text-decoration: none; + margin-bottom: 2rem; + font-size: 0.9rem; + transition: color 200ms ease; +} + +.product-page__back:hover { + color: var(--text); +} + +.product-page__content { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 3rem; + align-items: start; +} + +.product-page__media { + position: relative; + border-radius: 32px; + overflow: hidden; + aspect-ratio: 4 / 3; + background: var(--card); + border: 1px solid var(--border); +} + +.product-page__media img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.product-page__details { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.product-page__category { + text-transform: uppercase; + letter-spacing: 0.2em; + font-size: 0.75rem; + color: var(--muted); + margin: 0; +} + +.product-page__title { + font-size: clamp(2rem, 5vw, 3rem); + line-height: 1.2; + margin: 0; +} + +.product-page__description { + font-size: 1.1rem; + line-height: 1.7; + color: var(--muted-strong); + margin: 0; +} + +.product-page__meta { + display: flex; + align-items: center; + gap: 1.5rem; + font-size: 1.5rem; + font-weight: 600; +} + +.product-page__price { + color: var(--text); +} + +.product-page__rating { + color: var(--muted-strong); + font-size: 1.1rem; +} + +.product-page__colors { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.product-page__colors-label { + font-size: 0.9rem; + color: var(--muted-strong); + margin: 0; + font-weight: 600; +} + +.product-page__colors-list { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.product-page__color-option { + padding: 0.6rem 1.2rem; + border: 1px solid var(--border); + background: var(--card); + border-radius: 999px; + font-size: 0.9rem; + cursor: pointer; + transition: all 200ms ease; + color: var(--text); +} + +.product-page__color-option:hover { + border-color: var(--accent); + transform: translateY(-1px); +} + +.product-page__color-option--selected { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.product-page__cta { + align-self: flex-start; + border-radius: 999px; + border: none; + background: rgba(15, 23, 42, 0.85); + color: #fff; + padding: 0.75rem 2rem; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: + transform 200ms ease, + box-shadow 200ms ease; + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.product-page__cta:hover { + transform: translateY(-1px); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.2); +} + +.product-page__info { + margin-top: 1rem; + padding-top: 2rem; + border-top: 1px solid var(--border); +} + +.product-page__info h3 { + font-size: 1.2rem; + margin: 0 0 1rem 0; +} + +.product-page__info ul { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; + color: var(--muted-strong); +} + +.product-page__info li { + padding-left: 1.5rem; + position: relative; +} + +.product-page__info li::before { + content: '•'; + position: absolute; + left: 0; + color: var(--accent); + font-weight: bold; +} + +.product-page__not-found { + text-align: center; + padding: 4rem 2rem; + display: flex; + flex-direction: column; + gap: 1.5rem; + align-items: center; +} + +.product-page__not-found h1 { + font-size: 2.5rem; + margin: 0; +} + +.product-page__not-found p { + color: var(--muted-strong); + font-size: 1.1rem; + margin: 0; +} + +.reviews { + margin-top: 4rem; + padding-top: 3rem; + border-top: 1px solid var(--border); +} + +.reviews__title { + font-size: 2rem; + margin: 0 0 2rem 0; +} + +.reviews__list { + display: flex; + flex-direction: column; + gap: 1.5rem; + margin-bottom: 3rem; +} + +.review { + background: var(--card); + border: 1px solid var(--border); + border-radius: 20px; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.review__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + flex-wrap: wrap; +} + +.review__header > div { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.review__name { + font-weight: 600; + margin: 0; + color: var(--text); +} + +.review__date { + font-size: 0.85rem; + color: var(--muted-strong); +} + +.review__text { + margin: 0; + line-height: 1.6; + color: var(--muted-strong); +} + +.reviews__form { + background: var(--card); + border: 1px solid var(--border); + border-radius: 24px; + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.reviews__form-title { + font-size: 1.3rem; + margin: 0; +} + +.reviews__form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.reviews__label { + font-size: 0.9rem; + font-weight: 600; + color: var(--text); +} + +.reviews__input, +.reviews__textarea { + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: 12px; + font-size: 1rem; + font-family: inherit; + background: var(--bg); + color: var(--text); + transition: + border-color 200ms ease, + box-shadow 200ms ease; +} + +.reviews__input:focus, +.reviews__textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(241, 84, 53, 0.1); +} + +.reviews__input:disabled, +.reviews__textarea:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.reviews__textarea { + resize: vertical; + min-height: 100px; +} + +.reviews__input--captcha { + max-width: 120px; +} + +.reviews__error { + color: var(--accent); + font-size: 0.9rem; + margin: 0; + padding: 0.75rem 1rem; + background: rgba(241, 84, 53, 0.1); + border-radius: 8px; + border: 1px solid rgba(241, 84, 53, 0.2); +} + +.reviews__submit { + align-self: flex-start; + margin-top: 0.5rem; +} + +.reviews__submit:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.star-rating { + display: inline-flex; + gap: 0.25rem; + align-items: center; +} + +.star-rating__star { + background: none; + border: none; + padding: 0; + font-size: 1.25rem; + color: rgba(15, 23, 42, 0.2); + cursor: default; + transition: + color 150ms ease, + transform 150ms ease; + line-height: 1; +} + +.star-rating__star--filled { + color: #fbbf24; +} + +.star-rating__star--interactive { + cursor: pointer; +} + +.star-rating__star--interactive:hover { + transform: scale(1.15); +} + +.star-rating__star:disabled { + cursor: default; +} + @media (max-width: 720px) { .hero__actions { flex-direction: column; @@ -467,4 +965,34 @@ .cart-panel { padding: 1.5rem; } + + .product-page { + padding: 1rem; + } + + .product-page__content { + gap: 2rem; + } + + .product-page__meta { + font-size: 1.2rem; + } + + .products-page { + padding: 1rem; + } + + .reviews { + margin-top: 2rem; + padding-top: 2rem; + } + + .reviews__form { + padding: 1.5rem; + } + + .review__header { + flex-direction: column; + align-items: flex-start; + } } diff --git a/src/App.test.tsx b/src/App.test.tsx index 8b1dd6d..ae08345 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,21 +1,39 @@ import { describe, it, expect } from 'vitest' import '@testing-library/jest-dom/vitest' import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' import userEvent from '@testing-library/user-event' import App from './App' import products from './data/products' describe('App', () => { - it('renders hero content and product cards', () => { - render() + it('renders hero content on home page', () => { + render( + + + , + ) expect(screen.getByRole('heading', { name: /modern home shop/i })).toBeInTheDocument() + }) + + it('renders product cards on products page', () => { + render( + + + , + ) + expect(screen.getAllByTestId('product-card')).toHaveLength(products.length) }) it('adds items to the basket and adjusts quantities', async () => { const user = userEvent.setup() - render() + render( + + + , + ) const addButtons = screen.getAllByRole('button', { name: /add to bag/i }) await user.click(addButtons[0]) diff --git a/src/App.tsx b/src/App.tsx index 22bb1f2..9d9c8db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,9 @@ import { useEffect, useMemo, useRef, useState } from 'react' +import { Link, Route, Routes } from 'react-router-dom' import './App.css' import products, { type Product } from './data/products' +import ProductPage from './ProductPage' +import ProductsPage from './ProductsPage' const currency = new Intl.NumberFormat('en-US', { style: 'currency', @@ -36,57 +39,16 @@ type CartLineItem = { quantity: number } -function ProductCard({ - product, - onAdd, - isHighlighted, -}: { - product: Product - onAdd: () => void - isHighlighted: boolean -}) { - return ( -
-
- {product.name} - {product.badge && {product.badge}} -
-
-

{product.category}

-

{product.name}

-

{product.description}

-
- {currency.format(product.price)} - ★ {product.rating.toFixed(1)} -
-
- {product.colors.map((color) => ( - {color} - ))} -
- -
-
- ) -} - function CartLine({ item, onIncrement, onDecrement, + onRemove, }: { item: CartLineItem onIncrement: () => void onDecrement: () => void + onRemove: () => void }) { return (
  • @@ -95,23 +57,47 @@ function CartLine({ {currency.format(item.product.price)}
    -
    - - - {item.quantity} - +
    +
    + + + {item.quantity} + + +
    @@ -122,79 +108,31 @@ function CartLine({ ) } -function App() { - const [cart, setCart] = useState>({}) - const [isCartOpen, setIsCartOpen] = useState(false) - const [highlightedProduct, setHighlightedProduct] = useState<{ - id: string - token: number - } | null>(null) - const highlightTimeoutRef = useRef(null) - const highlightSequenceRef = useRef(0) - - const cartItems = useMemo(() => { - return Object.entries(cart) - .map(([productId, quantity]) => { - const product = productDictionary[productId] - if (!product) return null - return { product, quantity } - }) - .filter(Boolean) as CartLineItem[] - }, [cart]) - - const cartCount = useMemo( - () => cartItems.reduce((total, item) => total + item.quantity, 0), - [cartItems], - ) - const subtotal = useMemo( - () => cartItems.reduce((total, item) => total + item.product.price * item.quantity, 0), - [cartItems], - ) - const shipping = subtotal === 0 || subtotal >= freeShippingThreshold ? 0 : flatShippingRate - const total = subtotal + shipping - const freeShippingMessage = - subtotal === 0 - ? 'Start building your bag to unlock complimentary delivery.' - : shipping === 0 - ? 'Shipping is on us today.' - : `Add ${currency.format(freeShippingThreshold - subtotal)} more for free express delivery.` - - const updateQuantity = (productId: string, updater: (current: number) => number) => { - setCart((current) => { - const nextQuantity = updater(current[productId] ?? 0) - if (nextQuantity <= 0) { - const rest = { ...current } - delete rest[productId] - return rest - } - return { ...current, [productId]: nextQuantity } - }) - } - - const addToCart = (productId: string) => { - updateQuantity(productId, (current) => current + 1) - const token = ++highlightSequenceRef.current - setHighlightedProduct({ id: productId, token }) - if (highlightTimeoutRef.current) { - clearTimeout(highlightTimeoutRef.current) - } - highlightTimeoutRef.current = window.setTimeout(() => { - setHighlightedProduct((currentHighlight) => - currentHighlight?.token === token ? null : currentHighlight, - ) - }, 900) - } - - useEffect(() => { - return () => { - if (highlightTimeoutRef.current) { - clearTimeout(highlightTimeoutRef.current) - } - } - }, []) - - const toggleCart = () => setIsCartOpen((open) => !open) +type LayoutProps = { + children: React.ReactNode + cartItems: CartLineItem[] + cartCount: number + subtotal: number + shipping: number + total: number + freeShippingMessage: string + isCartOpen: boolean + toggleCart: () => void + updateQuantity: (productId: string, updater: (current: number) => number) => void +} +function Layout({ + children, + cartItems, + cartCount, + subtotal, + shipping, + total, + freeShippingMessage, + isCartOpen, + toggleCart, + updateQuantity, +}: LayoutProps) { return (
    @@ -237,6 +175,7 @@ function App() { item={item} onIncrement={() => updateQuantity(item.product.id, (qty) => qty + 1)} onDecrement={() => updateQuantity(item.product.id, (qty) => qty - 1)} + onRemove={() => updateQuantity(item.product.id, () => 0)} /> ))} @@ -261,6 +200,14 @@ function App() { )} + {children} +
    + ) +} + +function HomePage() { + return ( + <>

    New season edit

    @@ -269,9 +216,9 @@ function App() { Curated furniture, lighting, and objects crafted in small batches and ready to ship.

    - + Shop the collection - +
    -
    + + ) +} + +function App() { + const [cart, setCart] = useState>({}) + const [isCartOpen, setIsCartOpen] = useState(false) + const [highlightedProduct, setHighlightedProduct] = useState<{ + id: string + token: number + } | null>(null) + const highlightTimeoutRef = useRef(null) + const highlightSequenceRef = useRef(0) + + const cartItems = useMemo(() => { + return Object.entries(cart) + .map(([productId, quantity]) => { + const product = productDictionary[productId] + if (!product) return null + return { product, quantity } + }) + .filter(Boolean) as CartLineItem[] + }, [cart]) + + const cartCount = useMemo( + () => cartItems.reduce((total, item) => total + item.quantity, 0), + [cartItems], + ) + const subtotal = useMemo( + () => cartItems.reduce((total, item) => total + item.product.price * item.quantity, 0), + [cartItems], + ) + const shipping = subtotal === 0 || subtotal >= freeShippingThreshold ? 0 : flatShippingRate + const total = subtotal + shipping + const freeShippingMessage = + subtotal === 0 + ? 'Start building your bag to unlock complimentary delivery.' + : shipping === 0 + ? 'Shipping is on us today.' + : `Add ${currency.format(freeShippingThreshold - subtotal)} more for free express delivery.` + + const updateQuantity = (productId: string, updater: (current: number) => number) => { + setCart((current) => { + const nextQuantity = updater(current[productId] ?? 0) + if (nextQuantity <= 0) { + const rest = { ...current } + delete rest[productId] + return rest + } + return { ...current, [productId]: nextQuantity } + }) + } + + const addToCart = (productId: string) => { + updateQuantity(productId, (current) => current + 1) + + // Clear any existing timeout + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current) + highlightTimeoutRef.current = null + } + + // If this product is already highlighted, briefly remove the highlight to restart the animation + if (highlightedProduct?.id === productId) { + setHighlightedProduct(null) + // Use setTimeout with 0ms to ensure the state update is processed before re-adding + setTimeout(() => { + const token = ++highlightSequenceRef.current + setHighlightedProduct({ id: productId, token }) + highlightTimeoutRef.current = window.setTimeout(() => { + setHighlightedProduct((currentHighlight) => + currentHighlight?.token === token ? null : currentHighlight, + ) + }, 1200) + }, 0) + } else { + // First time highlighting this product + const token = ++highlightSequenceRef.current + setHighlightedProduct({ id: productId, token }) + highlightTimeoutRef.current = window.setTimeout(() => { + setHighlightedProduct((currentHighlight) => + currentHighlight?.token === token ? null : currentHighlight, + ) + }, 1200) + } + } + + useEffect(() => { + return () => { + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current) + } + } + }, []) + + const toggleCart = () => setIsCartOpen((open) => !open) + + const isHighlighted = (productId: string) => highlightedProduct?.id === productId + + return ( + + + + + } + /> + + + + } + /> + + + + } + /> + ) } diff --git a/src/ProductCard.tsx b/src/ProductCard.tsx new file mode 100644 index 0000000..37b0eec --- /dev/null +++ b/src/ProductCard.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import './App.css' +import { type Product } from './data/products' +import { getAverageRating } from './utils/reviews' + +const currency = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}) + +type ProductCardProps = { + product: Product + onAdd: () => void + isHighlighted: boolean +} + +export default function ProductCard({ product, onAdd, isHighlighted }: ProductCardProps) { + const [averageRating, setAverageRating] = useState(null) + + useEffect(() => { + const loadRating = () => { + const rating = getAverageRating(product.id) + setAverageRating(rating) + } + loadRating() + + const handleReviewAdded = () => { + const newRating = getAverageRating(product.id) + setAverageRating(newRating) + } + + window.addEventListener('reviewAdded', handleReviewAdded) + return () => { + window.removeEventListener('reviewAdded', handleReviewAdded) + } + }, [product.id]) + + const handleAddClick = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + onAdd() + } + + return ( + +
    + {product.name} + {product.badge && {product.badge}} +
    +
    +

    {product.category}

    +

    {product.name}

    +

    {product.description}

    +
    + {currency.format(product.price)} + {averageRating !== null && ( + ★ {averageRating.toFixed(1)} + )} +
    +
    + {product.colors.map((color) => ( + {color} + ))} +
    + +
    + + ) +} diff --git a/src/ProductPage.tsx b/src/ProductPage.tsx new file mode 100644 index 0000000..807fcdf --- /dev/null +++ b/src/ProductPage.tsx @@ -0,0 +1,171 @@ +import { useMemo, useState, useEffect } from 'react' +import { Link, useParams } from 'react-router-dom' +import './App.css' +import products from './data/products' +import Reviews from './Reviews' +import { getAverageRating } from './utils/reviews' + +const currency = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}) + +type ProductPageProps = { + onAddToCart: (productId: string) => void + isHighlighted: (productId: string) => boolean +} + +export default function ProductPage({ onAddToCart, isHighlighted }: ProductPageProps) { + const { id } = useParams<{ id: string }>() + const [selectedColor, setSelectedColor] = useState(null) + const [averageRating, setAverageRating] = useState(null) + + const product = useMemo(() => { + return products.find((p) => p.id === id) + }, [id]) + + // Scroll to top when product page loads or product changes + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }) + }, [id]) + + useEffect(() => { + if (!product) return + const loadRating = () => { + const rating = getAverageRating(product.id) + setAverageRating(rating) + } + loadRating() + }, [product]) + + // Listen for storage changes to update rating when new reviews are added + useEffect(() => { + if (!product) return + + const handleStorageChange = () => { + const rating = getAverageRating(product.id) + setAverageRating(rating) + } + + window.addEventListener('storage', handleStorageChange) + // Also listen for custom event from Reviews component + window.addEventListener('reviewAdded', handleStorageChange) + + return () => { + window.removeEventListener('storage', handleStorageChange) + window.removeEventListener('reviewAdded', handleStorageChange) + } + }, [product]) + + if (!product) { + return ( +
    +
    +
    +

    Product not found

    +

    Sorry, we couldn't find the product you're looking for.

    + + Back to shop + +
    +
    +
    + ) + } + + const handleColorSelect = (color: string) => { + setSelectedColor(color) + } + + const handleAddToCart = () => { + onAddToCart(product.id) + } + + return ( +
    +
    + + ← Back to products + + +
    +
    + {product.name} + {product.badge && {product.badge}} +
    + +
    +

    {product.category}

    +

    {product.name}

    +

    {product.description}

    + +
    + {currency.format(product.price)} + {averageRating !== null && ( + ★ {averageRating.toFixed(1)} + )} +
    + +
    +

    Available colors:

    +
    + {product.colors.map((color) => ( + + ))} +
    +
    + + + +
    +

    Product details

    +
      +
    • Premium materials and craftsmanship
    • +
    • 30-day return policy
    • +
    • Complimentary shipping on orders over $150
    • +
    • Design consultation available
    • +
    +
    +
    +
    + + +
    +
    + ) +} diff --git a/src/ProductsPage.tsx b/src/ProductsPage.tsx new file mode 100644 index 0000000..fe54fc7 --- /dev/null +++ b/src/ProductsPage.tsx @@ -0,0 +1,44 @@ +import { useEffect } from 'react' +import { Link } from 'react-router-dom' +import './App.css' +import products from './data/products' +import ProductCard from './ProductCard' + +type ProductsPageProps = { + addToCart: (productId: string) => void + isHighlighted: (productId: string) => boolean +} + +export default function ProductsPage({ addToCart, isHighlighted }: ProductsPageProps) { + // Scroll to top when products page loads + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }) + }, []) + + return ( +
    + + ← Back to home + +
    +
    +

    Featured pieces

    +

    Crafted to layer beautifully

    +

    + Mix tactile fabrics, natural woods, and sculptural silhouettes for your signature look. +

    +
    +
    + {products.map((product) => ( + addToCart(product.id)} + isHighlighted={isHighlighted(product.id)} + /> + ))} +
    +
    +
    + ) +} diff --git a/src/Reviews.tsx b/src/Reviews.tsx new file mode 100644 index 0000000..85f8cf4 --- /dev/null +++ b/src/Reviews.tsx @@ -0,0 +1,258 @@ +import { useState, useEffect } from 'react' +import './App.css' + +type Review = { + id: string + productId: string + name: string + text: string + rating: number + date: string +} + +type StarRatingProps = { + rating: number + onRatingChange?: (rating: number) => void + interactive?: boolean +} + +function StarRating({ rating, onRatingChange, interactive = false }: StarRatingProps) { + const [hoverRating, setHoverRating] = useState(0) + + const handleClick = (value: number) => { + if (interactive && onRatingChange) { + onRatingChange(value) + } + } + + const handleMouseEnter = (value: number) => { + if (interactive) { + setHoverRating(value) + } + } + + const handleMouseLeave = () => { + if (interactive) { + setHoverRating(0) + } + } + + const displayRating = hoverRating || rating + + return ( +
    + {[1, 2, 3, 4, 5].map((value) => ( + + ))} +
    + ) +} + +type ReviewsProps = { + productId: string +} + +function generateCaptcha() { + const num1 = Math.floor(Math.random() * 10) + 1 + const num2 = Math.floor(Math.random() * 10) + 1 + return { question: `${num1} + ${num2}`, answer: num1 + num2 } +} + +export default function Reviews({ productId }: ReviewsProps) { + const [reviews, setReviews] = useState([]) + const [name, setName] = useState('') + const [text, setText] = useState('') + const [rating, setRating] = useState(0) + const [captcha, setCaptcha] = useState(generateCaptcha()) + const [captchaAnswer, setCaptchaAnswer] = useState('') + const [error, setError] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + useEffect(() => { + const loadReviews = () => { + const stored = localStorage.getItem(`reviews-${productId}`) + if (stored) { + try { + const parsed = JSON.parse(stored) as Review[] + // Filter out old reviews without ratings + const validReviews = parsed.filter((review) => review.rating && review.rating > 0) + setReviews(validReviews) + // Update localStorage to remove old reviews + if (validReviews.length !== parsed.length) { + localStorage.setItem(`reviews-${productId}`, JSON.stringify(validReviews)) + } + } catch { + // Invalid JSON, ignore + } + } + } + loadReviews() + }, [productId]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + setError('') + + if (!name.trim()) { + setError('Please enter your name') + return + } + + if (!text.trim()) { + setError('Please enter your review') + return + } + + if (rating === 0) { + setError('Please select a rating') + return + } + + const answer = parseInt(captchaAnswer, 10) + if (isNaN(answer) || answer !== captcha.answer) { + setError('Incorrect captcha answer. Please try again.') + setCaptcha(generateCaptcha()) + setCaptchaAnswer('') + return + } + + setIsSubmitting(true) + + // Simulate a brief delay for better UX + setTimeout(() => { + const newReview: Review = { + id: Date.now().toString(), + productId, + name: name.trim(), + text: text.trim(), + rating, + date: new Date().toISOString(), + } + + const updatedReviews = [newReview, ...reviews] + setReviews(updatedReviews) + localStorage.setItem(`reviews-${productId}`, JSON.stringify(updatedReviews)) + + // Dispatch custom event to notify other components + window.dispatchEvent(new CustomEvent('reviewAdded', { detail: { productId } })) + + // Reset form + setName('') + setText('') + setRating(0) + setCaptchaAnswer('') + setCaptcha(generateCaptcha()) + setIsSubmitting(false) + setError('') + }, 300) + } + + const formatDate = (dateString: string) => { + const date = new Date(dateString) + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + } + + return ( +
    +

    Customer Reviews

    + + {reviews.length > 0 && ( +
    + {reviews.map((review) => ( +
    +
    +
    +

    {review.name}

    + +
    + +
    +

    {review.text}

    +
    + ))} +
    + )} + +
    +

    Write a review

    + +
    + + setName(e.target.value)} + placeholder="Enter your name" + disabled={isSubmitting} + /> +
    + +
    + + +
    + +
    + +