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