Add login, reg, cart, newsletter und access to small batch releases

This commit is contained in:
Salih Hasicic 2026-04-17 15:05:45 +02:00
parent 414967441a
commit fb801e3752
30 changed files with 2979 additions and 246 deletions

View File

@ -11,6 +11,8 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
data/*.sqlite
data/*.sqlite-*
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

View File

@ -26,4 +26,14 @@ export default defineConfig([
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
}, },
}, },
{
files: ['server/**/*.js'],
languageOptions: {
ecmaVersion: 'latest',
globals: globals.node,
parserOptions: {
sourceType: 'module',
},
},
},
]) ])

View File

@ -269,21 +269,21 @@
} }
}, },
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.9.0", "version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.2.0", "@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.9.0", "version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -292,9 +292,9 @@
} }
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.0", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -562,26 +562,28 @@
} }
}, },
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1" "@tybys/wasm-util": "^0.10.1"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/Brooooooklyn" "url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.120.0", "version": "0.124.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
"integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@ -589,9 +591,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -606,9 +608,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -623,9 +625,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -640,9 +642,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -657,9 +659,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
"integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -674,13 +676,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -691,13 +696,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -708,13 +716,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -725,13 +736,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -742,13 +756,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -759,13 +776,16 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -776,9 +796,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -793,9 +813,9 @@
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi": { "node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
"integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
@ -803,16 +823,18 @@
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "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": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -827,9 +849,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2341,14 +2363,14 @@
} }
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
"integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.120.0", "@oxc-project/types": "=0.124.0",
"@rolldown/pluginutils": "1.0.0-rc.10" "@rolldown/pluginutils": "1.0.0-rc.15"
}, },
"bin": { "bin": {
"rolldown": "bin/cli.mjs" "rolldown": "bin/cli.mjs"
@ -2357,27 +2379,27 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-android-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.15",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
} }
}, },
"node_modules/rolldown/node_modules/@rolldown/pluginutils": { "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
"integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -2542,16 +2564,16 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.1", "version": "8.0.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
"integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.3", "picomatch": "^4.0.4",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"rolldown": "1.0.0-rc.10", "rolldown": "1.0.0-rc.15",
"tinyglobby": "^0.2.15" "tinyglobby": "^0.2.15"
}, },
"bin": { "bin": {
@ -2569,7 +2591,7 @@
"peerDependencies": { "peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0", "@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0", "@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0", "esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0", "jiti": ">=1.21.0",
"less": "^4.0.0", "less": "^4.0.0",
"sass": "^1.70.0", "sass": "^1.70.0",

View File

@ -4,7 +4,9 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "node server/dev.js",
"dev:frontend": "vite",
"dev:api": "node server/index.js",
"build": "vite build", "build": "vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"

View File

@ -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,
},
]),
];

169
parfum-shop/server/db.js Normal file
View File

@ -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}`);

33
parfum-shop/server/dev.js Normal file
View File

@ -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);

732
parfum-shop/server/index.js Normal file
View File

@ -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}`);
});

View File

