From eab524c593fc85dee291cfd3df457c91a44d68da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Nederg=C3=A5rd?= Date: Thu, 20 Nov 2025 16:41:15 +0100 Subject: [PATCH 1/4] fix: green checkbox --- src/App.css | 76 ++++++++++++++++++++++++++++++++++++++++++++--------- src/App.tsx | 23 ++++++++++++++-- 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/src/App.css b/src/App.css index cfd784a..b5e842f 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 { @@ -152,7 +156,9 @@ display: flex; flex-direction: column; min-height: 100%; - transition: transform 200ms ease, box-shadow 200ms ease; + transition: + transform 200ms ease, + box-shadow 200ms ease; } .product-card__media { @@ -233,7 +239,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 +256,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 +284,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); } } diff --git a/src/App.tsx b/src/App.tsx index 22bb1f2..53ad4ac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -69,10 +69,29 @@ function ProductCard({ @@ -182,7 +201,7 @@ function App() { setHighlightedProduct((currentHighlight) => currentHighlight?.token === token ? null : currentHighlight, ) - }, 900) + }, 1200) } useEffect(() => { From 613b380b88a22c646837746d23901cdf02cf277a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Nederg=C3=A5rd?= Date: Thu, 20 Nov 2025 17:21:19 +0100 Subject: [PATCH 2/4] fix: customer reviews --- package-lock.json | 92 ++++++++-- package.json | 3 +- src/App.css | 418 +++++++++++++++++++++++++++++++++++++++++++ src/App.test.tsx | 13 +- src/App.tsx | 271 ++++++++++++++++++++-------- src/ProductPage.tsx | 166 +++++++++++++++++ src/Reviews.tsx | 258 ++++++++++++++++++++++++++ src/main.tsx | 5 +- src/utils/reviews.ts | 25 +++ 9 files changed, 1159 insertions(+), 92 deletions(-) create mode 100644 src/ProductPage.tsx create mode 100644 src/Reviews.tsx create mode 100644 src/utils/reviews.ts 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 b5e842f..c80642e 100644 --- a/src/App.css +++ b/src/App.css @@ -159,6 +159,14 @@ 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 { @@ -502,6 +510,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; @@ -517,4 +909,30 @@ .cart-panel { padding: 1.5rem; } + + .product-page { + padding: 1rem; + } + + .product-page__content { + gap: 2rem; + } + + .product-page__meta { + font-size: 1.2rem; + } + + .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..cb00fb1 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,13 +1,18 @@ 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() + render( + + + , + ) expect(screen.getByRole('heading', { name: /modern home shop/i })).toBeInTheDocument() expect(screen.getAllByTestId('product-card')).toHaveLength(products.length) @@ -15,7 +20,11 @@ describe('App', () => { 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 53ad4ac..8c5a334 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 { getAverageRating } from './utils/reviews' const currency = new Intl.NumberFormat('en-US', { style: 'currency', @@ -45,8 +48,35 @@ function ProductCard({ onAdd: () => void isHighlighted: boolean }) { + 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 ( -
@@ -60,7 +90,9 @@ function ProductCard({

{product.description}

{currency.format(product.price)} - ★ {product.rating.toFixed(1)} + {averageRating !== null && ( + ★ {averageRating.toFixed(1)} + )}
{product.colors.map((color) => ( @@ -70,7 +102,7 @@ function ProductCard({
-
+ ) } @@ -141,79 +173,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, - ) - }, 1200) - } - - 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 (
@@ -280,6 +264,20 @@ function App() { )} + {children} +
+ ) +} + +function HomePage({ + addToCart, + isHighlighted, +}: { + addToCart: (productId: string) => void + isHighlighted: (productId: string) => boolean +}) { + return ( + <>

New season edit

@@ -350,7 +348,7 @@ function App() { key={product.id} product={product} onAdd={() => addToCart(product.id)} - isHighlighted={highlightedProduct?.id === product.id} + isHighlighted={isHighlighted(product.id)} /> ))}
@@ -377,7 +375,124 @@ function App() {
- + + ) +} + +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, + ) + }, 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/ProductPage.tsx b/src/ProductPage.tsx new file mode 100644 index 0000000..c257ff5 --- /dev/null +++ b/src/ProductPage.tsx @@ -0,0 +1,166 @@ +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]) + + 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 shop + + +
+
+ {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/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} + /> +
+ +
+ + +
+ +
+ +