From fb801e375267fd6e953525cc9f34008e6c870aa1 Mon Sep 17 00:00:00 2001 From: hasicicsalih Date: Fri, 17 Apr 2026 15:05:45 +0200 Subject: [PATCH] Add login, reg, cart, newsletter und access to small batch releases --- parfum-shop/.gitignore | 2 + parfum-shop/eslint.config.js | 10 + parfum-shop/package-lock.json | 208 ++--- parfum-shop/package.json | 4 +- parfum-shop/server/catalog.js | 38 + parfum-shop/server/db.js | 169 ++++ parfum-shop/server/dev.js | 33 + parfum-shop/server/index.js | 732 ++++++++++++++++++ parfum-shop/src/App.jsx | 8 +- parfum-shop/src/components/CartToast.jsx | 40 + .../src/components/ProductDetailPage.css | 24 +- .../src/components/ProductDetailPage.jsx | 91 ++- parfum-shop/src/components/SharedNavbar.jsx | 33 + parfum-shop/src/components/ShopDrawer.css | 495 ++++++++++++ parfum-shop/src/components/ShopDrawer.jsx | 476 ++++++++++++ .../src/components/landing/HeroSection.jsx | 18 +- parfum-shop/src/main.jsx | 7 +- parfum-shop/src/pages/AboutPage.jsx | 22 +- parfum-shop/src/pages/DatenschutzPage.jsx | 22 +- parfum-shop/src/pages/DiscoverySetPage.jsx | 51 +- parfum-shop/src/pages/ImpressumPage.jsx | 22 +- parfum-shop/src/pages/SmallBatchPage.css | 125 +++ parfum-shop/src/pages/SmallBatchPage.jsx | 123 +++ parfum-shop/src/pages/SupportPage.jsx | 22 +- parfum-shop/src/shop/ShopContext.jsx | 408 ++++++++++ parfum-shop/src/shop/ShopContextBase.js | 3 + parfum-shop/src/shop/money.js | 5 + parfum-shop/src/shop/useShop.js | 10 + parfum-shop/src/style/navbar.css | 19 +- parfum-shop/vite.config.js | 5 + 30 files changed, 2979 insertions(+), 246 deletions(-) create mode 100644 parfum-shop/server/catalog.js create mode 100644 parfum-shop/server/db.js create mode 100644 parfum-shop/server/dev.js create mode 100644 parfum-shop/server/index.js create mode 100644 parfum-shop/src/components/CartToast.jsx create mode 100644 parfum-shop/src/components/SharedNavbar.jsx create mode 100644 parfum-shop/src/components/ShopDrawer.css create mode 100644 parfum-shop/src/components/ShopDrawer.jsx create mode 100644 parfum-shop/src/pages/SmallBatchPage.css create mode 100644 parfum-shop/src/pages/SmallBatchPage.jsx create mode 100644 parfum-shop/src/shop/ShopContext.jsx create mode 100644 parfum-shop/src/shop/ShopContextBase.js create mode 100644 parfum-shop/src/shop/money.js create mode 100644 parfum-shop/src/shop/useShop.js diff --git a/parfum-shop/.gitignore b/parfum-shop/.gitignore index a547bf3..bc35c82 100644 --- a/parfum-shop/.gitignore +++ b/parfum-shop/.gitignore @@ -11,6 +11,8 @@ node_modules dist dist-ssr *.local +data/*.sqlite +data/*.sqlite-* # Editor directories and files .vscode/* diff --git a/parfum-shop/eslint.config.js b/parfum-shop/eslint.config.js index 4fa125d..356ff75 100644 --- a/parfum-shop/eslint.config.js +++ b/parfum-shop/eslint.config.js @@ -26,4 +26,14 @@ export default defineConfig([ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], }, }, + { + files: ['server/**/*.js'], + languageOptions: { + ecmaVersion: 'latest', + globals: globals.node, + parserOptions: { + sourceType: 'module', + }, + }, + }, ]) diff --git a/parfum-shop/package-lock.json b/parfum-shop/package-lock.json index c51c371..cb70349 100644 --- a/parfum-shop/package-lock.json +++ b/parfum-shop/package-lock.json @@ -269,21 +269,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", - "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "dev": true, "license": "MIT", "optional": true, @@ -292,9 +292,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -562,26 +562,28 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@oxc-project/types": { - "version": "0.120.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", - "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", "dev": true, "license": "MIT", "funding": { @@ -589,9 +591,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", "cpu": [ "arm64" ], @@ -606,9 +608,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", "cpu": [ "arm64" ], @@ -623,9 +625,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", "cpu": [ "x64" ], @@ -640,9 +642,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", "cpu": [ "x64" ], @@ -657,9 +659,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", - "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", "cpu": [ "arm" ], @@ -674,13 +676,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -691,13 +696,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -708,13 +716,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -725,13 +736,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -742,13 +756,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -759,13 +776,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -776,9 +796,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", "cpu": [ "arm64" ], @@ -793,9 +813,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", - "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", "cpu": [ "wasm32" ], @@ -803,16 +823,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", "cpu": [ "arm64" ], @@ -827,9 +849,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", "cpu": [ "x64" ], @@ -2341,14 +2363,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", - "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.120.0", - "@rolldown/pluginutils": "1.0.0-rc.10" + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" @@ -2357,27 +2379,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-x64": "1.0.0-rc.10", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", - "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", "dev": true, "license": "MIT" }, @@ -2542,16 +2564,16 @@ } }, "node_modules/vite": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", - "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", + "picomatch": "^4.0.4", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.10", + "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "bin": { @@ -2569,7 +2591,7 @@ "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", diff --git a/parfum-shop/package.json b/parfum-shop/package.json index aa76d48..6bb12c3 100644 --- a/parfum-shop/package.json +++ b/parfum-shop/package.json @@ -4,7 +4,9 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "node server/dev.js", + "dev:frontend": "vite", + "dev:api": "node server/index.js", "build": "vite build", "lint": "eslint .", "preview": "vite preview" diff --git a/parfum-shop/server/catalog.js b/parfum-shop/server/catalog.js new file mode 100644 index 0000000..f2f95b7 --- /dev/null +++ b/parfum-shop/server/catalog.js @@ -0,0 +1,38 @@ +import perfumes from "../src/data/perfumes.js"; + +const parsePriceCents = (price) => { + const match = String(price).match(/(\d+)/); + return match ? Number(match[1]) * 100 : 0; +}; + +export const catalogProducts = [ + { + id: "discovery-set", + slug: "discovery-set", + name: "Discovery Set", + kind: "discovery_set", + size_label: "6 x 2ml", + price_cents: 4800, + discovery_credit_cents: 4800, + }, + ...perfumes.flatMap((perfume) => [ + { + id: `${perfume.slug}-sample`, + slug: perfume.slug, + name: `${perfume.name} Sample`, + kind: "sample", + size_label: "2ml", + price_cents: parsePriceCents(perfume.prices.sample), + discovery_credit_cents: 0, + }, + { + id: `${perfume.slug}-full`, + slug: perfume.slug, + name: `${perfume.name} Full Size`, + kind: "full_size", + size_label: "50ml", + price_cents: parsePriceCents(perfume.prices.full), + discovery_credit_cents: 0, + }, + ]), +]; diff --git a/parfum-shop/server/db.js b/parfum-shop/server/db.js new file mode 100644 index 0000000..aece297 --- /dev/null +++ b/parfum-shop/server/db.js @@ -0,0 +1,169 @@ +import { mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { DatabaseSync } from "node:sqlite"; +import { catalogProducts } from "./catalog.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +export const dbPath = join(__dirname, "../data/shop.sqlite"); + +mkdirSync(dirname(dbPath), { recursive: true }); + +export const db = new DatabaseSync(dbPath); + +db.exec(` + PRAGMA foreign_keys = ON; + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + password_salt TEXT NOT NULL, + name TEXT, + first_name TEXT NOT NULL, + surname TEXT NOT NULL, + address TEXT, + street_name TEXT, + house_number TEXT, + zip_code TEXT, + city TEXT, + birthdate TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS products ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL, + name TEXT NOT NULL, + kind TEXT NOT NULL, + size_label TEXT NOT NULL, + price_cents INTEGER NOT NULL, + discovery_credit_cents INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS cart_items ( + user_id INTEGER NOT NULL, + product_id TEXT NOT NULL, + quantity INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, product_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (product_id) REFERENCES products(id) + ); + + CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + subtotal_cents INTEGER NOT NULL, + discount_cents INTEGER NOT NULL, + total_cents INTEGER NOT NULL, + shipping_address TEXT NOT NULL, + payment_method TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS order_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL, + product_id TEXT NOT NULL, + quantity INTEGER NOT NULL, + unit_price_cents INTEGER NOT NULL, + line_total_cents INTEGER NOT NULL, + FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE, + FOREIGN KEY (product_id) REFERENCES products(id) + ); + + CREATE TABLE IF NOT EXISTS discovery_credits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + order_id INTEGER NOT NULL, + amount_cents INTEGER NOT NULL, + redeemed_order_id INTEGER, + created_at TEXT NOT NULL, + redeemed_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE, + FOREIGN KEY (redeemed_order_id) REFERENCES orders(id) + ); + + CREATE TABLE IF NOT EXISTS sample_credits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + slug TEXT NOT NULL, + order_id INTEGER NOT NULL, + amount_cents INTEGER NOT NULL, + redeemed_order_id INTEGER, + created_at TEXT NOT NULL, + redeemed_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE, + FOREIGN KEY (redeemed_order_id) REFERENCES orders(id) + ); + + CREATE TABLE IF NOT EXISTS notification_preferences ( + user_id INTEGER PRIMARY KEY, + drops_enabled INTEGER NOT NULL DEFAULT 0, + restocks_enabled INTEGER NOT NULL DEFAULT 0, + small_batch_enabled INTEGER NOT NULL DEFAULT 0, + discovery_enabled INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS product_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + product_id TEXT NOT NULL, + type TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (user_id, product_id, type), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (product_id) REFERENCES products(id) + ); +`); + +const seedProduct = db.prepare(` + INSERT INTO products ( + id, slug, name, kind, size_label, price_cents, discovery_credit_cents + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + slug = excluded.slug, + name = excluded.name, + kind = excluded.kind, + size_label = excluded.size_label, + price_cents = excluded.price_cents, + discovery_credit_cents = excluded.discovery_credit_cents +`); + +for (const product of catalogProducts) { + seedProduct.run( + product.id, + product.slug, + product.name, + product.kind, + product.size_label, + product.price_cents, + product.discovery_credit_cents + ); +} + +db.exec(` + DELETE FROM discovery_credits + WHERE id NOT IN ( + SELECT MIN(id) + FROM discovery_credits + GROUP BY user_id + ); +`); + +console.log(`SQLite shop database ready at ${dbPath}`); diff --git a/parfum-shop/server/dev.js b/parfum-shop/server/dev.js new file mode 100644 index 0000000..7abf711 --- /dev/null +++ b/parfum-shop/server/dev.js @@ -0,0 +1,33 @@ +import { spawn } from "node:child_process"; + +const run = (name, command, args, env = {}) => { + const child = spawn(command, args, { + stdio: "inherit", + shell: process.platform === "win32", + env: { ...process.env, ...env }, + }); + + child.on("exit", (code, signal) => { + if (signal) { + console.log(`${name} stopped with ${signal}`); + return; + } + if (code !== 0) { + console.log(`${name} exited with code ${code}`); + process.exitCode = code; + } + }); + + return child; +}; + +const api = run("api", "node", ["server/index.js"], { API_PORT: "4174" }); +const vite = run("vite", "node_modules/.bin/vite", []); + +const stop = () => { + api.kill("SIGTERM"); + vite.kill("SIGTERM"); +}; + +process.on("SIGINT", stop); +process.on("SIGTERM", stop); diff --git a/parfum-shop/server/index.js b/parfum-shop/server/index.js new file mode 100644 index 0000000..d9d2a05 --- /dev/null +++ b/parfum-shop/server/index.js @@ -0,0 +1,732 @@ +import { pbkdf2Sync, randomBytes, timingSafeEqual } from "node:crypto"; +import { createServer } from "node:http"; +import { db } from "./db.js"; + +const PORT = Number(process.env.API_PORT || 4174); +const SESSION_DAYS = 30; +const now = () => new Date().toISOString(); +const addDays = (days) => new Date(Date.now() + days * 86400000).toISOString(); + +const moneyId = (value) => value; + +const json = (res, status, payload) => { + res.writeHead(status, { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,POST,PATCH,DELETE,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }); + res.end(JSON.stringify(payload)); +}; + +const readBody = async (req) => + new Promise((resolve, reject) => { + let raw = ""; + req.on("data", (chunk) => { + raw += chunk; + if (raw.length > 1_000_000) { + reject(new Error("Request body too large")); + req.destroy(); + } + }); + req.on("end", () => { + if (!raw) { + resolve({}); + return; + } + try { + resolve(JSON.parse(raw)); + } catch { + reject(new Error("Invalid JSON")); + } + }); + req.on("error", reject); + }); + +const hashPassword = (password, salt = randomBytes(16).toString("hex")) => ({ + salt, + hash: pbkdf2Sync(password, salt, 120000, 64, "sha512").toString("hex"), +}); + +const verifyPassword = (password, salt, expectedHash) => { + const { hash } = hashPassword(password, salt); + const actual = Buffer.from(hash, "hex"); + const expected = Buffer.from(expectedHash, "hex"); + return actual.length === expected.length && timingSafeEqual(actual, expected); +}; + +const getBearerToken = (req) => { + const auth = req.headers.authorization || ""; + const match = auth.match(/^Bearer\s+(.+)$/i); + return match ? match[1] : null; +}; + +const composeAddress = ({ street_name, house_number, zip_code, city }) => + [street_name, house_number].filter(Boolean).join(" ").trim() + + (zip_code || city ? `, ${[zip_code, city].filter(Boolean).join(" ")}` : ""); + +const normalizeEmail = (email) => String(email || "").trim().toLowerCase(); + +const notificationForUser = (userId) => { + const existing = db + .prepare("SELECT * FROM notification_preferences WHERE user_id = ?") + .get(userId); + if (existing) return existing; + + db.prepare( + `INSERT INTO notification_preferences ( + user_id, drops_enabled, restocks_enabled, small_batch_enabled, discovery_enabled, updated_at + ) VALUES (?, 0, 0, 0, 0, ?)` + ).run(userId, now()); + return db.prepare("SELECT * FROM notification_preferences WHERE user_id = ?").get(userId); +}; + +const rowToUser = (row) => { + if (!row) return null; + return { + id: row.id, + email: row.email, + name: row.name, + first_name: row.first_name, + surname: row.surname, + address: row.address, + street_name: row.street_name || "", + house_number: row.house_number || "", + zip_code: row.zip_code || "", + city: row.city || "", + birthdate: row.birthdate || "", + created_at: row.created_at, + notifications: prefsToJson(notificationForUser(row.id)), + productSubscriptions: getProductSubscriptions(row.id), + discoveryStatus: getDiscoveryStatus(row.id), + sampleCredits: getSampleCreditStatus(row.id), + loyaltyStatus: getLoyaltyStatus(row.id), + }; +}; + +const prefsToJson = (prefs) => ({ + drops_enabled: Boolean(prefs.drops_enabled), + restocks_enabled: Boolean(prefs.restocks_enabled), + small_batch_enabled: Boolean(prefs.small_batch_enabled), + discovery_enabled: Boolean(prefs.discovery_enabled), + updated_at: prefs.updated_at, +}); + +const authenticate = (req) => { + const token = getBearerToken(req); + if (!token) return null; + const session = db + .prepare("SELECT * FROM sessions WHERE token = ?") + .get(token); + if (!session) return null; + if (new Date(session.expires_at).getTime() <= Date.now()) { + db.prepare("DELETE FROM sessions WHERE token = ?").run(token); + return null; + } + const user = db.prepare("SELECT * FROM users WHERE id = ?").get(session.user_id); + return user ? { token, user } : null; +}; + +const requireAuth = (req, res) => { + const auth = authenticate(req); + if (!auth) { + json(res, 401, { error: "Please log in to continue." }); + return null; + } + return auth; +}; + +const createSession = (userId) => { + const token = randomBytes(32).toString("hex"); + db.prepare( + "INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)" + ).run(token, userId, now(), addDays(SESSION_DAYS)); + return token; +}; + +const getCartRows = (userId) => + db.prepare( + `SELECT c.product_id, c.quantity, p.slug, p.name, p.kind, p.size_label, + p.price_cents, p.discovery_credit_cents + FROM cart_items c + JOIN products p ON p.id = c.product_id + WHERE c.user_id = ? + ORDER BY c.created_at ASC` + ).all(userId); + +const getAvailableDiscounts = (userId, rows) => { + const discounts = []; + const fullRows = rows.filter((row) => row.kind === "full_size"); + const fullTotal = fullRows.reduce( + (sum, row) => sum + row.price_cents * row.quantity, + 0 + ); + + if (fullTotal > 0) { + const discovery = db + .prepare( + `SELECT * FROM discovery_credits + WHERE user_id = ? AND redeemed_order_id IS NULL + ORDER BY id ASC LIMIT 1` + ) + .get(userId); + if (discovery) { + discounts.push({ + type: "discovery", + creditId: discovery.id, + amount_cents: Math.min(discovery.amount_cents, fullTotal), + label: "Discovery Set credit", + }); + } + + for (const row of fullRows) { + const sample = db + .prepare( + `SELECT * FROM sample_credits + WHERE user_id = ? AND slug = ? AND redeemed_order_id IS NULL + ORDER BY id ASC LIMIT 1` + ) + .get(userId, row.slug); + if (sample) { + discounts.push({ + type: "sample", + creditId: sample.id, + slug: row.slug, + product_id: row.product_id, + amount_cents: Math.min(sample.amount_cents, row.price_cents * row.quantity), + label: `${row.name.replace(" Full Size", "")} sample credit`, + }); + } + } + } + + return discounts.filter((discount) => discount.amount_cents > 0); +}; + +const getCart = (userId) => { + const rows = getCartRows(userId); + const items = rows.map((row) => ({ + product_id: row.product_id, + quantity: row.quantity, + line_total_cents: row.price_cents * row.quantity, + product: { + id: row.product_id, + slug: row.slug, + name: row.name, + kind: row.kind, + size_label: row.size_label, + price_cents: row.price_cents, + discovery_credit_cents: row.discovery_credit_cents, + }, + })); + const subtotal_cents = items.reduce((sum, item) => sum + item.line_total_cents, 0); + const discounts = getAvailableDiscounts(userId, rows); + const discount_cents = discounts.reduce((sum, item) => sum + item.amount_cents, 0); + return { + items, + subtotal_cents, + discount_cents, + total_cents: Math.max(0, subtotal_cents - discount_cents), + total_quantity: items.reduce((sum, item) => sum + item.quantity, 0), + discounts, + }; +}; + +const getOrders = (userId) => { + const orders = db + .prepare("SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC") + .all(userId); + const itemStmt = db.prepare( + `SELECT oi.*, p.name, p.slug, p.kind, p.size_label + FROM order_items oi + JOIN products p ON p.id = oi.product_id + WHERE oi.order_id = ? + ORDER BY oi.id ASC` + ); + return orders.map((order) => ({ + ...order, + items: itemStmt.all(order.id).map((item) => ({ + id: item.id, + product_id: item.product_id, + quantity: item.quantity, + unit_price_cents: item.unit_price_cents, + line_total_cents: item.line_total_cents, + product: { + id: item.product_id, + name: item.name, + slug: item.slug, + kind: item.kind, + size_label: item.size_label, + }, + })), + })); +}; + +function getDiscoveryStatus(userId) { + const credit = db + .prepare("SELECT * FROM discovery_credits WHERE user_id = ? ORDER BY id ASC LIMIT 1") + .get(userId); + if (!credit) return "No Discount atm"; + return credit.redeemed_order_id ? "Discount already used" : "Discount available"; +} + +function getSampleCreditStatus(userId) { + return db + .prepare( + `SELECT slug, amount_cents, redeemed_order_id, created_at, redeemed_at + FROM sample_credits + WHERE user_id = ? + ORDER BY created_at DESC` + ) + .all(userId) + .map((credit) => ({ + ...credit, + status: credit.redeemed_order_id ? "used" : "available", + })); +} + +function getProductSubscriptions(userId) { + return db + .prepare( + `SELECT ps.id, ps.product_id, ps.type, ps.created_at, + p.slug, p.name, p.kind, p.size_label + FROM product_subscriptions ps + JOIN products p ON p.id = ps.product_id + WHERE ps.user_id = ? + ORDER BY ps.created_at DESC, ps.id DESC` + ) + .all(userId); +} + +function getLoyaltyStatus(userId) { + const orderStats = db + .prepare( + `SELECT COUNT(*) AS purchases, COALESCE(SUM(total_cents), 0) AS spent_cents + FROM orders + WHERE user_id = ?` + ) + .get(userId); + const discovery = db + .prepare( + `SELECT 1 + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE o.user_id = ? AND oi.product_id = 'discovery-set' + LIMIT 1` + ) + .get(userId); + const full = db + .prepare( + `SELECT 1 + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + JOIN products p ON p.id = oi.product_id + WHERE o.user_id = ? AND p.kind = 'full_size' + LIMIT 1` + ) + .get(userId); + + const status = { + hasDiscoverySet: Boolean(discovery), + hasFullSize: Boolean(full), + purchases: Number(orderStats.purchases || 0), + spent_cents: Number(orderStats.spent_cents || 0), + }; + status.unlocked = + status.hasDiscoverySet && + status.hasFullSize && + status.purchases >= 3 && + status.spent_cents > 50000; + return status; +} + +const stateForUser = (userRow, token) => ({ + token, + user: rowToUser(userRow), + cart: getCart(userRow.id), + orders: getOrders(userRow.id), +}); + +const register = async (req, res) => { + const body = await readBody(req); + const email = normalizeEmail(body.email); + const password = String(body.password || ""); + const firstName = String(body.first_name || body.firstName || "").trim(); + const surname = String(body.surname || "").trim(); + + if (!firstName || !surname || !email || !password) { + json(res, 400, { error: "First name, surname, email, and password are required." }); + return; + } + + const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email); + if (existing) { + json(res, 409, { error: "An account with this email already exists." }); + return; + } + + const { salt, hash } = hashPassword(password); + const fullName = `${firstName} ${surname}`.trim(); + const created = now(); + const result = db + .prepare( + `INSERT INTO users ( + email, password_hash, password_salt, name, first_name, surname, + address, street_name, house_number, zip_code, city, birthdate, created_at + ) VALUES (?, ?, ?, ?, ?, ?, '', '', '', '', '', '', ?)` + ) + .run(email, hash, salt, fullName, firstName, surname, created); + + notificationForUser(result.lastInsertRowid); + const user = db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid); + json(res, 201, stateForUser(user, createSession(user.id))); +}; + +const login = async (req, res) => { + const body = await readBody(req); + const email = normalizeEmail(body.email); + const password = String(body.password || ""); + const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email); + + if (!user || !verifyPassword(password, user.password_salt, user.password_hash)) { + json(res, 401, { error: "Invalid email or password." }); + return; + } + + json(res, 200, stateForUser(user, createSession(user.id))); +}; + +const patchProfile = async (req, res, user) => { + const body = await readBody(req); + const current = db.prepare("SELECT * FROM users WHERE id = ?").get(user.id); + const firstName = String(body.first_name ?? current.first_name ?? "").trim(); + const surname = String(body.surname ?? current.surname ?? "").trim(); + const streetName = String(body.street_name ?? current.street_name ?? "").trim(); + const houseNumber = String(body.house_number ?? current.house_number ?? "").trim(); + const zipCode = String(body.zip_code ?? current.zip_code ?? "").trim(); + const city = String(body.city ?? current.city ?? "").trim(); + const birthdate = String(body.birthdate ?? current.birthdate ?? "").trim(); + const name = `${firstName} ${surname}`.trim(); + const address = composeAddress({ + street_name: streetName, + house_number: houseNumber, + zip_code: zipCode, + city, + }); + + if (!firstName || !surname) { + json(res, 400, { error: "First name and surname are required." }); + return; + } + + db.prepare( + `UPDATE users + SET name = ?, first_name = ?, surname = ?, address = ?, street_name = ?, + house_number = ?, zip_code = ?, city = ?, birthdate = ? + WHERE id = ?` + ).run(name, firstName, surname, address, streetName, houseNumber, zipCode, city, birthdate, user.id); + + const updated = db.prepare("SELECT * FROM users WHERE id = ?").get(user.id); + json(res, 200, { user: rowToUser(updated) }); +}; + +const patchNotifications = async (req, res, user) => { + const body = await readBody(req); + db.prepare( + `INSERT INTO notification_preferences ( + user_id, drops_enabled, restocks_enabled, small_batch_enabled, discovery_enabled, updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + drops_enabled = excluded.drops_enabled, + restocks_enabled = excluded.restocks_enabled, + small_batch_enabled = excluded.small_batch_enabled, + discovery_enabled = excluded.discovery_enabled, + updated_at = excluded.updated_at` + ).run( + user.id, + body.drops_enabled ? 1 : 0, + body.restocks_enabled ? 1 : 0, + body.small_batch_enabled ? 1 : 0, + body.discovery_enabled ? 1 : 0, + now() + ); + json(res, 200, { notifications: prefsToJson(notificationForUser(user.id)) }); +}; + +const addCartItem = async (req, res, user) => { + const body = await readBody(req); + const productId = moneyId(String(body.productId || body.product_id || "")); + const quantity = Math.max(1, Number(body.quantity || 1)); + const product = db.prepare("SELECT * FROM products WHERE id = ?").get(productId); + if (!product) { + json(res, 404, { error: "Product not found." }); + return; + } + const timestamp = now(); + db.prepare( + `INSERT INTO cart_items (user_id, product_id, quantity, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(user_id, product_id) DO UPDATE SET + quantity = cart_items.quantity + excluded.quantity, + updated_at = excluded.updated_at` + ).run(user.id, productId, quantity, timestamp, timestamp); + json(res, 200, { + cart: getCart(user.id), + message: `${quantity} x ${product.name} added.`, + }); +}; + +const patchCartItem = async (req, res, user, productId) => { + const body = await readBody(req); + const quantity = Number(body.quantity); + if (!Number.isFinite(quantity)) { + json(res, 400, { error: "Quantity is required." }); + return; + } + if (quantity <= 0) { + db.prepare("DELETE FROM cart_items WHERE user_id = ? AND product_id = ?").run(user.id, productId); + } else { + db.prepare( + "UPDATE cart_items SET quantity = ?, updated_at = ? WHERE user_id = ? AND product_id = ?" + ).run(Math.floor(quantity), now(), user.id, productId); + } + json(res, 200, { cart: getCart(user.id) }); +}; + +const deleteCartItem = (res, user, productId) => { + db.prepare("DELETE FROM cart_items WHERE user_id = ? AND product_id = ?").run(user.id, productId); + json(res, 200, { cart: getCart(user.id) }); +}; + +const checkout = async (req, res, user) => { + const body = await readBody(req); + const rows = getCartRows(user.id); + if (rows.length === 0) { + json(res, 400, { error: "Your cart is empty." }); + return; + } + + const addressFields = { + street_name: String(body.street_name || "").trim(), + house_number: String(body.house_number || "").trim(), + zip_code: String(body.zip_code || "").trim(), + city: String(body.city || "").trim(), + }; + const paymentMethod = String(body.payment_method || body.paymentMethod || "").trim(); + if (!addressFields.street_name || !addressFields.house_number || !addressFields.zip_code || !addressFields.city) { + json(res, 400, { error: "Street name, house number, ZIP code, and city are required." }); + return; + } + if (!["Bill", "Card", "Twint", "PayPal"].includes(paymentMethod)) { + json(res, 400, { error: "Choose a payment method." }); + return; + } + + const subtotal = rows.reduce((sum, row) => sum + row.price_cents * row.quantity, 0); + const discounts = getAvailableDiscounts(user.id, rows); + const discountTotal = discounts.reduce((sum, discount) => sum + discount.amount_cents, 0); + const total = Math.max(0, subtotal - discountTotal); + const shippingAddress = composeAddress(addressFields); + const timestamp = now(); + + try { + db.exec("BEGIN"); + const orderResult = db + .prepare( + `INSERT INTO orders ( + user_id, subtotal_cents, discount_cents, total_cents, + shipping_address, payment_method, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run(user.id, subtotal, discountTotal, total, shippingAddress, paymentMethod, timestamp); + const orderId = orderResult.lastInsertRowid; + + const insertItem = db.prepare( + `INSERT INTO order_items ( + order_id, product_id, quantity, unit_price_cents, line_total_cents + ) VALUES (?, ?, ?, ?, ?)` + ); + for (const row of rows) { + insertItem.run(orderId, row.product_id, row.quantity, row.price_cents, row.price_cents * row.quantity); + } + + for (const discount of discounts) { + if (discount.type === "discovery") { + db.prepare( + "UPDATE discovery_credits SET redeemed_order_id = ?, redeemed_at = ? WHERE id = ?" + ).run(orderId, timestamp, discount.creditId); + } + if (discount.type === "sample") { + db.prepare( + "UPDATE sample_credits SET redeemed_order_id = ?, redeemed_at = ? WHERE id = ?" + ).run(orderId, timestamp, discount.creditId); + } + } + + const hasDiscoverySet = rows.some((row) => row.product_id === "discovery-set"); + const hadDiscoveryCredit = db + .prepare("SELECT id FROM discovery_credits WHERE user_id = ? LIMIT 1") + .get(user.id); + if (hasDiscoverySet && !hadDiscoveryCredit) { + db.prepare( + `INSERT INTO discovery_credits ( + user_id, order_id, amount_cents, redeemed_order_id, created_at, redeemed_at + ) VALUES (?, ?, 4800, NULL, ?, NULL)` + ).run(user.id, orderId, timestamp); + } + + const sampleRows = rows.filter((row) => row.kind === "sample"); + for (const row of sampleRows) { + const existing = db + .prepare("SELECT id FROM sample_credits WHERE user_id = ? AND slug = ? LIMIT 1") + .get(user.id, row.slug); + if (!existing) { + db.prepare( + `INSERT INTO sample_credits ( + user_id, slug, order_id, amount_cents, redeemed_order_id, created_at, redeemed_at + ) VALUES (?, ?, ?, ?, NULL, ?, NULL)` + ).run(user.id, row.slug, orderId, row.price_cents, timestamp); + } + } + + db.prepare("DELETE FROM cart_items WHERE user_id = ?").run(user.id); + db.exec("COMMIT"); + + const updatedUser = db.prepare("SELECT * FROM users WHERE id = ?").get(user.id); + json(res, 200, { + order: getOrders(user.id).find((order) => order.id === orderId), + cart: getCart(user.id), + orders: getOrders(user.id), + user: rowToUser(updatedUser), + }); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } +}; + +const subscribeProduct = async (req, res, user) => { + const body = await readBody(req); + const productId = String(body.product_id || body.productId || "").trim(); + const type = String(body.type || "restock").trim(); + const product = db.prepare("SELECT * FROM products WHERE id = ?").get(productId); + if (!product) { + json(res, 404, { error: "Product not found." }); + return; + } + db.prepare( + `INSERT OR IGNORE INTO product_subscriptions (user_id, product_id, type, created_at) + VALUES (?, ?, ?, ?)` + ).run(user.id, productId, type, now()); + json(res, 200, { + ok: true, + message: `${product.name} ${type} subscription saved.`, + subscriptions: getProductSubscriptions(user.id), + }); +}; + +const deleteProductSubscription = (res, user, id) => { + db.prepare("DELETE FROM product_subscriptions WHERE id = ? AND user_id = ?").run( + id, + user.id + ); + json(res, 200, { + ok: true, + subscriptions: getProductSubscriptions(user.id), + }); +}; + +const smallBatch = (res, user) => { + const loyaltyStatus = getLoyaltyStatus(user.id); + const releases = loyaltyStatus.unlocked + ? [ + { + type: "Archive Batch", + name: "KALTER BETON Archive Batch 01", + note: "A colder iris-heavy return from the first concrete accord trials.", + }, + { + type: "Prototype", + name: "NASSER MARMOR Fog Prototype", + note: "A misted marble study with softened aldehydes and mineral musk.", + }, + { + type: "Small Batch", + name: "SCHWARZES BENZIN Night Run", + note: "Low-light petrol, birch smoke, and leather in a numbered run.", + }, + ] + : []; + json(res, 200, { loyaltyStatus, releases }); +}; + +const route = async (req, res) => { + if (req.method === "OPTIONS") { + json(res, 204, {}); + return; + } + + const url = new URL(req.url, `http://${req.headers.host}`); + const path = url.pathname; + + if (req.method === "GET" && path === "/api/catalog") { + json(res, 200, { products: db.prepare("SELECT * FROM products ORDER BY id ASC").all() }); + return; + } + + if (req.method === "POST" && path === "/api/auth/register") return register(req, res); + if (req.method === "POST" && path === "/api/auth/login") return login(req, res); + + if (req.method === "POST" && path === "/api/auth/logout") { + const token = getBearerToken(req); + if (token) db.prepare("DELETE FROM sessions WHERE token = ?").run(token); + json(res, 200, { ok: true }); + return; + } + + if (req.method === "GET" && path === "/api/auth/session") { + const auth = requireAuth(req, res); + if (!auth) return; + json(res, 200, stateForUser(auth.user, auth.token)); + return; + } + + const auth = requireAuth(req, res); + if (!auth) return; + const { user } = auth; + + if (req.method === "PATCH" && path === "/api/profile") return patchProfile(req, res, user); + if (req.method === "PATCH" && path === "/api/notifications") return patchNotifications(req, res, user); + if (req.method === "GET" && path === "/api/cart") { + json(res, 200, { cart: getCart(user.id) }); + return; + } + if (req.method === "POST" && path === "/api/cart/items") return addCartItem(req, res, user); + if (req.method === "POST" && path === "/api/cart/checkout") return checkout(req, res, user); + if (req.method === "POST" && path === "/api/product-subscriptions") return subscribeProduct(req, res, user); + if (req.method === "GET" && path === "/api/small-batch") return smallBatch(res, user); + + const subscriptionMatch = path.match(/^\/api\/product-subscriptions\/(\d+)$/); + if (subscriptionMatch && req.method === "DELETE") { + return deleteProductSubscription(res, user, Number(subscriptionMatch[1])); + } + + const itemMatch = path.match(/^\/api\/cart\/items\/([^/]+)$/); + if (itemMatch && req.method === "PATCH") { + return patchCartItem(req, res, user, decodeURIComponent(itemMatch[1])); + } + if (itemMatch && req.method === "DELETE") { + return deleteCartItem(res, user, decodeURIComponent(itemMatch[1])); + } + + json(res, 404, { error: "Route not found." }); +}; + +createServer((req, res) => { + route(req, res).catch((error) => { + console.error(error); + json(res, 500, { error: "Server error." }); + }); +}).listen(PORT, () => { + console.log(`Shop API listening on http://localhost:${PORT}`); +}); diff --git a/parfum-shop/src/App.jsx b/parfum-shop/src/App.jsx index 8308007..5b49eb7 100644 --- a/parfum-shop/src/App.jsx +++ b/parfum-shop/src/App.jsx @@ -6,9 +6,12 @@ import ImpressumPage from "./pages/ImpressumPage"; import DatenschutzPage from "./pages/DatenschutzPage"; import SupportPage from "./pages/SupportPage"; import DiscoverySetPage from "./pages/DiscoverySetPage"; +import SmallBatchPage from "./pages/SmallBatchPage"; import Footer from "./components/Footer"; import SupportChatbot from "./components/SupportChatbot"; import ScrollToTop from "./components/ScrollToTop"; +import ShopDrawer from "./components/ShopDrawer"; +import CartToast from "./components/CartToast"; function App() { return ( @@ -23,12 +26,15 @@ function App() { } /> } /> } /> + } /> + +