@ -6,9 +6,12 @@ import ImpressumPage from "./pages/ImpressumPage";
import DatenschutzPage from "./pages/DatenschutzPage"; import DatenschutzPage from "./pages/DatenschutzPage";
import SupportPage from "./pages/SupportPage"; import SupportPage from "./pages/SupportPage";
import DiscoverySetPage from "./pages/DiscoverySetPage"; import DiscoverySetPage from "./pages/DiscoverySetPage";
import SmallBatchPage from "./pages/SmallBatchPage";
import Footer from "./components/Footer"; import Footer from "./components/Footer";
import SupportChatbot from "./components/SupportChatbot"; import SupportChatbot from "./components/SupportChatbot";
import ScrollToTop from "./components/ScrollToTop"; import ScrollToTop from "./components/ScrollToTop";
import ShopDrawer from "./components/ShopDrawer";
import CartToast from "./components/CartToast";
function App() { function App() {
return ( return (
@ -23,8 +26,11 @@ function App() {
<Route path="/datenschutz" element={<DatenschutzPage />} /> <Route path="/datenschutz" element={<DatenschutzPage />} />
<Route path="/support" element={<SupportPage />} /> <Route path="/support" element={<SupportPage />} />
<Route path="/discovery-set" element={<DiscoverySetPage />} /> <Route path="/discovery-set" element={<DiscoverySetPage />} />
<Route path="/small-batch" element={<SmallBatchPage />} />
</Routes> </Routes>
<ShopDrawer />
<CartToast />
<Footer /> <Footer />
<SupportChatbot /> <SupportChatbot />
</> </>

View File

@ -0,0 +1,40 @@
import { useShop } from "../shop/useShop";
import "./ShopDrawer.css";
function CartToast() {
const {
cartToast,
dismissToast,
openCart,
openProfile,
} = useShop();
if (!cartToast) return null;
const runAction = () => {
dismissToast();
if (cartToast.actionPanel === "cart") {
openCart();
}
if (cartToast.actionPanel === "profile") {
openProfile();
}
};
return (
<div className="cart-toast">
<button className="cart-toast-close" type="button" onClick={dismissToast}>
x
</button>
<strong>{cartToast.title}</strong>
<p>{cartToast.message}</p>
{cartToast.actionLabel && (
<button type="button" onClick={runAction}>
{cartToast.actionLabel}
</button>
)}
</div>
);
}
export default CartToast;

View File

@ -922,6 +922,11 @@
max-width: 420px; max-width: 420px;
} }
.discovery-note-text .discount-preview {
margin-top: 8px;
color: #fff;
}
.discovery-note-btn { .discovery-note-btn {
border: none; border: none;
border-radius: 999px; border-radius: 999px;
@ -972,6 +977,23 @@
background: rgba(255, 106, 0, 0.8); background: rgba(255, 106, 0, 0.8);
} }
.restock-button {
width: 100%;
min-height: 48px;
margin-top: 10px;
border: 1px solid #d6d6d6;
border-radius: 0;
background: #f8f8f8;
color: #1f1f1f;
font-size: 12px;
letter-spacing: 0.13em;
cursor: pointer;
}
.restock-button:hover {
outline: 1px solid #ff6a00;
}
/* --- Discovery Hinweis + Kaufen Button End --- */ /* --- Discovery Hinweis + Kaufen Button End --- */
/* --- Bottom CTA Start --- */ /* --- Bottom CTA Start --- */

View File

@ -1,11 +1,19 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router"; import { Link, useNavigate, useParams } from "react-router";
import perfumes from "../data/perfumes"; import perfumes from "../data/perfumes";
import "../style/navbar.css"; import SharedNavbar from "./SharedNavbar";
import { formatChf } from "../shop/money";
import { useShop } from "../shop/useShop";
import "./ProductDetailPage.css"; import "./ProductDetailPage.css";
const priceToCents = (price) => {
const match = String(price).match(/(\d+)/);
return match ? Number(match[1]) * 100 : 0;
};
function ProductDetailContent({ perfumeSlug }) { function ProductDetailContent({ perfumeSlug }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { addToCart, subscribeToProduct, user } = useShop();
const perfume = useMemo( const perfume = useMemo(
() => perfumes.find((item) => item.slug === perfumeSlug) || perfumes[0], () => perfumes.find((item) => item.slug === perfumeSlug) || perfumes[0],
@ -18,6 +26,20 @@ function ProductDetailContent({ perfumeSlug }) {
const [selectedSize, setSelectedSize] = useState("sample"); const [selectedSize, setSelectedSize] = useState("sample");
const [showReviewDetails, setShowReviewDetails] = useState(false); const [showReviewDetails, setShowReviewDetails] = useState(false);
const [commentPage, setCommentPage] = useState(0); const [commentPage, setCommentPage] = useState(0);
const selectedProductId = `${perfume.slug}-${selectedSize === "sample" ? "sample" : "full"}`;
const selectedProductLabel = selectedSize === "sample" ? "Sample" : "Full Size";
const selectedPriceCents = priceToCents(perfume.prices[selectedSize]);
const sampleCredit = user?.sampleCredits?.find(
(credit) => credit.slug === perfume.slug && credit.status === "available"
);
const discountPreviewCents =
selectedSize === "full"
? Math.min(
selectedPriceCents,
(user?.discoveryStatus === "Discount available" ? 4800 : 0) +
(sampleCredit?.amount_cents || 0)
)
: 0;
const sizeOptions = [ const sizeOptions = [
{ {
@ -71,22 +93,7 @@ function ProductDetailContent({ perfumeSlug }) {
return ( return (
<div className="detail-page"> <div className="detail-page">
<nav className="navbar navbar--light"> <SharedNavbar variant="light" />
<div className="nav-pill">
<Link to="/" className="nav-link">
atmos
</Link>
<Link to="/#dufte" className="nav-link active">
Düfte
</Link>
<Link to="/discovery-set" className="nav-link">
Testen
</Link>
<a href="#cart" className="nav-link">
Cart
</a>
</div>
</nav>
<main className="detail-shell"> <main className="detail-shell">
<div className="detail-topbar"> <div className="detail-topbar">
@ -246,11 +253,17 @@ function ProductDetailContent({ perfumeSlug }) {
<div className="discovery-note"> <div className="discovery-note">
<div className="discovery-note-text"> <div className="discovery-note-text">
<strong>Discovery Set wird angerechnet</strong> <strong>Discovery Set wird einmalig angerechnet</strong>
<p> <p>
Hast du das Discovery Set gekauft, wird der volle Preis beim Kauf Nur das erste Discovery Set erzeugt CHF 48 Guthaben. Es wird
automatisch abgezogen. einmal bei einem späteren Full-Size-Kauf automatisch abgezogen.
</p> </p>
{discountPreviewCents > 0 && (
<p className="discount-preview">
Erwarteter Preis mit Rabatt:{" "}
<strong>{formatChf(selectedPriceCents - discountPreviewCents)}</strong>
</p>
)}
</div> </div>
<Link to="/discovery-set" className="discovery-note-btn"> <Link to="/discovery-set" className="discovery-note-btn">
@ -258,10 +271,28 @@ function ProductDetailContent({ perfumeSlug }) {
</Link> </Link>
</div> </div>
<button className="buy-button" type="button"> <button
className="buy-button"
type="button"
onClick={() =>
addToCart(
selectedProductId,
1,
`${perfume.name} ${selectedProductLabel} added.`
).catch(() => {})
}
>
KAUFEN KAUFEN
</button> </button>
<button
className="restock-button"
type="button"
onClick={() => subscribeToProduct(selectedProductId, "restock").catch(() => {})}
>
RESTOCK UPDATE ABONNIEREN
</button>
<div className="detail-description-section"> <div className="detail-description-section">
<span className="label-title">BESCHREIBUNG</span> <span className="label-title">BESCHREIBUNG</span>
@ -415,8 +446,20 @@ function ProductDetailContent({ perfumeSlug }) {
</p> </p>
<div className="detail-bottom-actions"> <div className="detail-bottom-actions">
<button type="button">SAMPLE BESTELLEN CHF 12</button> <button
<button type="button">DISCOVERY SET CHF 48</button> type="button"
onClick={() =>
addToCart(`${perfume.slug}-sample`, 1, `${perfume.name} Sample added.`).catch(() => {})
}
>
SAMPLE BESTELLEN {perfume.prices.sample}
</button>
<button
type="button"
onClick={() => addToCart("discovery-set", 1, "Discovery Set added.").catch(() => {})}
>
DISCOVERY SET CHF 48
</button>
</div> </div>
</section> </section>
</main> </main>

View File

@ -0,0 +1,33 @@
import { Link } from "react-router";
import { useShop } from "../shop/useShop";
import "../style/navbar.css";
function SharedNavbar({ variant = "light", active = "" }) {
const { cart, openCart, openProfile, user } = useShop();
const cartLabel =
cart.total_quantity > 0 ? `Cart ${cart.total_quantity}` : "Cart";
return (
<nav className={`navbar navbar--${variant}`} aria-label="Hauptnavigation">
<div className="nav-pill">
<Link to="/" className={`nav-link ${active === "atmos" ? "active" : ""}`}>
atmos
</Link>
<Link
to="/discovery-set"
className={`nav-link ${active === "testen" ? "active" : ""}`}
>
Testen
</Link>
<button type="button" className="nav-link nav-button" onClick={openCart}>
{cartLabel}
</button>
<button type="button" className="nav-link nav-button" onClick={openProfile}>
{user ? "Profile" : "Profile"}
</button>
</div>
</nav>
);
}
export default SharedNavbar;

View File

@ -0,0 +1,495 @@
.drawer-backdrop {
position: fixed;
inset: 0;
z-index: 80;
background: rgba(0, 0, 0, 0.58);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease;
}
.drawer-backdrop.open {
opacity: 1;
pointer-events: auto;
}
.shop-drawer {
position: fixed;
top: 0;
right: 0;
z-index: 90;
width: min(560px, 100%);
height: 100vh;
padding: 22px;
overflow-y: auto;
background: #f5f5f5;
border-left: 1px solid #d9d9d9;
color: #1f1f1f;
transform: translateX(100%);
transition: transform 0.24s ease;
}
.shop-drawer.open {
transform: translateX(0);
}
.drawer-top,
.profile-section-header,
.drawer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.drawer-top {
padding-bottom: 18px;
border-bottom: 1px solid #d9d9d9;
margin-bottom: 18px;
font-size: 12px;
letter-spacing: 0.24em;
}
.drawer-top button,
.cart-toast-close {
width: 34px;
height: 34px;
border: 1px solid #d6d6d6;
background: #f1f1f1;
color: #1f1f1f;
cursor: pointer;
}
.drawer-stack,
.auth-panel,
.cart-items,
.order-list,
.requirements,
.sample-credit-list,
.subscription-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.drawer-section {
padding: 18px;
background: #f1f1f1;
border: 1px solid #dddddd;
}
.drawer-section h2,
.drawer-section h3,
.drawer-section p {
margin-top: 0;
}
.drawer-kicker {
display: block;
margin-bottom: 12px;
font-size: 10px;
letter-spacing: 0.22em;
color: #666;
}
.drawer-grid {
display: grid;
gap: 12px;
}
.drawer-grid--two,
.profile-read-grid,
.toggle-grid,
.payment-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.shop-field {
display: flex;
flex-direction: column;
gap: 7px;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #666;
}
.shop-field input {
width: 100%;
min-height: 42px;
border: 1px solid #d6d6d6;
border-radius: 0;
background: #f8f8f8;
padding: 10px 11px;
color: #1f1f1f;
font: inherit;
letter-spacing: 0;
text-transform: none;
}
.drawer-primary,
.drawer-secondary,
.cart-remove,
.pref-toggle,
.payment-card,
.cart-controls button,
.cart-toast button {
border-radius: 0;
cursor: pointer;
font: inherit;
}
.drawer-primary {
width: 100%;
min-height: 46px;
border: 1px solid #1f1f1f;
background: #1f1f1f;
color: #fff;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.drawer-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.drawer-secondary {
min-height: 38px;
border: 1px solid #d6d6d6;
background: #f8f8f8;
color: #1f1f1f;
padding: 0 13px;
}
.drawer-link-primary {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.cart-item,
.order-card,
.read-block,
.status-box,
.requirement-row {
background: #f8f8f8;
border: 1px solid #dddddd;
}
.cart-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 12px;
align-items: center;
padding: 14px;
}
.cart-item h3 {
margin-bottom: 4px;
font-size: 14px;
}
.cart-item p,
.drawer-muted,
.order-card p {
margin: 0;
color: #666;
font-size: 13px;
line-height: 1.45;
}
.cart-controls {
display: grid;
grid-template-columns: 32px 32px 32px;
align-items: center;
border: 1px solid #d6d6d6;
background: #f1f1f1;
}
.cart-controls button {
width: 32px;
height: 32px;
border: 0;
background: transparent;
}
.cart-controls span {
text-align: center;
}
.cart-remove {
min-height: 34px;
border: 1px solid #d6d6d6;
background: #f8f8f8;
padding: 0 10px;
}
.payment-card {
display: flex;
align-items: center;
gap: 10px;
min-height: 60px;
border: 1px solid #d6d6d6;
background: #f8f8f8;
padding: 12px;
}
.payment-card span {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
height: 28px;
background: #1f1f1f;
color: #fff;
font-size: 10px;
letter-spacing: 0.08em;
}
.payment-card.active {
border-color: #ff6a00;
background: rgba(255, 106, 0, 0.12);
}
.totals-box {
display: flex;
flex-direction: column;
gap: 10px;
}
.totals-box > div,
.requirement-row {
display: flex;
justify-content: space-between;
gap: 12px;
}
.totals-box .discount-explainer {
display: block;
padding: 12px;
border: 1px solid #dddddd;
background: #f8f8f8;
}
.discount-explainer span {
display: block;
margin-bottom: 8px;
}
.discount-explainer p {
margin: 0;
color: #1f1f1f;
font-size: 13px;
line-height: 1.45;
}
.discount-explainer p + p {
margin-top: 6px;
}
.total-row {
padding-top: 10px;
border-top: 1px solid #d6d6d6;
font-size: 18px;
}
.drawer-error {
margin: 0;
padding: 12px 14px;
border: 1px solid #dddddd;
background: #f8f8f8;
color: #1f1f1f;
}
.drawer-error {
border-color: #ff6a00;
}
.profile-head {
display: grid;
gap: 8px;
}
.profile-head h2 {
margin-bottom: 0;
}
.profile-read-grid {
margin-top: 14px;
}
.read-block {
padding: 13px;
display: flex;
flex-direction: column;
gap: 7px;
}
.read-block span,
.requirement-row span,
.totals-box span {
color: #666;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.status-box {
padding: 14px;
font-weight: 700;
}
.sample-credit-list {
margin-top: 12px;
}
.sample-credit-list span {
padding: 10px;
background: #f8f8f8;
border: 1px solid #dddddd;
font-size: 13px;
}
.pref-toggle {
min-height: 58px;
padding: 12px;
border: 1px solid #d6d6d6;
background: #f8f8f8;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.pref-toggle.active {
border-color: #ff6a00;
background: rgba(255, 106, 0, 0.12);
}
.subscription-list {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #d6d6d6;
}
.subscription-list-title {
color: #666;
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.subscription-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px;
border: 1px solid #dddddd;
background: #f8f8f8;
}
.subscription-row div {
display: flex;
flex-direction: column;
gap: 4px;
}
.subscription-row span {
color: #666;
font-size: 12px;
}
.subscription-row button {
min-height: 34px;
border: 1px solid #d6d6d6;
border-radius: 0;
background: #f1f1f1;
color: #1f1f1f;
padding: 0 12px;
cursor: pointer;
}
.subscription-row button:hover {
border-color: #ff6a00;
}
.requirement-row {
padding: 12px;
}
.requirement-row strong.met {
color: #ff6a00;
}
.order-card {
padding: 14px;
}
.order-card div {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.cart-toast {
position: fixed;
right: 22px;
bottom: 22px;
z-index: 100;
width: min(360px, calc(100vw - 44px));
padding: 18px;
border: 1px solid #d6d6d6;
background: #f5f5f5;
color: #1f1f1f;
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.22);
}
.cart-toast p {
margin: 8px 38px 14px 0;
color: #666;
}
.cart-toast > button:last-child {
min-height: 40px;
width: 100%;
border: 1px solid #1f1f1f;
background: #1f1f1f;
color: #fff;
}
.cart-toast > button.cart-toast-close:last-child {
width: 34px;
min-height: 34px;
border: 1px solid #d6d6d6;
background: #f1f1f1;
color: #1f1f1f;
}
.cart-toast-close {
position: absolute;
top: 12px;
right: 12px;
}
@media (max-width: 640px) {
.shop-drawer {
padding: 16px;
}
.drawer-grid--two,
.profile-read-grid,
.toggle-grid,
.payment-grid {
grid-template-columns: 1fr;
}
.cart-item {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,476 @@
import { useState } from "react";
import { Link } from "react-router";
import { formatChf } from "../shop/money";
import { useShop } from "../shop/useShop";
import "./ShopDrawer.css";
const paymentMethods = [
{ key: "Bill", badge: "BILL", label: "Bill" },
{ key: "Card", badge: "CARD", label: "Card" },
{ key: "Twint", badge: "TW", label: "Twint" },
{ key: "PayPal", badge: "PP", label: "PayPal" },
];
const notificationLabels = [
["drops_enabled", "New Drops"],
["restocks_enabled", "Restocks"],
["small_batch_enabled", "Small Batch Releases"],
["discovery_enabled", "Discovery Set Updates"],
];
function Field({ label, value, onChange, type = "text", readOnly = false }) {
return (
<label className="shop-field">
<span>{label}</span>
<input
type={type}
value={value}
readOnly={readOnly}
onChange={(event) => onChange?.(event.target.value)}
/>
</label>
);
}
function AuthPanel() {
const { busy, error, login, register } = useShop();
const [mode, setMode] = useState("login");
const [form, setForm] = useState({
first_name: "",
surname: "",
email: "",
password: "",
});
const update = (key, value) => setForm((current) => ({ ...current, [key]: value }));
const submit = (event) => {
event.preventDefault();
if (mode === "login") {
login({ email: form.email, password: form.password }).catch(() => {});
return;
}
register(form).catch(() => {});
};
return (
<form className="drawer-section auth-panel" onSubmit={submit}>
<span className="drawer-kicker">{mode === "login" ? "LOGIN" : "REGISTER"}</span>
<h2>{mode === "login" ? "Welcome back." : "Create your atmos account."}</h2>
{mode === "register" && (
<div className="drawer-grid drawer-grid--two">
<Field label="Name" value={form.first_name} onChange={(value) => update("first_name", value)} />
<Field label="Surname" value={form.surname} onChange={(value) => update("surname", value)} />
</div>
)}
<Field label="Email address" value={form.email} onChange={(value) => update("email", value)} />
<Field
label="Password"
type="password"
value={form.password}
onChange={(value) => update("password", value)}
/>
{error && <p className="drawer-error">{error}</p>}
<button className="drawer-primary" type="submit" disabled={busy}>
{mode === "login" ? "Login" : "Register"}
</button>
<button
className="drawer-secondary"
type="button"
onClick={() => setMode((current) => (current === "login" ? "register" : "login"))}
>
{mode === "login" ? "Create account" : "Use existing account"}
</button>
</form>
);
}
function CartPanel() {
const {
cart,
checkout,
removeCartItem,
updateCartQuantity,
busy,
error,
user,
} = useShop();
const [address, setAddress] = useState(() => ({
street_name: user?.street_name || "",
house_number: user?.house_number || "",
zip_code: user?.zip_code || "",
city: user?.city || "",
}));
const [paymentMethod, setPaymentMethod] = useState("Bill");
const updateAddress = (key, value) =>
setAddress((current) => ({ ...current, [key]: value }));
const submit = (event) => {
event.preventDefault();
checkout({ ...address, payment_method: paymentMethod }).catch(() => {});
};
return (
<form className="drawer-stack" onSubmit={submit}>
<section className="drawer-section">
<span className="drawer-kicker">CART</span>
{cart.items.length === 0 ? (
<p className="drawer-muted">Your cart is empty.</p>
) : (
<div className="cart-items">
{cart.items.map((item) => (
<article className="cart-item" key={item.product_id}>
<div>
<h3>{item.product.name}</h3>
<p>
{item.product.size_label} · {formatChf(item.product.price_cents)}
</p>
</div>
<div className="cart-controls">
<button
type="button"
onClick={() => updateCartQuantity(item.product_id, item.quantity - 1)}
>
-
</button>
<span>{item.quantity}</span>
<button
type="button"
onClick={() => updateCartQuantity(item.product_id, item.quantity + 1)}
>
+
</button>
</div>
<button
className="cart-remove"
type="button"
onClick={() => removeCartItem(item.product_id)}
>
Remove
</button>
</article>
))}
</div>
)}
</section>
<section className="drawer-section">
<span className="drawer-kicker">SHIPPING</span>
<div className="drawer-grid drawer-grid--two">
<Field label="Street Name" value={address.street_name} onChange={(value) => updateAddress("street_name", value)} />
<Field label="House Number" value={address.house_number} onChange={(value) => updateAddress("house_number", value)} />
<Field label="ZIP Code" value={address.zip_code} onChange={(value) => updateAddress("zip_code", value)} />
<Field label="City" value={address.city} onChange={(value) => updateAddress("city", value)} />
</div>
</section>
<section className="drawer-section">
<span className="drawer-kicker">PAYMENT</span>
<div className="payment-grid">
{paymentMethods.map((method) => (
<button
type="button"
className={`payment-card ${paymentMethod === method.key ? "active" : ""}`}
key={method.key}
onClick={() => setPaymentMethod(method.key)}
>
<span>{method.badge}</span>
<strong>{method.label}</strong>
</button>
))}
</div>
</section>
<section className="drawer-section totals-box">
<div>
<span>Subtotal</span>
<strong>{formatChf(cart.subtotal_cents)}</strong>
</div>
<div>
<span>Rabatte</span>
<strong>-{formatChf(cart.discount_cents)}</strong>
</div>
{cart.discounts?.length > 0 && (
<div className="discount-explainer">
<span>Applied automatically</span>
{cart.discounts.map((discount) => (
<p key={`${discount.type}-${discount.creditId}`}>
<strong>{formatChf(discount.amount_cents)}</strong>
{" - "}
{discount.type === "discovery"
? "Discovery Set credit for a full-size bottle"
: `Sample credit for ${discount.slug}`}
</p>
))}
</div>
)}
<div className="total-row">
<span>Total</span>
<strong>{formatChf(cart.total_cents)}</strong>
</div>
</section>
{error && <p className="drawer-error">{error}</p>}
<button className="drawer-primary" type="submit" disabled={busy || cart.items.length === 0}>
Checkout
</button>
</form>
);
}
function RequirementRow({ label, met, children }) {
return (
<div className="requirement-row">
<span>{label}</span>
<strong className={met ? "met" : ""}>{children || (met ? "met" : "open")}</strong>
</div>
);
}
function ProfilePanel() {
const {
busy,
error,
logout,
orders,
removeProductSubscription,
updateNotifications,
updateProfile,
user,
closePanel,
} = useShop();
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(user || {});
const update = (key, value) => setForm((current) => ({ ...current, [key]: value }));
const notifications = user?.notifications || {};
const restockSubscriptions = (user?.productSubscriptions || []).filter(
(subscription) => subscription.type === "restock"
);
const loyalty = user?.loyaltyStatus || {
hasDiscoverySet: false,
hasFullSize: false,
purchases: 0,
spent_cents: 0,
unlocked: false,
};
const save = () => {
updateProfile({
first_name: form.first_name,
surname: form.surname,
street_name: form.street_name,
house_number: form.house_number,
zip_code: form.zip_code,
city: form.city,
birthdate: form.birthdate,
})
.then(() => setEditing(false))
.catch(() => {});
};
const togglePreference = (key) => {
updateNotifications({ ...notifications, [key]: !notifications[key] }).catch(() => {});
};
return (
<div className="drawer-stack">
<section className="drawer-section profile-head">
<span className="drawer-kicker">PROFILE</span>
<h2>Hi, {user.first_name}</h2>
<button className="drawer-secondary" type="button" onClick={logout}>
Logout
</button>
</section>
<section className="drawer-section">
<div className="profile-section-header">
<span className="drawer-kicker">PROFILE INFORMATION</span>
{!editing && (
<button className="drawer-secondary" type="button" onClick={() => setEditing(true)}>
Profil bearbeiten
</button>
)}
</div>
{editing ? (
<>
<div className="drawer-grid drawer-grid--two">
<Field label="Name" value={form.first_name || ""} onChange={(value) => update("first_name", value)} />
<Field label="Surname" value={form.surname || ""} onChange={(value) => update("surname", value)} />
<Field label="Street Name" value={form.street_name || ""} onChange={(value) => update("street_name", value)} />
<Field label="House Number" value={form.house_number || ""} onChange={(value) => update("house_number", value)} />
<Field label="ZIP Code" value={form.zip_code || ""} onChange={(value) => update("zip_code", value)} />
<Field label="City" value={form.city || ""} onChange={(value) => update("city", value)} />
<Field label="Birthdate" type="date" value={form.birthdate || ""} onChange={(value) => update("birthdate", value)} />
</div>
<div className="drawer-actions">
<button className="drawer-primary" type="button" disabled={busy} onClick={save}>
Save profile
</button>
<button
className="drawer-secondary"
type="button"
onClick={() => {
setForm(user);
setEditing(false);
}}
>
Cancel
</button>
</div>
</>
) : (
<div className="profile-read-grid">
<ReadBlock label="Name" value={user.first_name} />
<ReadBlock label="Surname" value={user.surname} />
<ReadBlock label="Street Name" value={user.street_name || "-"} />
<ReadBlock label="House Number" value={user.house_number || "-"} />
<ReadBlock label="ZIP Code" value={user.zip_code || "-"} />
<ReadBlock label="City" value={user.city || "-"} />
<ReadBlock label="Birthdate" value={user.birthdate || "-"} />
</div>
)}
</section>
<section className="drawer-section">
<span className="drawer-kicker">DISCOUNT STATUS</span>
<div className="status-box">{user.discoveryStatus}</div>
{user.sampleCredits?.length > 0 && (
<div className="sample-credit-list">
{user.sampleCredits.map((credit) => (
<span key={`${credit.slug}-${credit.created_at}`}>
{credit.slug}: {credit.status}
</span>
))}
</div>
)}
</section>
<section className="drawer-section">
<span className="drawer-kicker">DROP / RESTOCK PREFERENCES</span>
<div className="toggle-grid">
{notificationLabels.map(([key, label]) => (
<button
key={key}
type="button"
className={`pref-toggle ${notifications[key] ? "active" : ""}`}
onClick={() => togglePreference(key)}
>
<span>{label}</span>
<strong>{notifications[key] ? "Active" : "Inactive"}</strong>
</button>
))}
</div>
<div className="subscription-list">
<span className="subscription-list-title">Subscribed Restocks</span>
{restockSubscriptions.length === 0 ? (
<p className="drawer-muted">No product-specific restock updates yet.</p>
) : (
restockSubscriptions.map((subscription) => (
<article className="subscription-row" key={subscription.id}>
<div>
<strong>{subscription.name}</strong>
<span>{subscription.size_label}</span>
</div>
<button
type="button"
onClick={() => removeProductSubscription(subscription.id).catch(() => {})}
>
Delete
</button>
</article>
))
)}
</div>
</section>
<section className="drawer-section">
<span className="drawer-kicker">SMALL BATCH ACCESS</span>
<div className="status-box">{loyalty.unlocked ? "Unlocked" : "Locked"}</div>
<div className="requirements">
<RequirementRow label="Discovery Set" met={loyalty.hasDiscoverySet} />
<RequirementRow label="Full Size" met={loyalty.hasFullSize} />
<RequirementRow label="Purchases" met={loyalty.purchases >= 3}>
{loyalty.purchases}/3 Purchases
</RequirementRow>
<RequirementRow label="Spend" met={loyalty.spent_cents > 50000}>
{formatChf(loyalty.spent_cents)} / CHF 500+
</RequirementRow>
</div>
<Link
className="drawer-primary drawer-link-primary"
to="/small-batch"
onClick={closePanel}
>
Small Batch ansehen
</Link>
</section>
<section className="drawer-section">
<span className="drawer-kicker">PURCHASES</span>
{orders.length === 0 ? (
<p className="drawer-muted">No orders yet.</p>
) : (
<div className="order-list">
{orders.map((order) => (
<article className="order-card" key={order.id}>
<div>
<strong>Order #{order.id}</strong>
<span>{new Date(order.created_at).toLocaleDateString("de-CH")}</span>
</div>
<p>{order.items.map((item) => `${item.quantity} x ${item.product.name}`).join(", ")}</p>
<strong>{formatChf(order.total_cents)}</strong>
</article>
))}
</div>
)}
</section>
{error && <p className="drawer-error">{error}</p>}
</div>
);
}
function ReadBlock({ label, value }) {
return (
<div className="read-block">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function ShopDrawer() {
const { closePanel, panelOpen, panelType, user } = useShop();
return (
<>
<div
className={`drawer-backdrop ${panelOpen ? "open" : ""}`}
onClick={closePanel}
/>
<aside className={`shop-drawer ${panelOpen ? "open" : ""}`} aria-hidden={!panelOpen}>
<div className="drawer-top">
<span>{!user ? "ACCOUNT" : panelType === "cart" ? "CART" : "PROFILE"}</span>
<button type="button" onClick={closePanel} aria-label="Close panel">
x
</button>
</div>
{!user ? <AuthPanel /> : panelType === "cart" ? <CartPanel /> : <ProfilePanel />}
</aside>
</>
);
}
export default ShopDrawer;

View File

@ -1,5 +1,6 @@
import { Link } from "react-router"; import { Link } from "react-router";
import IntroOverlay from "./IntroOverlay"; import IntroOverlay from "./IntroOverlay";
import SharedNavbar from "../SharedNavbar";
function HeroSection({ function HeroSection({
heroImageWrapRef, heroImageWrapRef,
@ -32,22 +33,7 @@ function HeroSection({
/> />
</Link> </Link>
<nav className="navbar navbar--hero" aria-label="Hauptnavigation"> <SharedNavbar variant="hero" active="atmos" />
<div className="nav-pill">
<a href="#home" className="nav-link active">
atmos
</a>
<a href="#dufte" className="nav-link">
{"D\u00FCfte"}
</a>
<Link to="/discovery-set" className="nav-link">
Testen
</Link>
<a href="#cart" className="nav-link">
Cart
</a>
</div>
</nav>
<div className="hero-content"> <div className="hero-content">
<h1 className="hero-title"> <h1 className="hero-title">

View File

@ -2,12 +2,15 @@ import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router"; import { BrowserRouter } from "react-router";
import App from "./App"; import App from "./App";
import { ShopProvider } from "./shop/ShopContext";
import "./App.css"; import "./App.css";
ReactDOM.createRoot(document.getElementById("root")).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<ShopProvider>
<App /> <App />
</ShopProvider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode> </React.StrictMode>
); );

View File

@ -1,26 +1,10 @@
import { Link } from "react-router"; import SharedNavbar from "../components/SharedNavbar";
import "../style/navbar.css";
import "./AboutPage.css"; import "./AboutPage.css";
function AboutPage() { function AboutPage() {
return ( return (
<div className="about-page"> <div className="about-page">
<nav className="navbar navbar--light"> <SharedNavbar variant="light" />
<div className="nav-pill">
<Link to="/" className="nav-link">
atmos
</Link>
<Link to="/#dufte" className="nav-link">
Düfte
</Link>
<Link to="/discovery-set" className="nav-link">
Testen
</Link>
<a href="#cart" className="nav-link">
Cart
</a>
</div>
</nav>
<main className="about-shell"> <main className="about-shell">
<section className="about-hero"> <section className="about-hero">

View File

@ -1,26 +1,10 @@
import { Link } from "react-router"; import SharedNavbar from "../components/SharedNavbar";
import "../style/navbar.css";
import "./DatenschutzPage.css"; import "./DatenschutzPage.css";
function DatenschutzPage() { function DatenschutzPage() {
return ( return (
<div className="datenschutz-page"> <div className="datenschutz-page">
<nav className="navbar navbar--light"> <SharedNavbar variant="light" />
<div className="nav-pill">
<Link to="/" className="nav-link">
atmos
</Link>
<Link to="/#dufte" className="nav-link">
Düfte
</Link>
<Link to="/discovery-set" className="nav-link">
Testen
</Link>
<a href="#cart" className="nav-link">
Cart
</a>
</div>
</nav>
<main className="datenschutz-shell"> <main className="datenschutz-shell">
<section className="datenschutz-hero"> <section className="datenschutz-hero">

View File

@ -1,6 +1,7 @@
import { Link, useNavigate } from "react-router"; import { useNavigate } from "react-router";
import perfumes from "../data/perfumes"; import perfumes from "../data/perfumes";
import "../style/navbar.css"; import SharedNavbar from "../components/SharedNavbar";
import { useShop } from "../shop/useShop";
import "./DiscoverySetPage.css"; import "./DiscoverySetPage.css";
const moodImages = [ const moodImages = [
@ -14,25 +15,13 @@ const moodImages = [
function DiscoverySetPage() { function DiscoverySetPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { addToCart } = useShop();
const buyDiscoverySet = () =>
addToCart("discovery-set", 1, "Discovery Set added.").catch(() => {});
return ( return (
<div className="discovery-page"> <div className="discovery-page">
<nav className="navbar navbar--light"> <SharedNavbar variant="light" active="testen" />
<div className="nav-pill">
<Link to="/" className="nav-link">
atmos
</Link>
<Link to="/#dufte" className="nav-link">
Düfte
</Link>
<Link to="/discovery-set" className="nav-link active">
Testen
</Link>
<a href="#cart" className="nav-link">
Cart
</a>
</div>
</nav>
<main className="discovery-shell"> <main className="discovery-shell">
<div className="discovery-topbar"> <div className="discovery-topbar">
@ -75,8 +64,8 @@ function DiscoverySetPage() {
<div> <div>
<strong>CHF 48 Gutschein automatisch im Set</strong> <strong>CHF 48 Gutschein automatisch im Set</strong>
<p> <p>
Wird beim späteren Full-Size-Kauf angerechnet kein manuelles Nur das erste Discovery Set erstellt den einmaligen Rabatt.
Einlösen nötig. Er wird bei einem späteren Full-Size-Kauf automatisch angerechnet.
</p> </p>
</div> </div>
</div> </div>
@ -105,10 +94,10 @@ function DiscoverySetPage() {
</div> </div>
<div className="discovery-hero-actions"> <div className="discovery-hero-actions">
<button type="button" className="discovery-primary-btn"> <button type="button" className="discovery-primary-btn" onClick={buyDiscoverySet}>
DISCOVERY SET BESTELLEN CHF 48. DISCOVERY SET BESTELLEN CHF 48.
</button> </button>
<p>Wird bei jedem Full-Size-Kauf angerechnet</p> <p>Nur das erste Set erstellt einen einmaligen CHF 48 Full-Size-Rabatt</p>
</div> </div>
</div> </div>
@ -158,8 +147,8 @@ function DiscoverySetPage() {
<div className="discovery-step-number">1</div> <div className="discovery-step-number">1</div>
<h3>Bestellen</h3> <h3>Bestellen</h3>
<p> <p>
Discovery Set für CHF 48 bestellen. Der Gutschein-Code ist Discovery Set für CHF 48 bestellen. Nur dein erstes Set erzeugt
automatisch im Set enthalten. automatisch einen einmaligen Rabatt.
</p> </p>
</article> </article>
@ -176,8 +165,8 @@ function DiscoverySetPage() {
<div className="discovery-step-number">3</div> <div className="discovery-step-number">3</div>
<h3>Entscheiden</h3> <h3>Entscheiden</h3>
<p> <p>
Full-Size bestellen. CHF 48 werden automatisch angerechnet. Full-Size bestellen. CHF 48 werden automatisch angerechnet,
Kein Risiko, kein Verlust. sofern der Rabatt noch nicht genutzt wurde.
</p> </p>
</article> </article>
</div> </div>
@ -215,17 +204,17 @@ function DiscoverySetPage() {
</div> </div>
<p> <p>
CHF 48 investieren, alle Düfte testen, bewusst entscheiden. Die CHF 48 investieren, alle Düfte testen, bewusst entscheiden. Die
Investition wird vollständig angerechnet der Einstieg bleibt erste Investition wird einmalig angerechnet der Einstieg bleibt
kontrolliert und nachvollziehbar. kontrolliert, nachvollziehbar und fair.
</p> </p>
</div> </div>
</div> </div>
<div className="discovery-bottom-cta"> <div className="discovery-bottom-cta">
<button type="button" className="discovery-primary-btn"> <button type="button" className="discovery-primary-btn" onClick={buyDiscoverySet}>
DISCOVERY SET BESTELLEN CHF 48. DISCOVERY SET BESTELLEN CHF 48.
</button> </button>
<p>Kostenloser Versand · 23 Werktage · Volle Anrechnung bei Full-Size</p> <p>Kostenloser Versand · 23 Werktage · Einmalige Anrechnung bei Full-Size</p>
</div> </div>
</section> </section>
</main> </main>

View File

@ -1,26 +1,10 @@
import { Link } from "react-router"; import SharedNavbar from "../components/SharedNavbar";
import "../style/navbar.css";
import "./ImpressumPage.css"; import "./ImpressumPage.css";
function ImpressumPage() { function ImpressumPage() {
return ( return (
<div className="impressum-page"> <div className="impressum-page">
<nav className="navbar navbar--light"> <SharedNavbar variant="light" />
<div className="nav-pill">
<Link to="/" className="nav-link">
atmos
</Link>
<Link to="/#dufte" className="nav-link">
Düfte
</Link>
<Link to="/discovery-set" className="nav-link">
Testen
</Link>
<a href="#cart" className="nav-link">
Cart
</a>
</div>
</nav>
<main className="impressum-shell"> <main className="impressum-shell">
<section className="impressum-hero"> <section className="impressum-hero">

View File

@ -0,0 +1,125 @@
.small-page {
min-height: 100vh;
padding: 26px 38px 38px;
background: #efefef;
color: #1f1f1f;
}
.small-shell {
background: #f5f5f5;
border: 1px solid #d9d9d9;
padding: 38px;
}
.small-hero {
max-width: 780px;
padding-bottom: 34px;
border-bottom: 1px solid #dddddd;
margin-bottom: 28px;
}
.small-kicker {
display: block;
margin-bottom: 12px;
color: #666;
font-size: 10px;
letter-spacing: 0.22em;
}
.small-hero h1,
.small-panel h2 {
margin: 0 0 14px;
letter-spacing: 0;
}
.small-hero h1 {
font-size: clamp(42px, 8vw, 92px);
line-height: 0.92;
}
.small-hero p,
.small-panel p,
.release-card p {
margin: 0;
color: #666;
line-height: 1.55;
}
.small-panel,
.release-card,
.small-error {
background: #f1f1f1;
border: 1px solid #dddddd;
padding: 22px;
}
.small-panel button,
.release-card button {
min-height: 44px;
margin-top: 18px;
border: 1px solid #1f1f1f;
border-radius: 0;
background: #1f1f1f;
color: #fff;
padding: 0 18px;
cursor: pointer;
}
.small-requirements {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.small-requirement {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 14px;
background: #f8f8f8;
border: 1px solid #dddddd;
}
.small-requirement span,
.release-card span {
color: #666;
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.small-requirement strong.met {
color: #ff6a00;
}
.release-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 22px;
}
.release-card h3 {
margin: 10px 0 12px;
}
.small-error {
margin: 16px 0 0;
border-color: #ff6a00;
}
@media (max-width: 760px) {
.small-page {
padding: 18px;
}
.small-shell {
padding: 24px 18px;
}
.small-requirements,
.release-grid {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,123 @@
import { useEffect, useState } from "react";
import SharedNavbar from "../components/SharedNavbar";
import { formatChf } from "../shop/money";
import { useShop } from "../shop/useShop";
import "./SmallBatchPage.css";
function Requirement({ label, met, children }) {
return (
<div className="small-requirement">
<span>{label}</span>
<strong className={met ? "met" : ""}>{children || (met ? "met" : "open")}</strong>
</div>
);
}
function SmallBatchPage() {
const { openProfile, token, user } = useShop();
const [state, setState] = useState({
loading: false,
error: "",
loyaltyStatus: user?.loyaltyStatus || null,
releases: [],
});
useEffect(() => {
if (!token) return;
fetch("/api/small-batch", {
headers: { Authorization: `Bearer ${token}` },
})
.then(async (response) => {
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "Small Batch request failed.");
setState({
loading: false,
error: "",
loyaltyStatus: data.loyaltyStatus,
releases: data.releases || [],
});
})
.catch((error) => {
setState((current) => ({
...current,
loading: false,
error:
error instanceof Error
? error.message
: "Shop API unreachable. Start it with npm run dev and try again.",
}));
});
}, [token, user?.loyaltyStatus]);
const loyalty = state.loyaltyStatus || {
hasDiscoverySet: false,
hasFullSize: false,
purchases: 0,
spent_cents: 0,
unlocked: false,
};
return (
<div className="small-page">
<SharedNavbar variant="light" />
<main className="small-shell">
<section className="small-hero">
<span className="small-kicker">SMALL BATCH / ARCHIVE / PROTOTYPE</span>
<h1>EARLY ACCESS</h1>
<p>
Limited releases are reserved for customers with enough purchase
history to understand the atmos material language.
</p>
</section>
{!user ? (
<section className="small-panel">
<span className="small-kicker">LOGIN REQUIRED</span>
<h2>Sign in to check access.</h2>
<p>Small Batch access is calculated from your completed orders.</p>
<button type="button" onClick={openProfile}>
Login / Register
</button>
</section>
) : (
<>
<section className="small-panel">
<span className="small-kicker">ACCESS STATUS</span>
<h2>{loyalty.unlocked ? "Unlocked" : "Locked"}</h2>
<div className="small-requirements">
<Requirement label="Discovery Set" met={loyalty.hasDiscoverySet} />
<Requirement label="Full Size" met={loyalty.hasFullSize} />
<Requirement label="Purchases" met={loyalty.purchases >= 3}>
{loyalty.purchases}/3 Purchases
</Requirement>
<Requirement label="Spend" met={loyalty.spent_cents > 50000}>
{formatChf(loyalty.spent_cents)} / CHF 500+
</Requirement>
</div>
</section>
{state.error && <p className="small-error">{state.error}</p>}
{state.loading && <p className="small-error">Loading access...</p>}
{loyalty.unlocked && (
<section className="release-grid">
{state.releases.map((release) => (
<article className="release-card" key={release.name}>
<span>{release.type}</span>
<h3>{release.name}</h3>
<p>{release.note}</p>
<button type="button">Request Allocation</button>
</article>
))}
</section>
)}
</>
)}
</main>
</div>
);
}
export default SmallBatchPage;

View File

@ -1,26 +1,10 @@
import { Link } from "react-router"; import SharedNavbar from "../components/SharedNavbar";
import "../style/navbar.css";
import "./SupportPage.css"; import "./SupportPage.css";
function SupportPage() { function SupportPage() {
return ( return (
<div className="support-page"> <div className="support-page">
<nav className="navbar navbar--light"> <SharedNavbar variant="light" />
<div className="nav-pill">
<Link to="/" className="nav-link">
atmos
</Link>
<Link to="/#dufte" className="nav-link">
Düfte
</Link>
<Link to="/discovery-set" className="nav-link">
Testen
</Link>
<a href="#cart" className="nav-link">
Cart
</a>
</div>
</nav>
<main className="support-shell"> <main className="support-shell">
<section className="support-hero"> <section className="support-hero">

View File

@ -0,0 +1,408 @@
import {
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { ShopContext } from "./ShopContextBase";
const TOKEN_KEY = "atmos-shop-token";
const request = async (path, options = {}, token) => {
const headers = {
"Content-Type": "application/json",
...(options.headers || {}),
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
let response;
try {
response = await fetch(path, { ...options, headers });
} catch {
throw new Error("Shop API unreachable. Start it with npm run dev and try again.");
}
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || "Shop request failed.");
}
return data;
};
export function ShopProvider({ children }) {
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY));
const [user, setUser] = useState(null);
const [cart, setCart] = useState({
items: [],
subtotal_cents: 0,
discount_cents: 0,
total_cents: 0,
total_quantity: 0,
discounts: [],
});
const [orders, setOrders] = useState([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [panelOpen, setPanelOpen] = useState(false);
const [panelType, setPanelType] = useState("profile");
const [cartToast, setCartToast] = useState(null);
const applyState = useCallback((payload) => {
if (payload.user) setUser(payload.user);
if (payload.cart) setCart(payload.cart);
if (payload.orders) setOrders(payload.orders);
if (payload.token) {
setToken(payload.token);
localStorage.setItem(TOKEN_KEY, payload.token);
}
}, []);
const run = useCallback(
async (task) => {
setBusy(true);
setError("");
try {
return await task();
} catch (err) {
const message = err instanceof Error ? err.message : "Something went wrong.";
setError(message);
throw err;
} finally {
setBusy(false);
}
},
[]
);
const showToast = useCallback((toast) => {
setCartToast(toast);
}, []);
useEffect(() => {
if (!token) return;
request("/api/auth/session", {}, token)
.then(applyState)
.catch(() => {
localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);
});
}, [applyState, token]);
const register = useCallback(
(payload) =>
run(async () => {
const data = await request(
"/api/auth/register",
{ method: "POST", body: JSON.stringify(payload) },
null
);
applyState(data);
setPanelType("profile");
showToast({
title: "Account created",
message: "Your profile is ready.",
actionLabel: "Profile",
actionPanel: "profile",
});
return data;
}),
[applyState, run, showToast]
);
const login = useCallback(
(payload) =>
run(async () => {
const data = await request(
"/api/auth/login",
{ method: "POST", body: JSON.stringify(payload) },
null
);
applyState(data);
setPanelType("profile");
showToast({
title: "Logged in",
message: "Welcome back to atmos.",
actionLabel: "Profile",
actionPanel: "profile",
});
return data;
}),
[applyState, run, showToast]
);
const logout = useCallback(
() =>
run(async () => {
if (token) {
await request("/api/auth/logout", { method: "POST" }, token);
}
localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);
setOrders([]);
setCart({
items: [],
subtotal_cents: 0,
discount_cents: 0,
total_cents: 0,
total_quantity: 0,
discounts: [],
});
showToast({
title: "Logged out",
message: "Your session has ended.",
});
}),
[run, showToast, token]
);
const openCart = useCallback(() => {
setPanelType("cart");
setPanelOpen(true);
}, []);
const openProfile = useCallback(() => {
setPanelType("profile");
setPanelOpen(true);
}, []);
const closePanel = useCallback(() => setPanelOpen(false), []);
const dismissToast = useCallback(() => setCartToast(null), []);
const addToCart = useCallback(
(productId, quantity = 1, itemMessage) =>
run(async () => {
if (!token || !user) {
setPanelType("profile");
setPanelOpen(true);
throw new Error("Please log in before adding products to the cart.");
}
const data = await request(
"/api/cart/items",
{ method: "POST", body: JSON.stringify({ productId, quantity }) },
token
);
setCart(data.cart);
showToast({
title: "Added to cart",
message: itemMessage || data.message,
actionLabel: "To the cart",
actionPanel: "cart",
});
return data;
}),
[run, showToast, token, user]
);
const updateCartQuantity = useCallback(
(productId, quantity) =>
run(async () => {
const data = await request(
`/api/cart/items/${encodeURIComponent(productId)}`,
{ method: "PATCH", body: JSON.stringify({ quantity }) },
token
);
setCart(data.cart);
return data;
}),
[run, token]
);
const removeCartItem = useCallback(
(productId) =>
run(async () => {
const data = await request(
`/api/cart/items/${encodeURIComponent(productId)}`,
{ method: "DELETE" },
token
);
setCart(data.cart);
return data;
}),
[run, token]
);
const checkout = useCallback(
(payload) =>
run(async () => {
const data = await request(
"/api/cart/checkout",
{ method: "POST", body: JSON.stringify(payload) },
token
);
applyState(data);
showToast({
title: "Checkout complete",
message: "Your order was placed and your purchase history was updated.",
actionLabel: "Profile",
actionPanel: "profile",
});
return data;
}),
[applyState, run, showToast, token]
);
const updateProfile = useCallback(
(payload) =>
run(async () => {
const data = await request(
"/api/profile",
{ method: "PATCH", body: JSON.stringify(payload) },
token
);
setUser(data.user);
showToast({
title: "Profile saved",
message: "Your profile details were updated.",
});
return data;
}),
[run, showToast, token]
);
const updateNotifications = useCallback(
(payload) =>
run(async () => {
const data = await request(
"/api/notifications",
{ method: "PATCH", body: JSON.stringify(payload) },
token
);
setUser((current) =>
current ? { ...current, notifications: data.notifications } : current
);
showToast({
title: "Preferences saved",
message: "Your drop and restock settings were updated.",
});
return data;
}),
[run, showToast, token]
);
const subscribeToProduct = useCallback(
(productId, type = "restock") =>
run(async () => {
if (!token || !user) {
setPanelType("profile");
setPanelOpen(true);
throw new Error("Please log in to subscribe to restock updates.");
}
const data = await request(
"/api/product-subscriptions",
{ method: "POST", body: JSON.stringify({ product_id: productId, type }) },
token
);
setUser((current) =>
current ? { ...current, productSubscriptions: data.subscriptions } : current
);
showToast({
title: "Subscription saved",
message: "You will receive updates for this product.",
actionLabel: "Profile",
actionPanel: "profile",
});
return data;
}),
[run, showToast, token, user]
);
const removeProductSubscription = useCallback(
(subscriptionId) =>
run(async () => {
const data = await request(
`/api/product-subscriptions/${encodeURIComponent(subscriptionId)}`,
{ method: "DELETE" },
token
);
setUser((current) =>
current ? { ...current, productSubscriptions: data.subscriptions } : current
);
showToast({
title: "Subscription removed",
message: "That restock update was deleted.",
});
return data;
}),
[run, showToast, token]
);
const refreshSession = useCallback(
() =>
token
? request("/api/auth/session", {}, token).then((data) => {
applyState(data);
return data;
})
: Promise.resolve(null),
[applyState, token]
);
const value = useMemo(
() => ({
token,
user,
cart,
orders,
busy,
error,
panelOpen,
panelType,
cartToast,
register,
login,
logout,
updateProfile,
updateNotifications,
addToCart,
updateCartQuantity,
removeCartItem,
checkout,
subscribeToProduct,
removeProductSubscription,
refreshSession,
openCart,
openProfile,
closePanel,
dismissToast,
showToast,
setPanelType,
setPanelOpen,
setError,
}),
[
token,
user,
cart,
orders,
busy,
error,
panelOpen,
panelType,
cartToast,
register,
login,
logout,
updateProfile,
updateNotifications,
addToCart,
updateCartQuantity,
removeCartItem,
checkout,
subscribeToProduct,
removeProductSubscription,
refreshSession,
openCart,
openProfile,
closePanel,
dismissToast,
showToast,
]
);
return <ShopContext.Provider value={value}>{children}</ShopContext.Provider>;
}

View File

@ -0,0 +1,3 @@
import { createContext } from "react";
export const ShopContext = createContext(null);

View File

@ -0,0 +1,5 @@
export const formatChf = (cents = 0) =>
new Intl.NumberFormat("de-CH", {
style: "currency",
currency: "CHF",
}).format(cents / 100);

View File

@ -0,0 +1,10 @@
import { useContext } from "react";
import { ShopContext } from "./ShopContextBase";
export const useShop = () => {
const value = useContext(ShopContext);
if (!value) {
throw new Error("useShop must be used inside ShopProvider.");
}
return value;
};

View File

@ -17,6 +17,9 @@
} }
.nav-link { .nav-link {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none; text-decoration: none;
font-size: 13px; font-size: 13px;
padding: 8px 14px; padding: 8px 14px;
@ -24,6 +27,12 @@
transition: 0.2s ease; transition: 0.2s ease;
} }
.nav-button {
border: none;
font-family: inherit;
cursor: pointer;
}
/* Hero variant */ /* Hero variant */
.navbar--hero { .navbar--hero {
padding-top: 22px; padding-top: 22px;
@ -37,6 +46,10 @@
color: rgba(255, 255, 255, 0.88); color: rgba(255, 255, 255, 0.88);
} }
.navbar--hero .nav-button {
background: transparent;
}
.navbar--hero .nav-link:hover, .navbar--hero .nav-link:hover,
.navbar--hero .nav-link.active { .navbar--hero .nav-link.active {
background: rgba(255, 255, 255, 0.22); background: rgba(255, 255, 255, 0.22);
@ -56,6 +69,10 @@
color: #1d1d1d; color: #1d1d1d;
} }
.navbar--light .nav-button {
background: transparent;
}
.navbar--light .nav-link:hover, .navbar--light .nav-link:hover,
.navbar--light .nav-link.active { .navbar--light .nav-link.active {
background: #ebebeb; background: #ebebeb;

View File

@ -8,4 +8,9 @@ export default defineConfig({
react(), react(),
babel({ presets: [reactCompilerPreset()] }) babel({ presets: [reactCompilerPreset()] })
], ],
server: {
proxy: {
"/api": "http://localhost:4174",
},
},
}) })