Compare commits
No commits in common. "main" and "discoveryset" have entirely different histories.
main
...
discoverys
2
parfum-shop/.gitignore
vendored
@ -11,8 +11,6 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
data/*.sqlite
|
||||
data/*.sqlite-*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@ -26,14 +26,4 @@ export default defineConfig([
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['server/**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
globals: globals.node,
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@ -1,61 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<title>atmos · Konzeptionelle Düfte aus der Schweiz</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="atmos — konzeptionelle Nischendüfte zwischen Materialität, Raum und Charakter. Sechs Düfte als Discovery Set oder 50 ml Flakon. Made in Switzerland."
|
||||
/>
|
||||
<meta name="theme-color" content="#262626" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
|
||||
<!-- TODO: replace https://atmos.example with the real production domain -->
|
||||
<link rel="canonical" href="https://atmos.example/" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="atmos" />
|
||||
<meta property="og:title" content="atmos · Konzeptionelle Düfte" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Konzeptionelle Nischendüfte zwischen Materialität, Raum und Charakter. Made in Switzerland."
|
||||
/>
|
||||
<meta property="og:url" content="https://atmos.example/" />
|
||||
<meta property="og:image" content="https://atmos.example/og-image.jpg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:locale" content="de_CH" />
|
||||
|
||||
<!-- Twitter / X -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="atmos · Konzeptionelle Düfte" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Konzeptionelle Nischendüfte zwischen Materialität, Raum und Charakter."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://atmos.example/og-image.jpg" />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/questrial/questrial-latin.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
|
||||
<!-- Preload LCP image of the landing hero -->
|
||||
<link
|
||||
rel="preload"
|
||||
as="image"
|
||||
href="/images/hero/768/blasse-seide-hero-product.webp"
|
||||
imagesrcset="/images/hero/480/blasse-seide-hero-product.webp 480w, /images/hero/768/blasse-seide-hero-product.webp 768w, /images/hero/960/blasse-seide-hero-product.webp 960w, /blasse-seide-hero-product.webp 1078w"
|
||||
imagesizes="(max-width: 760px) 92vw, (max-width: 1180px) 45vw, 768px"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>parfum-shop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
234
parfum-shop/package-lock.json
generated
@ -9,7 +9,6 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"gsap": "^3.14.2",
|
||||
"lenis": "^1.3.23",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router": "^7.14.0"
|
||||
@ -60,6 +59,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@ -270,21 +270,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
||||
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
|
||||
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"@emnapi/wasi-threads": "1.2.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
||||
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
|
||||
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@ -293,9 +293,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
|
||||
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@ -563,28 +563,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
|
||||
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.124.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
|
||||
"integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
|
||||
"version": "0.120.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz",
|
||||
"integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@ -592,9 +590,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -609,9 +607,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -626,9 +624,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -643,9 +641,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -660,9 +658,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@ -677,9 +675,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -694,9 +692,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -711,9 +709,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@ -728,9 +726,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@ -745,9 +743,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -762,9 +760,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -779,9 +777,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -796,9 +794,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@ -806,18 +804,16 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.9.2",
|
||||
"@emnapi/runtime": "1.9.2",
|
||||
"@napi-rs/wasm-runtime": "^1.1.3"
|
||||
"@napi-rs/wasm-runtime": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -832,9 +828,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -854,6 +850,7 @@
|
||||
"integrity": "sha512-q9pE8+47bQNHb5eWVcE6oXppA+JTSwvnrhH53m0ZuHuK5MLvwsLoWrWzBTFQqQ06BVxz1gp0HblLsch8o6pvZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
@ -917,6 +914,7 @@
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@ -963,6 +961,7 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -1026,6 +1025,7 @@
|
||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
@ -1081,6 +1081,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@ -1283,6 +1284,7 @@
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@ -1779,37 +1781,6 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lenis": {
|
||||
"version": "1.3.23",
|
||||
"resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz",
|
||||
"integrity": "sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"playground",
|
||||
"playground/*"
|
||||
],
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/darkroomengineering"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": ">=3.0.0",
|
||||
"react": ">=17.0.0",
|
||||
"vue": ">=3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@ -2328,6 +2299,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -2337,6 +2309,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@ -2377,14 +2350,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.124.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.15"
|
||||
"@oxc-project/types": "=0.120.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.10"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@ -2393,27 +2367,27 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.15",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.15",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.10",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.10",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.10",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
|
||||
"integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@ -2578,16 +2552,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.8",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
|
||||
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
|
||||
"integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.8",
|
||||
"rolldown": "1.0.0-rc.15",
|
||||
"rolldown": "1.0.0-rc.10",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"bin": {
|
||||
@ -2605,7 +2580,7 @@
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"sass": "^1.70.0",
|
||||
@ -2707,6 +2682,7 @@
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@ -4,16 +4,13 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node server/dev.js",
|
||||
"dev:frontend": "vite",
|
||||
"dev:api": "node server/index.js",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"gsap": "^3.14.2",
|
||||
"lenis": "^1.3.23",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router": "^7.14.0"
|
||||
|
||||
BIN
parfum-shop/public/BLASSE SEIDE.png
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
parfum-shop/public/DISCOVERYSET.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
parfum-shop/public/HERO.jpeg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
parfum-shop/public/KALTER BETON.png
Normal file
|
After Width: | Height: | Size: 341 KiB |
BIN
parfum-shop/public/NASSER MARMOR.png
Normal file
|
After Width: | Height: | Size: 338 KiB |
BIN
parfum-shop/public/SCHWARZES BENZIN.png
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
parfum-shop/public/VERBRANNTES CHROM.png
Normal file
|
After Width: | Height: | Size: 446 KiB |
BIN
parfum-shop/public/WEISSE ASCHE.png
Normal file
|
After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 1.8 MiB |
@ -1,3 +0,0 @@
|
||||
<svg width="1386" height="344" viewBox="0 0 1386 344" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M121 343.5C96.6667 343.5 75.3333 337.833 57 326.5C39 314.833 25 299.167 15 279.5C5 259.833 9.23872e-07 238.167 9.23872e-07 214.5C9.23872e-07 190.5 5 168.833 15 149.5C25 129.833 39 114.333 57 103C75.3333 91.3333 96.6667 85.5 121 85.5C141.667 85.5 159.333 89.5 174 97.5C189 105.5 201.167 116.333 210.5 130V89.5H248V339.5H210.5V299.5C201.167 312.833 189 323.5 174 331.5C159.333 339.5 141.667 343.5 121 343.5ZM126 309.5C144.667 309.5 160.333 305.167 173 296.5C186 287.833 195.833 276.333 202.5 262C209.167 247.333 212.5 231.5 212.5 214.5C212.5 197.167 209.167 181.333 202.5 167C195.833 152.667 186 141.167 173 132.5C160.333 123.833 144.667 119.5 126 119.5C107.667 119.5 91.8333 123.833 78.5 132.5C65.1667 141.167 55 152.667 48 167C41 181.333 37.5 197.167 37.5 214.5C37.5 231.5 41 247.333 48 262C55 276.333 65.1667 287.833 78.5 296.5C91.8333 305.167 107.667 309.5 126 309.5ZM339.863 339.5V122.5H288.863V89.5H339.863V1.43051e-06H377.363V89.5H434.863V122.5H377.363V339.5H339.863ZM475.949 339.5V89.5H513.449V127.5C519.116 117.5 528.116 108 540.449 99C552.783 90 569.116 85.5 589.449 85.5C606.116 85.5 621.449 90 635.449 99C649.783 107.667 660.949 120 668.949 136C672.283 130.333 677.449 123.5 684.449 115.5C691.783 107.5 701.283 100.5 712.949 94.5C724.616 88.5 738.616 85.5 754.949 85.5C770.949 85.5 785.949 89.6667 799.949 98C813.949 106.333 825.283 118.333 833.949 134C842.949 149.333 847.449 167.5 847.449 188.5V339.5H809.949V189.5C809.949 168.5 803.949 151.667 791.949 139C780.283 126 765.616 119.5 747.949 119.5C729.616 119.5 713.783 125.667 700.449 138C687.116 150.333 680.449 167.667 680.449 190V339.5H642.949V189.5C642.949 168.5 636.949 151.667 624.949 139C613.283 126 598.616 119.5 580.949 119.5C562.616 119.5 546.783 125.667 533.449 138C520.116 150.333 513.449 167.667 513.449 190V339.5H475.949ZM1018.53 343.5C993.198 343.5 971.198 337.833 952.531 326.5C933.865 314.833 919.531 299.167 909.531 279.5C899.531 259.833 894.531 238.167 894.531 214.5C894.531 190.5 899.531 168.833 909.531 149.5C919.531 129.833 933.865 114.333 952.531 103C971.198 91.3333 993.198 85.5 1018.53 85.5C1044.2 85.5 1066.2 91.3333 1084.53 103C1103.2 114.333 1117.53 129.833 1127.53 149.5C1137.53 168.833 1142.53 190.5 1142.53 214.5C1142.53 238.167 1137.53 259.833 1127.53 279.5C1117.53 299.167 1103.2 314.833 1084.53 326.5C1066.2 337.833 1044.2 343.5 1018.53 343.5ZM1018.53 309.5C1037.2 309.5 1052.86 305.167 1065.53 296.5C1078.53 287.833 1088.36 276.333 1095.03 262C1101.7 247.333 1105.03 231.5 1105.03 214.5C1105.03 197.167 1101.7 181.333 1095.03 167C1088.36 152.667 1078.53 141.167 1065.53 132.5C1052.86 123.833 1037.2 119.5 1018.53 119.5C1000.2 119.5 984.531 123.833 971.531 132.5C958.531 141.167 948.698 152.667 942.031 167C935.365 181.333 932.031 197.167 932.031 214.5C932.031 231.5 935.365 247.333 942.031 262C948.698 276.333 958.531 287.833 971.531 296.5C984.531 305.167 1000.2 309.5 1018.53 309.5ZM1283.26 343.5C1260.26 343.5 1240.76 339.667 1224.76 332C1209.09 324.333 1197.09 314.333 1188.76 302C1180.42 289.333 1175.76 275.667 1174.76 261H1213.76C1214.76 269.333 1217.59 277.5 1222.26 285.5C1227.26 293.167 1234.76 299.5 1244.76 304.5C1254.76 309.167 1267.76 311.5 1283.76 311.5C1288.76 311.5 1294.92 311 1302.26 310C1309.59 309 1316.59 307.167 1323.26 304.5C1330.26 301.833 1336.09 297.833 1340.76 292.5C1345.42 287.167 1347.76 280.333 1347.76 272C1347.76 261.667 1343.76 253.667 1335.76 248C1327.76 242.333 1317.42 238 1304.76 235C1292.09 231.667 1278.59 228.5 1264.26 225.5C1250.26 222.5 1236.92 218.667 1224.26 214C1211.59 209 1201.26 202.167 1193.26 193.5C1185.26 184.5 1181.26 172.333 1181.26 157C1181.26 134.333 1189.42 116.833 1205.76 104.5C1222.42 91.8333 1246.92 85.5 1279.26 85.5C1301.26 85.5 1319.09 89 1332.76 96C1346.76 102.667 1357.26 111.333 1364.26 122C1371.59 132.667 1375.92 144.167 1377.26 156.5H1339.26C1337.92 145.833 1332.59 136.667 1323.26 129C1314.26 121.333 1299.26 117.5 1278.26 117.5C1238.59 117.5 1218.76 129.5 1218.76 153.5C1218.76 163.5 1222.76 171.167 1230.76 176.5C1238.76 181.833 1249.09 186.167 1261.76 189.5C1274.42 192.5 1287.76 195.5 1301.76 198.5C1316.09 201.167 1329.59 205 1342.26 210C1354.92 215 1365.26 222.167 1373.26 231.5C1381.26 240.5 1385.26 252.833 1385.26 268.5C1385.26 292.833 1375.92 311.5 1357.26 324.5C1338.92 337.167 1314.26 343.5 1283.26 343.5Z" fill="#EAEAEA"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.3 KiB |
@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_1" data-name="Ebene 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 46.43 9.21">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #262626;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g>
|
||||
<path class="cls-1" d="M4.05,8.93c-.82,0-1.53-.19-2.14-.58-.61-.39-1.08-.91-1.42-1.57-.34-.66-.5-1.38-.5-2.18s.17-1.53.5-2.19c.33-.65.81-1.17,1.42-1.56.61-.39,1.32-.58,2.14-.58.69,0,1.29.13,1.78.4s.9.63,1.21,1.09V.42h1.26v8.37h-1.26v-1.34c-.31.45-.72.8-1.21,1.07-.5.27-1.09.4-1.78.4ZM4.22,7.79c.62,0,1.15-.14,1.58-.44.43-.29.76-.68.98-1.16.22-.49.33-1.01.33-1.58s-.11-1.11-.33-1.59c-.22-.48-.55-.86-.98-1.16s-.96-.44-1.58-.44-1.14.15-1.59.44-.79.68-1.02,1.16c-.23.48-.35,1.01-.35,1.59s.12,1.1.35,1.58c.23.49.58.87,1.02,1.16.45.29.98.44,1.59.44Z"/>
|
||||
<path class="cls-1" d="M43.01,8.93c-.77,0-1.42-.13-1.95-.39-.53-.26-.94-.59-1.21-1.01-.28-.42-.43-.87-.47-1.37h1.31c.03.28.13.55.29.81s.41.47.75.64c.33.16.77.24,1.31.24.17,0,.37-.02.62-.05s.48-.09.71-.18.42-.22.58-.4.23-.41.23-.69c0-.35-.13-.61-.4-.8-.27-.19-.61-.34-1.04-.44-.43-.11-.88-.21-1.35-.31-.47-.1-.92-.23-1.35-.39-.43-.16-.77-.39-1.04-.69s-.4-.7-.4-1.21c0-.76.28-1.35.83-1.77.55-.42,1.37-.63,2.45-.63.74,0,1.34.12,1.8.34.46.23.82.52,1.05.88.24.36.38.74.43,1.16h-1.27c-.04-.36-.22-.66-.53-.92-.31-.26-.81-.39-1.52-.39-1.33,0-1.99.4-1.99,1.21,0,.33.13.59.4.77.27.18.61.32,1.04.43.42.11.87.21,1.35.3.47.1.92.23,1.35.39s.77.4,1.04.71c.27.31.4.72.4,1.25,0,.82-.31,1.44-.93,1.87-.62.43-1.45.65-2.49.65Z"/>
|
||||
</g>
|
||||
<circle class="cls-1" cx="13.97" cy="4.6" r="4.6"/>
|
||||
<circle class="cls-1" cx="24.01" cy="4.6" r="4.6"/>
|
||||
<circle class="cls-1" cx="33.99" cy="4.6" r="4.6"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_1" data-name="Ebene 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 46.43 9.21">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #eaeaea;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g>
|
||||
<path class="cls-1" d="M4.05,8.93c-.82,0-1.53-.19-2.14-.58-.61-.39-1.08-.91-1.42-1.57-.34-.66-.5-1.38-.5-2.18s.17-1.53.5-2.19c.33-.65.81-1.17,1.42-1.56.61-.39,1.32-.58,2.14-.58.69,0,1.29.13,1.78.4s.9.63,1.21,1.09V.42h1.26v8.37h-1.26v-1.34c-.31.45-.72.8-1.21,1.07-.5.27-1.09.4-1.78.4ZM4.22,7.79c.62,0,1.15-.14,1.58-.44.43-.29.76-.68.98-1.16.22-.49.33-1.01.33-1.58s-.11-1.11-.33-1.59c-.22-.48-.55-.86-.98-1.16s-.96-.44-1.58-.44-1.14.15-1.59.44-.79.68-1.02,1.16c-.23.48-.35,1.01-.35,1.59s.12,1.1.35,1.58c.23.49.58.87,1.02,1.16.45.29.98.44,1.59.44Z"/>
|
||||
<path class="cls-1" d="M43.01,8.93c-.77,0-1.42-.13-1.95-.39-.53-.26-.94-.59-1.21-1.01-.28-.42-.43-.87-.47-1.37h1.31c.03.28.13.55.29.81s.41.47.75.64c.33.16.77.24,1.31.24.17,0,.37-.02.62-.05s.48-.09.71-.18.42-.22.58-.4.23-.41.23-.69c0-.35-.13-.61-.4-.8-.27-.19-.61-.34-1.04-.44-.43-.11-.88-.21-1.35-.31-.47-.1-.92-.23-1.35-.39-.43-.16-.77-.39-1.04-.69s-.4-.7-.4-1.21c0-.76.28-1.35.83-1.77.55-.42,1.37-.63,2.45-.63.74,0,1.34.12,1.8.34.46.23.82.52,1.05.88.24.36.38.74.43,1.16h-1.27c-.04-.36-.22-.66-.53-.92-.31-.26-.81-.39-1.52-.39-1.33,0-1.99.4-1.99,1.21,0,.33.13.59.4.77.27.18.61.32,1.04.43.42.11.87.21,1.35.3.47.1.92.23,1.35.39s.77.4,1.04.71c.27.31.4.72.4,1.25,0,.82-.31,1.44-.93,1.87-.62.43-1.45.65-2.49.65Z"/>
|
||||
</g>
|
||||
<circle class="cls-1" cx="13.97" cy="4.6" r="4.6"/>
|
||||
<circle class="cls-1" cx="24.01" cy="4.6" r="4.6"/>
|
||||
<circle class="cls-1" cx="33.99" cy="4.6" r="4.6"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 85 KiB |
@ -1,93 +0,0 @@
|
||||
Copyright 2011 The Questrial Project Authors (https://github.com/googlefonts/questrial)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
Before Width: | Height: | Size: 3.5 MiB |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="#eaeaea" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 273 B |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="#eaeaea" viewBox="0 0 256 256"><path d="M216,48H40A16,16,0,0,0,24,64V224a15.84,15.84,0,0,0,9.25,14.5A16.05,16.05,0,0,0,40,240a15.89,15.89,0,0,0,10.25-3.78l.09-.07L83,208H216a16,16,0,0,0,16-16V64A16,16,0,0,0,216,48ZM40,224h0ZM216,192H80a8,8,0,0,0-5.23,1.95L40,224V64H216Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 355 B |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="#eaeaea" viewBox="0 0 256 256"><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 309 B |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 29 KiB |
BIN
parfum-shop/public/kalter-beton-product.png
Normal file
|
After Width: | Height: | Size: 365 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 122 KiB |
@ -1,8 +0,0 @@
|
||||
# robots.txt — atmos parfum-shop
|
||||
# TODO: replace https://atmos.example with the real production domain
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /api/
|
||||
|
||||
Sitemap: https://atmos.example/sitemap.xml
|
||||
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 141 KiB |
@ -1,68 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://atmos.ch/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://atmos.ch/about</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://atmos.ch/discovery-set</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://atmos.ch/small-batch</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://atmos.ch/support</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.4</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://atmos.ch/impressum</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.2</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://atmos.ch/datenschutz</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.2</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://atmos.ch/duft/kalter-beton</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://atmos.ch/duft/nasser-marmor</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://atmos.ch/duft/blasse-seide</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://atmos.ch/duft/weisse-asche</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://atmos.ch/duft/verbranntes-chrom</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://atmos.ch/duft/schwarzes-benzin</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 220 KiB |
@ -1,38 +0,0 @@
|
||||
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 2 ml",
|
||||
price_cents: 4800,
|
||||
discovery_credit_cents: 4800,
|
||||
},
|
||||
...perfumes.flatMap((perfume) => [
|
||||
{
|
||||
id: `${perfume.slug}-sample`,
|
||||
slug: perfume.slug,
|
||||
name: `${perfume.name} Probe`,
|
||||
kind: "sample",
|
||||
size_label: "2 ml Probe",
|
||||
price_cents: parsePriceCents(perfume.prices.sample),
|
||||
discovery_credit_cents: 0,
|
||||
},
|
||||
{
|
||||
id: `${perfume.slug}-full`,
|
||||
slug: perfume.slug,
|
||||
name: `${perfume.name} 50 ml Flakon`,
|
||||
kind: "full_size",
|
||||
size_label: "50 ml Flakon",
|
||||
price_cents: parsePriceCents(perfume.prices.full),
|
||||
discovery_credit_cents: 0,
|
||||
},
|
||||
]),
|
||||
];
|
||||
@ -1,169 +0,0 @@
|
||||
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}`);
|
||||
@ -1,40 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
const viteBin = path.join(
|
||||
"node_modules",
|
||||
".bin",
|
||||
process.platform === "win32" ? "vite.cmd" : "vite"
|
||||
);
|
||||
|
||||
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", viteBin, []);
|
||||
|
||||
const stop = () => {
|
||||
api.kill("SIGTERM");
|
||||
vite.kill("SIGTERM");
|
||||
};
|
||||
|
||||
process.on("SIGINT", stop);
|
||||
process.on("SIGTERM", stop);
|
||||
@ -1,732 +0,0 @@
|
||||
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("Anfrage ist zu gross."));
|
||||
req.destroy();
|
||||
}
|
||||
});
|
||||
req.on("end", () => {
|
||||
if (!raw) {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch {
|
||||
reject(new Error("Ungültiges 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: "Bitte melde dich an, um fortzufahren." });
|
||||
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-Gutschrift",
|
||||
});
|
||||
}
|
||||
|
||||
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: `Proben-Gutschrift für ${row.name.replace(" 50 ml Flakon", "")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: "Vorname, Nachname, E-Mail und Passwort sind erforderlich." });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
|
||||
if (existing) {
|
||||
json(res, 409, { error: "Mit dieser E-Mail existiert bereits ein Konto." });
|
||||
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: "E-Mail oder Passwort ist ungültig." });
|
||||
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: "Vorname und Nachname sind erforderlich." });
|
||||
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: "Produkt nicht gefunden." });
|
||||
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} wurde in den Warenkorb gelegt.`,
|
||||
});
|
||||
};
|
||||
|
||||
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: "Die Menge ist erforderlich." });
|
||||
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: "Dein Warenkorb ist leer." });
|
||||
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: "Strasse, Hausnummer, PLZ und Ort sind erforderlich." });
|
||||
return;
|
||||
}
|
||||
if (!["Bill", "Card", "Twint", "PayPal"].includes(paymentMethod)) {
|
||||
json(res, 400, { error: "Wähle eine Zahlungsmethode." });
|
||||
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: "Produkt nicht gefunden." });
|
||||
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}: Benachrichtigung gespeichert.`,
|
||||
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 nicht gefunden." });
|
||||
};
|
||||
|
||||
createServer((req, res) => {
|
||||
route(req, res).catch((error) => {
|
||||
console.error(error);
|
||||
json(res, 500, { error: "Serverfehler." });
|
||||
});
|
||||
}).listen(PORT, () => {
|
||||
console.log(`Shop API listening on http://localhost:${PORT}`);
|
||||
});
|
||||
@ -1,116 +1,15 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
#root {
|
||||
background: var(--theme-bg);
|
||||
color: var(--theme-text);
|
||||
min-width: 0;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--theme-bg);
|
||||
}
|
||||
|
||||
main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: var(--container-wide);
|
||||
margin: 0 auto;
|
||||
padding: 0 0 clamp(2.2rem, 6vw, 5rem);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.page,
|
||||
.detail-page,
|
||||
.discovery-page,
|
||||
.about-page,
|
||||
.support-page,
|
||||
.small-page,
|
||||
.impressum-page,
|
||||
.datenschutz-page {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.detail-page .navbar--light,
|
||||
.discovery-page .navbar--light,
|
||||
.about-page .navbar--light,
|
||||
.support-page .navbar--light,
|
||||
.small-page .navbar--light,
|
||||
.impressum-page .navbar--light,
|
||||
.datenschutz-page .navbar--light {
|
||||
position: fixed;
|
||||
top: clamp(0.75rem, 2.1vw, 1.4rem);
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: var(--z-nav);
|
||||
margin-bottom: 0;
|
||||
padding-top: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.detail-page .navbar--light .nav-pill,
|
||||
.discovery-page .navbar--light .nav-pill,
|
||||
.about-page .navbar--light .nav-pill,
|
||||
.support-page .navbar--light .nav-pill,
|
||||
.small-page .navbar--light .nav-pill,
|
||||
.impressum-page .navbar--light .nav-pill,
|
||||
.datenschutz-page .navbar--light .nav-pill {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.navbar--light .nav-pill,
|
||||
.navbar--light .nav-link,
|
||||
.shell,
|
||||
.page,
|
||||
.detail-page,
|
||||
.discovery-page,
|
||||
.about-page,
|
||||
.support-page,
|
||||
.small-page,
|
||||
.impressum-page,
|
||||
.datenschutz-page,
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
transition:
|
||||
background-color var(--duration-med) var(--ease-out),
|
||||
border-color var(--duration-med) var(--ease-out),
|
||||
color var(--duration-med) var(--ease-out),
|
||||
box-shadow var(--duration-med) var(--ease-out);
|
||||
}
|
||||
|
||||
body.theme-dark .navbar--light .nav-pill {
|
||||
background: rgba(38, 38, 38, 0.88);
|
||||
border-color: rgba(234, 234, 234, 0.26);
|
||||
}
|
||||
|
||||
body.theme-dark .navbar--light .nav-link {
|
||||
color: var(--theme-white);
|
||||
}
|
||||
|
||||
body.theme-dark .navbar--light .nav-link:hover,
|
||||
body.theme-dark .navbar--light .nav-link.active {
|
||||
background: rgba(234, 234, 234, 0.15);
|
||||
}
|
||||
|
||||
body.theme-dark .navbar--light .nav-theme-switch__track {
|
||||
border-color: rgba(234, 234, 234, 0.3);
|
||||
background: rgba(234, 234, 234, 0.14);
|
||||
}
|
||||
|
||||
body.theme-dark .navbar--light .nav-brand-logo {
|
||||
filter: invert(1) brightness(1.08);
|
||||
}
|
||||
|
||||
body.theme-light .navbar--light .nav-link:hover,
|
||||
body.theme-light .navbar--light .nav-link.active {
|
||||
background: var(--theme-surface-soft);
|
||||
}
|
||||
background: #efefef;
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Routes, Route, useLocation } from "react-router";
|
||||
import { Routes, Route } from "react-router";
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
import ProductDetailPage from "./components/ProductDetailPage";
|
||||
import AboutPage from "./pages/AboutPage";
|
||||
@ -7,87 +6,26 @@ import ImpressumPage from "./pages/ImpressumPage";
|
||||
import DatenschutzPage from "./pages/DatenschutzPage";
|
||||
import SupportPage from "./pages/SupportPage";
|
||||
import DiscoverySetPage from "./pages/DiscoverySetPage";
|
||||
import SmallBatchPage from "./pages/SmallBatchPage";
|
||||
import Footer from "./components/Footer";
|
||||
import SupportChatbot from "./components/SupportChatbot";
|
||||
import ScrollToTop from "./components/ScrollToTop";
|
||||
import ShopDrawer from "./components/ShopDrawer";
|
||||
import CartToast from "./components/CartToast";
|
||||
import { ProductTransitionProvider } from "./components/ProductTransition";
|
||||
import { PageTransitionProvider } from "./transitions/PageTransition";
|
||||
import useLenisSmoothScroll from "./hooks/useLenisSmoothScroll";
|
||||
import useScrollTextReveal from "./hooks/useScrollTextReveal";
|
||||
import useButtonInteractions from "./hooks/useButtonInteractions";
|
||||
import { ThemeProvider } from "./theme/ThemeContext";
|
||||
import "./style/textReveal.css";
|
||||
|
||||
const THEME_STORAGE_KEY = "atmos-theme";
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
const routeContentRef = useRef(null);
|
||||
const [theme, setTheme] = useState(() => {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
return storedTheme === "light" ? "light" : "dark";
|
||||
});
|
||||
const shouldFlushFooter =
|
||||
location.pathname === "/" || location.pathname.startsWith("/duft/");
|
||||
const showSupportChatbot = location.pathname === "/";
|
||||
|
||||
useLenisSmoothScroll(location.pathname);
|
||||
useScrollTextReveal(routeContentRef, location.pathname);
|
||||
useButtonInteractions();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
document.body.classList.remove("theme-dark", "theme-light");
|
||||
document.body.classList.add(theme === "light" ? "theme-light" : "theme-dark");
|
||||
document.documentElement.style.colorScheme = theme === "light" ? "light" : "dark";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((currentTheme) => (currentTheme === "dark" ? "light" : "dark"));
|
||||
};
|
||||
|
||||
const isLight = theme === "light";
|
||||
|
||||
return (
|
||||
<ThemeProvider value={{ theme, isLight, toggleTheme }}>
|
||||
<ProductTransitionProvider>
|
||||
<PageTransitionProvider>
|
||||
<ScrollToTop />
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/duft/:perfumeSlug" element={<ProductDetailPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/impressum" element={<ImpressumPage />} />
|
||||
<Route path="/datenschutz" element={<DatenschutzPage />} />
|
||||
<Route path="/support" element={<SupportPage />} />
|
||||
<Route path="/discovery-set" element={<DiscoverySetPage />} />
|
||||
</Routes>
|
||||
|
||||
<a href="#main-content" className="skip-link">
|
||||
Zum Inhalt springen
|
||||
</a>
|
||||
|
||||
<div ref={routeContentRef} data-route-content>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/duft/:perfumeSlug" element={<ProductDetailPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/impressum" element={<ImpressumPage />} />
|
||||
<Route path="/datenschutz" element={<DatenschutzPage />} />
|
||||
<Route path="/support" element={<SupportPage />} />
|
||||
<Route path="/discovery-set" element={<DiscoverySetPage />} />
|
||||
<Route path="/small-batch" element={<SmallBatchPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
<ShopDrawer />
|
||||
<CartToast />
|
||||
<Footer flushTop={shouldFlushFooter} />
|
||||
{showSupportChatbot && <SupportChatbot />}
|
||||
</PageTransitionProvider>
|
||||
</ProductTransitionProvider>
|
||||
</ThemeProvider>
|
||||
<Footer />
|
||||
<SupportChatbot />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
@ -1,49 +0,0 @@
|
||||
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" role="status" aria-live="polite">
|
||||
<button
|
||||
type="button"
|
||||
className="atmos-btn atmos-btn--outline atmos-btn--icon atmos-btn--sm cart-toast-close"
|
||||
onClick={dismissToast}
|
||||
aria-label="Hinweis schliessen"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
<strong>{cartToast.title}</strong>
|
||||
<p>{cartToast.message}</p>
|
||||
{cartToast.actionLabel && (
|
||||
<button
|
||||
type="button"
|
||||
className="atmos-btn atmos-btn--primary atmos-btn--block atmos-btn--sm"
|
||||
onClick={runAction}
|
||||
>
|
||||
{cartToast.actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CartToast;
|
||||
@ -1,121 +1,80 @@
|
||||
.site-footer {
|
||||
position: relative;
|
||||
margin-top: 0;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 82% 10%, rgba(var(--theme-accent-rgb) / 0.15), transparent 22rem),
|
||||
var(--footer-bg);
|
||||
color: var(--footer-text);
|
||||
border-top: 1px solid var(--footer-border);
|
||||
}
|
||||
|
||||
.site-footer--flush {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.site-footer::before {
|
||||
content: "ATMOS";
|
||||
position: absolute;
|
||||
right: var(--page-x);
|
||||
bottom: -0.16em;
|
||||
color: var(--footer-watermark);
|
||||
font-size: clamp(5.5rem, 18vw, 20rem);
|
||||
line-height: 0.8;
|
||||
letter-spacing: 0;
|
||||
pointer-events: none;
|
||||
margin-top: 40px;
|
||||
background: #1f1f1f;
|
||||
color: #f5f5f5;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.site-footer__inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: var(--container-wide);
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(2.4rem, 7vw, 6.5rem) 0 clamp(2.2rem, 5vw, 4.8rem);
|
||||
padding: 28px 20px 32px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(12rem, 0.65fr) minmax(12rem, 0.75fr);
|
||||
gap: var(--gap-lg);
|
||||
align-items: start;
|
||||
grid-template-columns: 1.4fr 1fr 1fr;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.site-footer__brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.site-footer__logo {
|
||||
width: fit-content;
|
||||
color: var(--theme-accent-contrast);
|
||||
font-size: clamp(1rem, 1.5vw, 1.2rem);
|
||||
letter-spacing: 0.22em;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.22em;
|
||||
}
|
||||
|
||||
.site-footer__text {
|
||||
max-width: 32rem;
|
||||
margin: 0;
|
||||
color: var(--footer-text-muted);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.65;
|
||||
max-width: 320px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.site-footer__nav-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
padding-top: 0.2rem;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.site-footer__heading {
|
||||
color: var(--footer-text-faint);
|
||||
font-size: var(--text-xs);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.site-footer__nav {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.site-footer__nav ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.72rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.site-footer__nav a {
|
||||
width: fit-content;
|
||||
color: var(--footer-text);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.2;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color var(--duration-med) var(--ease-out),
|
||||
opacity var(--duration-med) var(--ease-out),
|
||||
transform var(--duration-med) var(--ease-out);
|
||||
color: #f5f5f5;
|
||||
font-size: 14px;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.site-footer__nav a:hover,
|
||||
.site-footer__nav a:focus-visible {
|
||||
color: var(--theme-accent);
|
||||
transform: translateX(0.25rem);
|
||||
.site-footer__nav a:hover {
|
||||
opacity: 0.7;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
@media (max-width: 900px) {
|
||||
.site-footer__inner {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.site-footer__brand {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
@media (max-width: 640px) {
|
||||
.site-footer__inner {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 24px 16px 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,58 +1,40 @@
|
||||
import { Link } from "react-router";
|
||||
import "./Footer.css";
|
||||
|
||||
const footerLinkGroups = [
|
||||
{
|
||||
heading: "Navigation",
|
||||
ariaLabel: "Footer Navigation",
|
||||
links: [
|
||||
{ to: "/", label: "Startseite" },
|
||||
{ to: "/#dufte", label: "D\u00FCfte" },
|
||||
{ to: "/discovery-set", label: "Discovery Set" },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Rechtliches & Info",
|
||||
ariaLabel: "Footer Rechtliches und Info",
|
||||
links: [
|
||||
{ to: "/about", label: "About Us" },
|
||||
{ to: "/support", label: "Support" },
|
||||
{ to: "/impressum", label: "Impressum" },
|
||||
{ to: "/datenschutz", label: "Datenschutz" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function Footer({ flushTop = false }) {
|
||||
function Footer() {
|
||||
return (
|
||||
<footer className={`site-footer${flushTop ? " site-footer--flush" : ""}`}>
|
||||
<footer className="site-footer">
|
||||
<div className="site-footer__inner">
|
||||
<div className="site-footer__brand">
|
||||
<Link to="/" className="site-footer__logo">
|
||||
ATMOS
|
||||
</Link>
|
||||
<p className="site-footer__text">
|
||||
{"Konzeptuelle D\u00FCfte zwischen Materialit\u00E4t, Raum und Charakter."}
|
||||
Konzeptuelle Düfte zwischen Materialität, Raum und Charakter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{footerLinkGroups.map((group) => (
|
||||
<div className="site-footer__nav-group" key={group.heading}>
|
||||
<span className="site-footer__heading">{group.heading}</span>
|
||||
<nav className="site-footer__nav" aria-label={group.ariaLabel}>
|
||||
<ul>
|
||||
{group.links.map((link) => (
|
||||
<li key={link.to}>
|
||||
<Link to={link.to}>{link.label}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
))}
|
||||
<div className="site-footer__nav-group">
|
||||
<span className="site-footer__heading">Navigation</span>
|
||||
<nav className="site-footer__nav">
|
||||
<Link to="/">Startseite</Link>
|
||||
<Link to="/#dufte">Düfte</Link>
|
||||
<Link to="/discovery-set">Discovery Set</Link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="site-footer__nav-group">
|
||||
<span className="site-footer__heading">Rechtliches & Info</span>
|
||||
<nav className="site-footer__nav">
|
||||
<Link to="/about">About Us</Link>
|
||||
<Link to="/support">Support</Link>
|
||||
<Link to="/impressum">Impressum</Link>
|
||||
<Link to="/datenschutz">Datenschutz</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
export default Footer;
|
||||
@ -1,37 +0,0 @@
|
||||
.product-transition {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2500;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-transition__wash {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--theme-bg);
|
||||
}
|
||||
|
||||
.product-transition__image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
max-width: none;
|
||||
object-fit: contain;
|
||||
transform-origin: 0 0;
|
||||
will-change: transform, opacity;
|
||||
filter: var(--shadow-product);
|
||||
}
|
||||
|
||||
body.product-transition-active {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.product-transition {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@ -1,339 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { gsap } from "gsap";
|
||||
import { ProductTransitionContext } from "../transitions/ProductTransitionContext";
|
||||
import "./ProductTransition.css";
|
||||
|
||||
const supportsProductTransition = () => {
|
||||
if (typeof window === "undefined") return false;
|
||||
|
||||
return !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
};
|
||||
|
||||
const rectToObject = (rect) => ({
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
});
|
||||
|
||||
const isPlainNavigationClick = (event) =>
|
||||
event.button === 0 &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.shiftKey;
|
||||
|
||||
const getStageRect = (sourceRect) => {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const sourceRatio = sourceRect.height / sourceRect.width || 1;
|
||||
const isMobile = viewportWidth <= 760;
|
||||
const maxWidthFraction = isMobile ? 0.85 : 0.56;
|
||||
const maxHeightFraction = isMobile ? 0.52 : 0.72;
|
||||
const maxAbsWidth = isMobile ? viewportWidth * maxWidthFraction : 620;
|
||||
const stageWidth = Math.min(
|
||||
Math.max(sourceRect.width * 1.16, 260),
|
||||
maxAbsWidth
|
||||
);
|
||||
const stageHeight = Math.min(stageWidth * sourceRatio, viewportHeight * maxHeightFraction);
|
||||
const normalizedWidth = stageHeight / sourceRatio;
|
||||
|
||||
return {
|
||||
left: (viewportWidth - normalizedWidth) / 2,
|
||||
top: (viewportHeight - stageHeight) / 2,
|
||||
width: normalizedWidth,
|
||||
height: stageHeight,
|
||||
};
|
||||
};
|
||||
|
||||
const getTransformForRect = (rect, sourceRect) => {
|
||||
const scale = Math.min(
|
||||
rect.width / sourceRect.width,
|
||||
rect.height / sourceRect.height
|
||||
);
|
||||
const width = sourceRect.width * scale;
|
||||
const height = sourceRect.height * scale;
|
||||
|
||||
return {
|
||||
x: rect.left + (rect.width - width) / 2,
|
||||
y: rect.top + (rect.height - height) / 2,
|
||||
scaleX: scale,
|
||||
scaleY: scale,
|
||||
};
|
||||
};
|
||||
|
||||
export function ProductTransitionProvider({ children }) {
|
||||
const navigate = useNavigate();
|
||||
const overlayRef = useRef(null);
|
||||
const imageRef = useRef(null);
|
||||
const washRef = useRef(null);
|
||||
const timelineRef = useRef(null);
|
||||
const [transition, setTransition] = useState(null);
|
||||
|
||||
const clearTransition = useCallback(() => {
|
||||
timelineRef.current?.kill();
|
||||
timelineRef.current = null;
|
||||
document.body.classList.remove("product-transition-active");
|
||||
setTransition(null);
|
||||
}, []);
|
||||
|
||||
const startProductTransition = useCallback(
|
||||
(event, perfume) => {
|
||||
if (!perfume?.slug || !isPlainNavigationClick(event)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!supportsProductTransition()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const card = event.currentTarget;
|
||||
const image = card.querySelector("[data-product-transition-source]");
|
||||
|
||||
if (!image) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sourceRect = image.getBoundingClientRect();
|
||||
|
||||
if (sourceRect.width <= 0 || sourceRect.height <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
timelineRef.current?.kill();
|
||||
document.body.classList.add("product-transition-active");
|
||||
|
||||
setTransition({
|
||||
id: `${perfume.slug}-${Date.now()}`,
|
||||
slug: perfume.slug,
|
||||
to: `/duft/${perfume.slug}`,
|
||||
image: image.currentSrc || image.src || perfume.image,
|
||||
alt: perfume.name,
|
||||
sourceRect: rectToObject(sourceRect),
|
||||
phase: "leaving",
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (transition?.phase !== "leaving") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const overlay = overlayRef.current;
|
||||
const image = imageRef.current;
|
||||
const wash = washRef.current;
|
||||
|
||||
if (!overlay || !image || !wash) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const routeContent = document.querySelector("[data-route-content]");
|
||||
const sourceRect = transition.sourceRect;
|
||||
const stageRect = getStageRect(sourceRect);
|
||||
let completed = false;
|
||||
|
||||
gsap.set(overlay, { autoAlpha: 1, pointerEvents: "auto" });
|
||||
gsap.set(wash, { autoAlpha: 0 });
|
||||
gsap.set(image, {
|
||||
x: sourceRect.left,
|
||||
y: sourceRect.top,
|
||||
width: sourceRect.width,
|
||||
height: sourceRect.height,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
autoAlpha: 1,
|
||||
transformOrigin: "0 0",
|
||||
force3D: true,
|
||||
});
|
||||
|
||||
timelineRef.current = gsap.timeline({
|
||||
defaults: { ease: "power4.inOut" },
|
||||
onComplete: () => {
|
||||
completed = true;
|
||||
|
||||
navigate(transition.to, {
|
||||
state: {
|
||||
productTransition: true,
|
||||
transitionId: transition.id,
|
||||
},
|
||||
});
|
||||
|
||||
setTransition((current) =>
|
||||
current?.id === transition.id
|
||||
? { ...current, phase: "entering" }
|
||||
: current
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
timelineRef.current
|
||||
.to(wash, { autoAlpha: 1, duration: 0.42, ease: "power2.out" }, 0)
|
||||
.to(
|
||||
routeContent,
|
||||
{
|
||||
autoAlpha: 0,
|
||||
filter: "none",
|
||||
scale: 1,
|
||||
duration: 0.62,
|
||||
ease: "power3.out",
|
||||
},
|
||||
0
|
||||
)
|
||||
.to(
|
||||
image,
|
||||
{
|
||||
...getTransformForRect(stageRect, sourceRect),
|
||||
duration: 0.78,
|
||||
},
|
||||
0.03
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (!completed) {
|
||||
timelineRef.current?.kill();
|
||||
}
|
||||
};
|
||||
}, [navigate, transition]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (transition?.phase !== "entering") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const overlay = overlayRef.current;
|
||||
const image = imageRef.current;
|
||||
const wash = washRef.current;
|
||||
|
||||
if (!overlay || !image || !wash) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
let cancelled = false;
|
||||
let completed = false;
|
||||
const sourceRect = transition.sourceRect;
|
||||
|
||||
const runEnterAnimation = () => {
|
||||
if (cancelled) return;
|
||||
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
||||
|
||||
const target = document.querySelector(
|
||||
`[data-product-transition-target="${transition.slug}"]`
|
||||
);
|
||||
|
||||
if (!target && frame < 16) {
|
||||
frame += 1;
|
||||
window.requestAnimationFrame(runEnterAnimation);
|
||||
return;
|
||||
}
|
||||
|
||||
const routeContent = document.querySelector("[data-route-content]");
|
||||
gsap.set(routeContent, {
|
||||
autoAlpha: 1,
|
||||
filter: "none",
|
||||
scale: 1,
|
||||
clearProps: "opacity,visibility,filter,transform",
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
gsap.to(overlay, {
|
||||
autoAlpha: 0,
|
||||
duration: 0.3,
|
||||
ease: "power2.out",
|
||||
onComplete: clearTransition,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRect = rectToObject(target.getBoundingClientRect());
|
||||
const revealItems = gsap.utils.toArray("[data-product-transition-reveal]");
|
||||
|
||||
gsap.set(target, { autoAlpha: 0 });
|
||||
gsap.set(revealItems, { y: 28, autoAlpha: 0, force3D: true });
|
||||
|
||||
timelineRef.current?.kill();
|
||||
timelineRef.current = gsap.timeline({
|
||||
defaults: { ease: "power4.out" },
|
||||
onComplete: () => {
|
||||
completed = true;
|
||||
clearTransition();
|
||||
},
|
||||
});
|
||||
|
||||
timelineRef.current
|
||||
.to(image, {
|
||||
...getTransformForRect(targetRect, sourceRect),
|
||||
duration: 0.72,
|
||||
})
|
||||
.set(target, { autoAlpha: 1 }, ">-0.08")
|
||||
.to(image, { autoAlpha: 0, duration: 0.16, ease: "power2.out" }, "<")
|
||||
.to(wash, { autoAlpha: 0, duration: 0.5, ease: "power2.out" }, "<")
|
||||
.to(
|
||||
revealItems,
|
||||
{
|
||||
y: 0,
|
||||
autoAlpha: 1,
|
||||
duration: 0.82,
|
||||
stagger: 0.07,
|
||||
ease: "power4.out",
|
||||
clearProps: "transform",
|
||||
},
|
||||
">-0.05"
|
||||
);
|
||||
};
|
||||
|
||||
window.requestAnimationFrame(runEnterAnimation);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (!completed) {
|
||||
timelineRef.current?.kill();
|
||||
}
|
||||
};
|
||||
}, [clearTransition, transition]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
activeSlug: transition?.slug || null,
|
||||
phase: transition?.phase || "idle",
|
||||
startProductTransition,
|
||||
}),
|
||||
[startProductTransition, transition]
|
||||
);
|
||||
|
||||
return (
|
||||
<ProductTransitionContext.Provider value={value}>
|
||||
{children}
|
||||
|
||||
{transition && (
|
||||
<div
|
||||
className={`product-transition product-transition--${transition.phase}`}
|
||||
ref={overlayRef}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="product-transition__wash" ref={washRef} />
|
||||
<img
|
||||
className="product-transition__image"
|
||||
src={transition.image}
|
||||
alt={transition.alt}
|
||||
ref={imageRef}
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ProductTransitionContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default ScrollToTop;
|
||||
@ -1,88 +0,0 @@
|
||||
import { Link } from "react-router";
|
||||
import { useShop } from "../shop/useShop";
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
import "../style/navbar.css";
|
||||
|
||||
function SharedNavbar({ variant = "hero", active = "", brandMode = "logo" }) {
|
||||
const { cart, openCart, openProfile, panelOpen, panelType, user } = useShop();
|
||||
const { isLight, toggleTheme } = useTheme();
|
||||
const cartCount = cart.total_quantity || 0;
|
||||
const cartLabel = cartCount > 0 ? `Warenkorb ${cartCount}` : "Warenkorb";
|
||||
const cartCompactLabel = cartCount > 0 ? `Korb ${cartCount}` : "Korb";
|
||||
const cartAriaLabel =
|
||||
cartCount > 0
|
||||
? `Warenkorb mit ${cartCount} ${cartCount === 1 ? "Artikel" : "Artikeln"} öffnen`
|
||||
: "Warenkorb öffnen";
|
||||
const logoSrc =
|
||||
variant === "hero" ? "/atmos-logo-light.svg" : "/atmos-logo-dark.svg";
|
||||
const brandIsBack = brandMode === "back";
|
||||
|
||||
return (
|
||||
<header className={`site-header site-header--${variant}`}>
|
||||
<nav className={`navbar navbar--${variant}`} aria-label="Hauptnavigation">
|
||||
<div className="nav-pill">
|
||||
<Link
|
||||
to="/"
|
||||
className={`nav-link nav-link--brand ${brandIsBack ? "nav-link--back" : ""} ${
|
||||
active === "atmos" ? "active" : ""
|
||||
}`}
|
||||
aria-label={brandIsBack ? "Zur Startseite" : "Atmos Startseite"}
|
||||
>
|
||||
{brandIsBack ? (
|
||||
<>
|
||||
<span className="nav-back-icon" aria-hidden="true" />
|
||||
<span>Zurück</span>
|
||||
</>
|
||||
) : (
|
||||
<img src={logoSrc} alt="" className="nav-brand-logo" />
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
to="/discovery-set"
|
||||
className={`nav-link ${active === "testen" ? "active" : ""}`}
|
||||
>
|
||||
Testen
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="nav-link nav-button nav-link--cart"
|
||||
onClick={openCart}
|
||||
aria-haspopup="dialog"
|
||||
aria-controls="shop-drawer"
|
||||
aria-expanded={panelOpen && panelType === "cart"}
|
||||
aria-label={cartAriaLabel}
|
||||
>
|
||||
<span className="nav-label nav-label--full">{cartLabel}</span>
|
||||
<span className="nav-label nav-label--compact">
|
||||
{cartCompactLabel}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="nav-link nav-button"
|
||||
onClick={openProfile}
|
||||
aria-haspopup="dialog"
|
||||
aria-controls="shop-drawer"
|
||||
aria-expanded={panelOpen && panelType === "profile"}
|
||||
aria-label={user ? "Profil öffnen" : "Anmelden oder Profil öffnen"}
|
||||
>
|
||||
Profil
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-link nav-button nav-theme-switch ${isLight ? "is-light" : ""}`}
|
||||
onClick={toggleTheme}
|
||||
aria-label="Light Mode"
|
||||
aria-pressed={isLight}
|
||||
>
|
||||
<span className="nav-theme-switch__track" aria-hidden="true">
|
||||
<span className="nav-theme-switch__thumb" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default SharedNavbar;
|
||||
@ -1,750 +0,0 @@
|
||||
/**
|
||||
* ShopDrawer + CartToast — visually aligned with Landing/Product surfaces.
|
||||
*
|
||||
* Buttons inside the drawer use the global `.atmos-btn` system. This file
|
||||
* only owns layout, the form-field skin, and the surface look of inner
|
||||
* cards (cart item, payment card, requirements, orders, etc.).
|
||||
*/
|
||||
|
||||
/* ----- Backdrop & shell -------------------------------------------------- */
|
||||
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-drawer-backdrop);
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--duration-med) var(--ease-out);
|
||||
}
|
||||
|
||||
.drawer-backdrop.is-open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.shop-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: var(--z-drawer);
|
||||
width: min(560px, 100%);
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
padding: clamp(1rem, 3vw, 1.4rem);
|
||||
overflow-y: auto;
|
||||
background: var(--theme-surface);
|
||||
border-left: 1px solid var(--theme-border);
|
||||
color: var(--theme-text);
|
||||
box-shadow: var(--theme-shadow);
|
||||
transform: translateX(100%);
|
||||
transition: transform var(--duration-med) var(--ease-out);
|
||||
}
|
||||
|
||||
.shop-drawer.is-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* ----- Top bar ----------------------------------------------------------- */
|
||||
|
||||
.drawer-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap-sm);
|
||||
padding-bottom: var(--gap-sm);
|
||||
margin-bottom: var(--gap-md);
|
||||
border-bottom: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.drawer-top .atmos-btn--icon {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ----- Sections ---------------------------------------------------------- */
|
||||
|
||||
.drawer-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.drawer-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
padding: clamp(1rem, 2.4vw, 1.4rem);
|
||||
border: 1px solid var(--theme-border);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.008)),
|
||||
var(--theme-surface-soft);
|
||||
}
|
||||
|
||||
.drawer-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.drawer-eyebrow {
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.drawer-heading {
|
||||
margin: 0;
|
||||
color: var(--theme-text);
|
||||
font-size: clamp(1.4rem, 3vw, 1.8rem);
|
||||
font-weight: 300;
|
||||
line-height: 1.05;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.drawer-muted {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.drawer-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
/* ----- Form fields ------------------------------------------------------- */
|
||||
|
||||
.drawer-grid {
|
||||
display: grid;
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.drawer-grid--two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.drawer-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.drawer-field > span {
|
||||
color: var(--theme-text-muted);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.drawer-field input {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0 0.75rem;
|
||||
border: 1px solid var(--theme-control-border);
|
||||
background: var(--theme-control-bg);
|
||||
color: var(--theme-text);
|
||||
font: inherit;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
transition:
|
||||
border-color var(--duration-med) var(--ease-out),
|
||||
background-color var(--duration-med) var(--ease-out);
|
||||
}
|
||||
|
||||
.drawer-field input:hover {
|
||||
border-color: var(--theme-control-border-hover);
|
||||
}
|
||||
|
||||
.drawer-field input:focus {
|
||||
border-color: var(--theme-control-border-focus);
|
||||
outline: 2px solid var(--theme-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ----- Cart -------------------------------------------------------------- */
|
||||
|
||||
.cart-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
margin-top: var(--gap-2xs);
|
||||
}
|
||||
|
||||
.cart-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
padding: clamp(0.85rem, 2vw, 1.1rem);
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-paper);
|
||||
}
|
||||
|
||||
.cart-item-info h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
color: var(--theme-text);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.cart-item-info p {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cart-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 36px 36px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface);
|
||||
}
|
||||
|
||||
.cart-controls button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--theme-text);
|
||||
font-size: var(--text-base);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--duration-med) var(--ease-out),
|
||||
color var(--duration-med) var(--ease-out);
|
||||
}
|
||||
|
||||
.cart-controls button:hover {
|
||||
background: rgba(var(--theme-accent-rgb) / 0.12);
|
||||
color: var(--theme-accent);
|
||||
}
|
||||
|
||||
.cart-controls span {
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* ----- Payment ----------------------------------------------------------- */
|
||||
|
||||
.payment-fieldset {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.payment-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--gap-xs);
|
||||
margin-top: var(--gap-2xs);
|
||||
}
|
||||
|
||||
.payment-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
min-height: 60px;
|
||||
padding: var(--gap-xs);
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-paper);
|
||||
color: var(--theme-text);
|
||||
cursor: pointer;
|
||||
isolation: isolate;
|
||||
transition:
|
||||
border-color var(--duration-med) var(--ease-out),
|
||||
background-color var(--duration-med) var(--ease-out);
|
||||
}
|
||||
|
||||
.payment-card__input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.payment-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--theme-accent);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
transition: transform var(--duration-med) var(--ease-out);
|
||||
}
|
||||
|
||||
.payment-card:hover {
|
||||
border-color: rgba(var(--theme-accent-rgb) / 0.55);
|
||||
}
|
||||
|
||||
.payment-card:focus-within {
|
||||
border-color: var(--theme-control-border-focus);
|
||||
outline: 2px solid var(--theme-focus-ring);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.payment-card.is-active {
|
||||
border-color: var(--theme-accent);
|
||||
background: rgba(var(--theme-accent-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.payment-card.is-active::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.payment-card__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
height: 28px;
|
||||
background: var(--theme-text);
|
||||
color: var(--theme-bg);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.payment-card strong {
|
||||
color: var(--theme-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ----- Totals ------------------------------------------------------------ */
|
||||
|
||||
.drawer-totals {
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.drawer-totals__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
|
||||
.drawer-totals__row span {
|
||||
color: var(--theme-text-muted);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.drawer-totals__row--total {
|
||||
margin-top: var(--gap-xs);
|
||||
padding-top: var(--gap-xs);
|
||||
border-top: 1px solid var(--theme-border);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.drawer-totals__row--total span {
|
||||
color: var(--theme-text);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.drawer-totals__explainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: var(--gap-xs);
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-paper);
|
||||
}
|
||||
|
||||
.drawer-totals__explainer p {
|
||||
margin: 0;
|
||||
color: var(--theme-text);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.drawer-totals__explainer strong {
|
||||
color: var(--theme-accent);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ----- Error ------------------------------------------------------------- */
|
||||
|
||||
.drawer-error {
|
||||
margin: 0;
|
||||
padding: var(--gap-xs);
|
||||
border: 1px solid var(--theme-accent);
|
||||
background: rgba(var(--theme-accent-rgb) / 0.08);
|
||||
color: var(--theme-text);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ----- Profile ----------------------------------------------------------- */
|
||||
|
||||
.drawer-profile-head {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.drawer-profile-head > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.profile-read-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--gap-xs);
|
||||
margin-top: var(--gap-2xs);
|
||||
}
|
||||
|
||||
.drawer-read-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: var(--gap-xs);
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-paper);
|
||||
}
|
||||
|
||||
.drawer-read-block span {
|
||||
color: var(--theme-text-muted);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.drawer-read-block strong {
|
||||
color: var(--theme-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ----- Status box -------------------------------------------------------- */
|
||||
|
||||
.drawer-status-box {
|
||||
padding: var(--gap-xs);
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-paper);
|
||||
color: var(--theme-text);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.drawer-credit-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: var(--gap-2xs);
|
||||
}
|
||||
|
||||
.drawer-credit-list span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-paper);
|
||||
color: var(--theme-text);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ----- Toggles ----------------------------------------------------------- */
|
||||
|
||||
.drawer-toggle-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.drawer-toggle {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
min-height: 64px;
|
||||
padding: var(--gap-xs);
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-paper);
|
||||
color: var(--theme-text);
|
||||
cursor: pointer;
|
||||
isolation: isolate;
|
||||
transition:
|
||||
border-color var(--duration-med) var(--ease-out),
|
||||
background-color var(--duration-med) var(--ease-out);
|
||||
}
|
||||
|
||||
.drawer-toggle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--theme-accent);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
transition: transform var(--duration-med) var(--ease-out);
|
||||
}
|
||||
|
||||
.drawer-toggle:hover {
|
||||
border-color: rgba(var(--theme-accent-rgb) / 0.55);
|
||||
}
|
||||
|
||||
.drawer-toggle.is-active {
|
||||
border-color: var(--theme-accent);
|
||||
background: rgba(var(--theme-accent-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.drawer-toggle.is-active::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.drawer-toggle span {
|
||||
color: var(--theme-text-muted);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.drawer-toggle strong {
|
||||
color: var(--theme-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.drawer-toggle.is-active strong {
|
||||
color: var(--theme-accent);
|
||||
}
|
||||
|
||||
/* ----- Subscriptions ----------------------------------------------------- */
|
||||
|
||||
.drawer-subscription-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
margin-top: var(--gap-sm);
|
||||
padding-top: var(--gap-sm);
|
||||
border-top: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.drawer-subscription-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
padding: var(--gap-xs);
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-paper);
|
||||
}
|
||||
|
||||
.drawer-subscription-row > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.drawer-subscription-row strong {
|
||||
color: var(--theme-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.drawer-subscription-row span {
|
||||
color: var(--theme-text-muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* ----- Requirements ------------------------------------------------------ */
|
||||
|
||||
.drawer-requirements {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-top: var(--gap-2xs);
|
||||
}
|
||||
|
||||
.drawer-requirement {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap-sm);
|
||||
padding: var(--gap-xs);
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-paper);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.drawer-requirement::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--theme-accent);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
transition: transform var(--duration-med) var(--ease-out);
|
||||
}
|
||||
|
||||
.drawer-requirement.is-met::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.drawer-requirement span {
|
||||
color: var(--theme-text-muted);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.drawer-requirement strong {
|
||||
color: var(--theme-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.drawer-requirement.is-met strong {
|
||||
color: var(--theme-accent);
|
||||
}
|
||||
|
||||
/* ----- Orders ------------------------------------------------------------ */
|
||||
|
||||
.drawer-order-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
margin-top: var(--gap-2xs);
|
||||
}
|
||||
|
||||
.drawer-order-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: var(--gap-xs);
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-paper);
|
||||
}
|
||||
|
||||
.drawer-order-card header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.drawer-order-card header span {
|
||||
color: var(--theme-text-muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.drawer-order-card p {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.drawer-order-card__total {
|
||||
align-self: flex-end;
|
||||
color: var(--theme-text);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ----- Cart Toast -------------------------------------------------------- */
|
||||
|
||||
.cart-toast {
|
||||
position: fixed;
|
||||
right: var(--page-x);
|
||||
bottom: var(--page-x);
|
||||
z-index: var(--z-toast);
|
||||
width: min(360px, calc(100vw - (var(--page-x) * 2)));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
padding: clamp(1rem, 2.4vw, 1.25rem);
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface);
|
||||
color: var(--theme-text);
|
||||
box-shadow: var(--theme-shadow);
|
||||
}
|
||||
|
||||
.cart-toast strong {
|
||||
color: var(--theme-text);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cart-toast p {
|
||||
margin: 0 var(--gap-md) 0 0;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cart-toast-close {
|
||||
position: absolute;
|
||||
top: 0.55rem;
|
||||
right: 0.55rem;
|
||||
}
|
||||
|
||||
/* ----- Responsive -------------------------------------------------------- */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shop-drawer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.drawer-grid--two,
|
||||
.profile-read-grid,
|
||||
.drawer-toggle-grid,
|
||||
.payment-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cart-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.drawer-profile-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shop-drawer,
|
||||
.drawer-backdrop,
|
||||
.payment-card::before,
|
||||
.drawer-toggle::before,
|
||||
.drawer-requirement::before {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@ -1,824 +0,0 @@
|
||||
import { useEffect, useId, useRef, 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: "RE", label: "Rechnung" },
|
||||
{ key: "Card", badge: "KA", label: "Karte" },
|
||||
{ key: "Twint", badge: "TW", label: "Twint" },
|
||||
{ key: "PayPal", badge: "PP", label: "PayPal" },
|
||||
];
|
||||
|
||||
const notificationLabels = [
|
||||
["drops_enabled", "Neue Drops"],
|
||||
["restocks_enabled", "Verfügbarkeits-Updates"],
|
||||
["small_batch_enabled", "Small-Batch-Veröffentlichungen"],
|
||||
["discovery_enabled", "Discovery-Set-Updates"],
|
||||
];
|
||||
|
||||
const discoveryStatusLabels = {
|
||||
"No Discount atm": "Noch kein Rabatt",
|
||||
"Discount already used": "Rabatt bereits genutzt",
|
||||
"Discount available": "Rabatt verfügbar",
|
||||
};
|
||||
|
||||
const creditStatusLabels = {
|
||||
available: "verfügbar",
|
||||
used: "genutzt",
|
||||
};
|
||||
|
||||
const formatDiscoveryStatus = (status) => discoveryStatusLabels[status] || status;
|
||||
const formatCreditStatus = (status) => creditStatusLabels[status] || status;
|
||||
|
||||
const focusableSelector = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled]):not([type='hidden'])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
].join(",");
|
||||
|
||||
const getFocusableElements = (container) =>
|
||||
Array.from(container.querySelectorAll(focusableSelector)).filter(
|
||||
(element) =>
|
||||
element instanceof HTMLElement &&
|
||||
element.tabIndex >= 0 &&
|
||||
!element.closest("[inert]") &&
|
||||
element.getClientRects().length > 0
|
||||
);
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = "text",
|
||||
readOnly = false,
|
||||
id,
|
||||
name,
|
||||
autoComplete,
|
||||
inputMode,
|
||||
describedBy,
|
||||
invalid = false,
|
||||
}) {
|
||||
const generatedId = useId();
|
||||
const fallbackName = label
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_|_$/g, "");
|
||||
const fieldName = name || fallbackName;
|
||||
const fieldId = id || `shop-field-${fieldName}-${generatedId}`;
|
||||
|
||||
return (
|
||||
<label className="drawer-field" htmlFor={fieldId}>
|
||||
<span>{label}</span>
|
||||
<input
|
||||
id={fieldId}
|
||||
name={fieldName}
|
||||
type={type}
|
||||
value={value}
|
||||
readOnly={readOnly}
|
||||
autoComplete={autoComplete}
|
||||
inputMode={inputMode}
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid || undefined}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerError({ id, children }) {
|
||||
if (!children) return null;
|
||||
|
||||
return (
|
||||
<p className="drawer-error" id={id} role="alert">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthPanel() {
|
||||
const { busy, error, login, register } = useShop();
|
||||
const [mode, setMode] = useState("login");
|
||||
const errorId = "auth-error";
|
||||
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-stack" onSubmit={submit}>
|
||||
<section className="drawer-section">
|
||||
<span className="drawer-eyebrow">{mode === "login" ? "Anmelden" : "Registrieren"}</span>
|
||||
<h2 className="drawer-heading">
|
||||
{mode === "login" ? "Willkommen zurück." : "Konto erstellen."}
|
||||
</h2>
|
||||
|
||||
<div className="drawer-form">
|
||||
{mode === "register" && (
|
||||
<div className="drawer-grid drawer-grid--two">
|
||||
<Field
|
||||
label="Name"
|
||||
name="first_name"
|
||||
value={form.first_name}
|
||||
autoComplete="given-name"
|
||||
describedBy={error ? errorId : undefined}
|
||||
invalid={!!error}
|
||||
onChange={(value) => update("first_name", value)}
|
||||
/>
|
||||
<Field
|
||||
label="Nachname"
|
||||
name="surname"
|
||||
value={form.surname}
|
||||
autoComplete="family-name"
|
||||
describedBy={error ? errorId : undefined}
|
||||
invalid={!!error}
|
||||
onChange={(value) => update("surname", value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field
|
||||
id="auth-email"
|
||||
label="E-Mail"
|
||||
name="email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
describedBy={error ? errorId : undefined}
|
||||
invalid={!!error}
|
||||
onChange={(value) => update("email", value)}
|
||||
/>
|
||||
<Field
|
||||
id="auth-password"
|
||||
label="Passwort"
|
||||
name="password"
|
||||
type="password"
|
||||
value={form.password}
|
||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||
describedBy={error ? errorId : undefined}
|
||||
invalid={!!error}
|
||||
onChange={(value) => update("password", value)}
|
||||
/>
|
||||
|
||||
<DrawerError id={errorId}>{error}</DrawerError>
|
||||
|
||||
<button
|
||||
className="atmos-btn atmos-btn--primary atmos-btn--block"
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
>
|
||||
{mode === "login" ? "Anmelden" : "Registrieren"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="atmos-btn atmos-btn--ghost atmos-btn--block atmos-btn--sm"
|
||||
type="button"
|
||||
onClick={() => setMode((current) => (current === "login" ? "register" : "login"))}
|
||||
>
|
||||
{mode === "login" ? "Konto erstellen" : "Bestehendes Konto nutzen"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</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 errorId = "cart-error";
|
||||
|
||||
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-eyebrow">Warenkorb</span>
|
||||
{cart.items.length === 0 ? (
|
||||
<p className="drawer-muted">Dein Warenkorb ist leer.</p>
|
||||
) : (
|
||||
<div className="cart-items">
|
||||
{cart.items.map((item) => {
|
||||
const productName = item.product.name;
|
||||
|
||||
return (
|
||||
<article className="cart-item" key={item.product_id}>
|
||||
<div className="cart-item-info">
|
||||
<h3>{productName}</h3>
|
||||
<p>
|
||||
{item.product.size_label} · {formatChf(item.product.price_cents)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="cart-controls"
|
||||
role="group"
|
||||
aria-label={`${productName} Menge: ${item.quantity}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${productName} Menge verringern`}
|
||||
onClick={() => updateCartQuantity(item.product_id, item.quantity - 1)}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span aria-live="polite">{item.quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${productName} Menge erhoehen`}
|
||||
onClick={() => updateCartQuantity(item.product_id, item.quantity + 1)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="atmos-btn atmos-btn--ghost atmos-btn--sm"
|
||||
type="button"
|
||||
aria-label={`${productName} aus dem Warenkorb entfernen`}
|
||||
onClick={() => removeCartItem(item.product_id)}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="drawer-section">
|
||||
<span className="drawer-eyebrow">Versand</span>
|
||||
<div className="drawer-grid drawer-grid--two">
|
||||
<Field
|
||||
label="Strasse"
|
||||
value={address.street_name}
|
||||
describedBy={error ? errorId : undefined}
|
||||
invalid={!!error}
|
||||
onChange={(value) => updateAddress("street_name", value)}
|
||||
/>
|
||||
<Field
|
||||
label="Nr."
|
||||
value={address.house_number}
|
||||
describedBy={error ? errorId : undefined}
|
||||
invalid={!!error}
|
||||
onChange={(value) => updateAddress("house_number", value)}
|
||||
/>
|
||||
<Field
|
||||
label="PLZ"
|
||||
value={address.zip_code}
|
||||
describedBy={error ? errorId : undefined}
|
||||
invalid={!!error}
|
||||
onChange={(value) => updateAddress("zip_code", value)}
|
||||
/>
|
||||
<Field
|
||||
label="Ort"
|
||||
value={address.city}
|
||||
describedBy={error ? errorId : undefined}
|
||||
invalid={!!error}
|
||||
onChange={(value) => updateAddress("city", value)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="drawer-section">
|
||||
<span className="drawer-eyebrow">Zahlung</span>
|
||||
<fieldset
|
||||
className="payment-fieldset"
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
>
|
||||
<legend className="visually-hidden">Zahlungsmethode</legend>
|
||||
<div className="payment-grid">
|
||||
{paymentMethods.map((method) => {
|
||||
const active = paymentMethod === method.key;
|
||||
return (
|
||||
<label
|
||||
className={`payment-card ${active ? "is-active" : ""}`}
|
||||
key={method.key}
|
||||
>
|
||||
<input
|
||||
className="payment-card__input"
|
||||
type="radio"
|
||||
name="payment_method"
|
||||
value={method.key}
|
||||
checked={active}
|
||||
aria-invalid={!!error || undefined}
|
||||
onChange={() => setPaymentMethod(method.key)}
|
||||
/>
|
||||
<span className="payment-card__badge" aria-hidden="true">
|
||||
{method.badge}
|
||||
</span>
|
||||
<strong>{method.label}</strong>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section className="drawer-section drawer-totals">
|
||||
<div className="drawer-totals__row">
|
||||
<span>Zwischensumme</span>
|
||||
<strong>{formatChf(cart.subtotal_cents)}</strong>
|
||||
</div>
|
||||
<div className="drawer-totals__row">
|
||||
<span>Rabatte</span>
|
||||
<strong>−{formatChf(cart.discount_cents)}</strong>
|
||||
</div>
|
||||
{cart.discounts?.length > 0 && (
|
||||
<div className="drawer-totals__explainer">
|
||||
<span className="drawer-eyebrow">Automatisch angewendet</span>
|
||||
{cart.discounts.map((discount) => (
|
||||
<p key={`${discount.type}-${discount.creditId}`}>
|
||||
<strong>{formatChf(discount.amount_cents)}</strong>
|
||||
{" — "}
|
||||
{discount.type === "discovery"
|
||||
? "Discovery-Set-Gutschrift für einen 50-ml-Flakon"
|
||||
: `Proben-Gutschrift für ${discount.slug}`}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="drawer-totals__row drawer-totals__row--total">
|
||||
<span>Gesamt</span>
|
||||
<strong>{formatChf(cart.total_cents)}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DrawerError id={errorId}>{error}</DrawerError>
|
||||
|
||||
<button
|
||||
className="atmos-btn atmos-btn--primary atmos-btn--block atmos-btn--lg"
|
||||
type="submit"
|
||||
disabled={busy || cart.items.length === 0}
|
||||
>
|
||||
Jetzt bezahlen
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function RequirementRow({ label, met, children }) {
|
||||
return (
|
||||
<div className={`drawer-requirement ${met ? "is-met" : ""}`}>
|
||||
<span>{label}</span>
|
||||
<strong>{children || (met ? "Erfüllt" : "Offen")}</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 errorId = "profile-error";
|
||||
|
||||
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 drawer-profile-head">
|
||||
<div>
|
||||
<span className="drawer-eyebrow">Profil</span>
|
||||
<h2 className="drawer-heading">Hallo, {user.first_name}.</h2>
|
||||
</div>
|
||||
<button
|
||||
className="atmos-btn atmos-btn--outline atmos-btn--sm"
|
||||
type="button"
|
||||
onClick={logout}
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="drawer-section">
|
||||
<header className="drawer-section-head">
|
||||
<span className="drawer-eyebrow">Profil-Informationen</span>
|
||||
{!editing && (
|
||||
<button
|
||||
className="atmos-btn atmos-btn--ghost atmos-btn--sm"
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{editing ? (
|
||||
<>
|
||||
<div className="drawer-grid drawer-grid--two">
|
||||
<Field
|
||||
label="Name"
|
||||
value={form.first_name || ""}
|
||||
describedBy={error ? errorId : undefined}
|
||||
invalid={!!error}
|
||||
onChange={(value) => update("first_name", value)}
|
||||
/>
|
||||
<Field
|
||||
label="Nachname"
|
||||
value={form.surname || ""}
|
||||
describedBy={error ? errorId : undefined}
|
||||
invalid={!!error}
|
||||
onChange={(value) => update("surname", value)}
|
||||
/>
|
||||
<Field
|
||||
label="Strasse"
|
||||
value={form.street_name || ""}
|
||||
describedBy={error ? errorId : undefined}
|
||||
invalid={!!error}
|
||||
onChange={(value) => update("street_name", value)}
|
||||
/>
|
||||
<Field
|
||||
label="Nr."
|
||||
value={form.house_number || ""}
|
||||
describedBy={error ? errorId : undefined}
|
||||
invalid={!!error}
|
||||
onChange={(value) => update("house_number", value)}
|
||||
/>
|
||||
<Field
|
||||
label="PLZ"
|
||||
value={form.zip_code || ""}
|
||||
describedBy={error ? errorId : undefined}
|
||||
invalid={!!error}
|
||||
onChange={(value) => update("zip_code", value)}
|
||||
/>
|
||||
<Field
|
||||
label="Ort"
|
||||
value={form.city || ""}
|
||||
describedBy={error ? errorId : undefined}
|
||||
invalid={!!error}
|
||||
onChange={(value) => update("city", value)}
|
||||
/>
|
||||
<Field
|
||||
label="Geburtsdatum"
|
||||
type="date"
|
||||
value={form.birthdate || ""}
|
||||
describedBy={error ? errorId : undefined}
|
||||
invalid={!!error}
|
||||
onChange={(value) => update("birthdate", value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="atmos-btn-row">
|
||||
<button
|
||||
className="atmos-btn atmos-btn--primary"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={save}
|
||||
>
|
||||
Profil speichern
|
||||
</button>
|
||||
<button
|
||||
className="atmos-btn atmos-btn--outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setForm(user);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="profile-read-grid">
|
||||
<ReadBlock label="Name" value={user.first_name} />
|
||||
<ReadBlock label="Nachname" value={user.surname} />
|
||||
<ReadBlock label="Strasse" value={user.street_name || "—"} />
|
||||
<ReadBlock label="Nr." value={user.house_number || "—"} />
|
||||
<ReadBlock label="PLZ" value={user.zip_code || "—"} />
|
||||
<ReadBlock label="Ort" value={user.city || "—"} />
|
||||
<ReadBlock label="Geburtsdatum" value={user.birthdate || "—"} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="drawer-section">
|
||||
<span className="drawer-eyebrow">Rabattstatus</span>
|
||||
<div className="drawer-status-box">{formatDiscoveryStatus(user.discoveryStatus)}</div>
|
||||
{user.sampleCredits?.length > 0 && (
|
||||
<div className="drawer-credit-list">
|
||||
{user.sampleCredits.map((credit) => (
|
||||
<span key={`${credit.slug}-${credit.created_at}`}>
|
||||
{credit.slug}: {formatCreditStatus(credit.status)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="drawer-section">
|
||||
<span className="drawer-eyebrow">Drop- und Verfügbarkeits-Einstellungen</span>
|
||||
<div className="drawer-toggle-grid">
|
||||
{notificationLabels.map(([key, label]) => {
|
||||
const active = !!notifications[key];
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
className={`drawer-toggle ${active ? "is-active" : ""}`}
|
||||
onClick={() => togglePreference(key)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<strong>{active ? "Aktiv" : "Inaktiv"}</strong>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="drawer-subscription-list">
|
||||
<span className="drawer-eyebrow">Abonnierte Restocks</span>
|
||||
{restockSubscriptions.length === 0 ? (
|
||||
<p className="drawer-muted">Noch keine produktbezogenen Restock-Updates.</p>
|
||||
) : (
|
||||
restockSubscriptions.map((subscription) => (
|
||||
<article className="drawer-subscription-row" key={subscription.id}>
|
||||
<div>
|
||||
<strong>{subscription.name}</strong>
|
||||
<span>{subscription.size_label}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="atmos-btn atmos-btn--ghost atmos-btn--sm"
|
||||
onClick={() => removeProductSubscription(subscription.id).catch(() => {})}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="drawer-section">
|
||||
<span className="drawer-eyebrow">Small-Batch-Zugang</span>
|
||||
<div className="drawer-status-box">
|
||||
{loyalty.unlocked ? "Freigeschaltet" : "Noch gesperrt"}
|
||||
</div>
|
||||
<div className="drawer-requirements">
|
||||
<RequirementRow label="Discovery Set" met={loyalty.hasDiscoverySet} />
|
||||
<RequirementRow label="50 ml Flakon" met={loyalty.hasFullSize} />
|
||||
<RequirementRow label="Bestellungen" met={loyalty.purchases >= 3}>
|
||||
{loyalty.purchases}/3
|
||||
</RequirementRow>
|
||||
<RequirementRow label="Umsatz" met={loyalty.spent_cents > 50000}>
|
||||
{formatChf(loyalty.spent_cents)} / CHF 500+
|
||||
</RequirementRow>
|
||||
</div>
|
||||
<Link
|
||||
className="atmos-btn atmos-btn--secondary atmos-btn--block"
|
||||
to="/small-batch"
|
||||
onClick={closePanel}
|
||||
>
|
||||
Small Batch ansehen
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section className="drawer-section">
|
||||
<span className="drawer-eyebrow">Bestellungen</span>
|
||||
{orders.length === 0 ? (
|
||||
<p className="drawer-muted">Noch keine Bestellungen.</p>
|
||||
) : (
|
||||
<div className="drawer-order-list">
|
||||
{orders.map((order) => (
|
||||
<article className="drawer-order-card" key={order.id}>
|
||||
<header>
|
||||
<strong>Bestellung #{order.id}</strong>
|
||||
<span>{new Date(order.created_at).toLocaleDateString("de-CH")}</span>
|
||||
</header>
|
||||
<p>{order.items.map((item) => `${item.quantity} × ${item.product.name}`).join(", ")}</p>
|
||||
<strong className="drawer-order-card__total">{formatChf(order.total_cents)}</strong>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<DrawerError id={errorId}>{error}</DrawerError>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadBlock({ label, value }) {
|
||||
return (
|
||||
<div className="drawer-read-block">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShopDrawer() {
|
||||
const { closePanel, panelOpen, panelType, user } = useShop();
|
||||
const drawerRef = useRef(null);
|
||||
const restoreFocusRef = useRef(null);
|
||||
const wasOpenRef = useRef(false);
|
||||
const drawerTitleId = useId();
|
||||
|
||||
const drawerLabel = !user ? "Konto" : panelType === "cart" ? "Warenkorb" : "Profil";
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return undefined;
|
||||
|
||||
if (!panelOpen) {
|
||||
if (wasOpenRef.current) {
|
||||
const restoreTarget = restoreFocusRef.current;
|
||||
if (restoreTarget && document.contains(restoreTarget)) {
|
||||
window.requestAnimationFrame(() => {
|
||||
restoreTarget.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
wasOpenRef.current = false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
wasOpenRef.current = true;
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
activeElement instanceof HTMLElement &&
|
||||
!drawerRef.current?.contains(activeElement)
|
||||
) {
|
||||
restoreFocusRef.current = activeElement;
|
||||
}
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
const drawer = drawerRef.current;
|
||||
if (!drawer) return;
|
||||
const [firstFocusable] = getFocusableElements(drawer);
|
||||
(firstFocusable || drawer).focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [panelOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panelOpen || typeof document === "undefined") return undefined;
|
||||
|
||||
const drawer = drawerRef.current;
|
||||
if (!drawer) return undefined;
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Tab") return;
|
||||
|
||||
const focusableElements = getFocusableElements(drawer);
|
||||
if (focusableElements.length === 0) {
|
||||
event.preventDefault();
|
||||
drawer.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const firstFocusable = focusableElements[0];
|
||||
const lastFocusable = focusableElements[focusableElements.length - 1];
|
||||
|
||||
if (!drawer.contains(document.activeElement)) {
|
||||
event.preventDefault();
|
||||
firstFocusable.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.shiftKey && document.activeElement === firstFocusable) {
|
||||
event.preventDefault();
|
||||
lastFocusable.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.shiftKey && document.activeElement === lastFocusable) {
|
||||
event.preventDefault();
|
||||
firstFocusable.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [closePanel, panelOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`drawer-backdrop ${panelOpen ? "is-open" : ""}`}
|
||||
aria-hidden="true"
|
||||
onClick={closePanel}
|
||||
/>
|
||||
<aside
|
||||
id="shop-drawer"
|
||||
ref={drawerRef}
|
||||
role="dialog"
|
||||
className={`shop-drawer ${panelOpen ? "is-open" : ""}`}
|
||||
aria-labelledby={drawerTitleId}
|
||||
aria-modal="true"
|
||||
aria-hidden={!panelOpen}
|
||||
inert={!panelOpen ? "" : undefined}
|
||||
tabIndex={-1}
|
||||
data-lenis-prevent
|
||||
>
|
||||
<header className="drawer-top">
|
||||
<span className="drawer-eyebrow" id={drawerTitleId}>
|
||||
{drawerLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="atmos-btn atmos-btn--outline atmos-btn--icon atmos-btn--sm"
|
||||
onClick={closePanel}
|
||||
aria-label="Panel schliessen"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</header>
|
||||
{!user ? <AuthPanel /> : panelType === "cart" ? <CartPanel /> : <ProfilePanel />}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShopDrawer;
|
||||
@ -1,110 +0,0 @@
|
||||
.sticky-buy-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.sticky-buy-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 900;
|
||||
display: block;
|
||||
padding: 0.55rem var(--page-x, 1rem);
|
||||
background: var(--theme-bg);
|
||||
border-top: 1px solid var(--theme-border);
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
transition:
|
||||
transform var(--duration-med, 0.3s) var(--ease-out, ease-out),
|
||||
opacity var(--duration-med, 0.3s) var(--ease-out, ease-out);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sticky-buy-bar.is-visible {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sticky-buy-bar__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap-sm, 0.75rem);
|
||||
max-width: 46rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.sticky-buy-bar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sticky-buy-bar__label {
|
||||
color: var(--theme-text-muted);
|
||||
font-size: var(--text-xs, 0.72rem);
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sticky-buy-bar__price {
|
||||
color: var(--theme-text);
|
||||
font-size: clamp(0.92rem, 3vw, 1.1rem);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- segmented size toggle ---- */
|
||||
|
||||
.sticky-buy-bar__toggle {
|
||||
display: flex;
|
||||
border: 1px solid var(--theme-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sticky-buy-bar__tab {
|
||||
padding: 0.4rem 0.65rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--duration-med, 0.3s) var(--ease-out, ease-out),
|
||||
background-color var(--duration-med, 0.3s) var(--ease-out, ease-out);
|
||||
}
|
||||
|
||||
.sticky-buy-bar__tab + .sticky-buy-bar__tab {
|
||||
border-left: 1px solid var(--theme-border);
|
||||
}
|
||||
|
||||
.sticky-buy-bar__tab.active {
|
||||
color: var(--theme-accent-contrast, #fff);
|
||||
background: var(--theme-accent);
|
||||
}
|
||||
|
||||
/* ---- buy button ---- */
|
||||
|
||||
.sticky-buy-bar .atmos-btn {
|
||||
flex-shrink: 0;
|
||||
min-width: 6.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.sticky-buy-bar.is-always-visible {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||