Compare commits

..

No commits in common. "main" and "herosection" have entirely different histories.

149 changed files with 4273 additions and 11507 deletions

View File

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

View File

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

View File

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

View File

@ -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"
@ -270,21 +269,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 +292,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 +562,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 +589,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 +606,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 +623,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 +640,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 +657,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 +674,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 +691,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 +708,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 +725,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 +742,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 +759,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 +776,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 +793,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 +803,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 +827,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"
],
@ -1779,37 +1774,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",
@ -2377,14 +2341,14 @@
}
},
"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",
"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 +2357,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 +2542,16 @@
}
},
"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",
"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 +2569,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",

View File

@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

View File

@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,29 @@ 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 />
<>
<ScrollToTop />
<a href="#main-content" className="skip-link">
Zum Inhalt springen
</a>
<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>
<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;

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More