Add login, reg, cart, newsletter und access to small batch releases
This commit is contained in:
parent
414967441a
commit
fb801e3752
2
parfum-shop/.gitignore
vendored
2
parfum-shop/.gitignore
vendored
@ -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/*
|
||||||
|
|||||||
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
208
parfum-shop/package-lock.json
generated
208
parfum-shop/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
38
parfum-shop/server/catalog.js
Normal file
38
parfum-shop/server/catalog.js
Normal 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
169
parfum-shop/server/db.js
Normal 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
33
parfum-shop/server/dev.js
Normal 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
732
parfum-shop/server/index.js
Normal 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}`);
|
||||||
|
});
|
||||||
@ -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 />
|
||||||
</>
|
</>
|
||||||
|
|||||||
40
parfum-shop/src/components/CartToast.jsx
Normal file
40
parfum-shop/src/components/CartToast.jsx
Normal 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;
|
||||||
@ -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 --- */
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
33
parfum-shop/src/components/SharedNavbar.jsx
Normal file
33
parfum-shop/src/components/SharedNavbar.jsx
Normal 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;
|
||||||
495
parfum-shop/src/components/ShopDrawer.css
Normal file
495
parfum-shop/src/components/ShopDrawer.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
476
parfum-shop/src/components/ShopDrawer.jsx
Normal file
476
parfum-shop/src/components/ShopDrawer.jsx
Normal 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;
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 · 2–3 Werktage · Volle Anrechnung bei Full-Size</p>
|
<p>Kostenloser Versand · 2–3 Werktage · Einmalige Anrechnung bei Full-Size</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
125
parfum-shop/src/pages/SmallBatchPage.css
Normal file
125
parfum-shop/src/pages/SmallBatchPage.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
parfum-shop/src/pages/SmallBatchPage.jsx
Normal file
123
parfum-shop/src/pages/SmallBatchPage.jsx
Normal 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;
|
||||||
@ -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">
|
||||||
|
|||||||
408
parfum-shop/src/shop/ShopContext.jsx
Normal file
408
parfum-shop/src/shop/ShopContext.jsx
Normal 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>;
|
||||||
|
}
|
||||||
3
parfum-shop/src/shop/ShopContextBase.js
Normal file
3
parfum-shop/src/shop/ShopContextBase.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
export const ShopContext = createContext(null);
|
||||||
5
parfum-shop/src/shop/money.js
Normal file
5
parfum-shop/src/shop/money.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export const formatChf = (cents = 0) =>
|
||||||
|
new Intl.NumberFormat("de-CH", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CHF",
|
||||||
|
}).format(cents / 100);
|
||||||
10
parfum-shop/src/shop/useShop.js
Normal file
10
parfum-shop/src/shop/useShop.js
Normal 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;
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
|||||||
@ -8,4 +8,9 @@ export default defineConfig({
|
|||||||
react(),
|
react(),
|
||||||
babel({ presets: [reactCompilerPreset()] })
|
babel({ presets: [reactCompilerPreset()] })
|
||||||
],
|
],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:4174",
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user