Compare commits
4 Commits
3440508bac
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61e05910a9 | ||
|
|
cee5f470ad | ||
| 8a4ec60655 | |||
| e039fdf555 |
BIN
.pnpm-store/v11/index.db
Normal file
BIN
.pnpm-store/v11/index.db
Normal file
Binary file not shown.
@@ -0,0 +1,52 @@
|
||||
---
|
||||
id: TASK-5
|
||||
title: Show the webcam toggle only when a webcam is available
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-05-06 20:11'
|
||||
updated_date: '2026-05-07 05:58'
|
||||
labels:
|
||||
- frontend
|
||||
- fallback
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Detect whether the browser reports an available webcam and only render the hero live-raster switch for visitors who can actually use the camera effect.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 The hero checks browser media devices for at least one video input without prompting for camera permission.
|
||||
- [x] #2 The webcam switch and helper copy are hidden when no video input is reported or media-device enumeration is unavailable.
|
||||
- [x] #3 The switch still starts and stops the existing webcam pixel grid for visitors with an available webcam.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Inspect current hero toggle and WebcamPixelGrid error behavior
|
||||
2. Detect webcam availability with browser media-device enumeration
|
||||
3. Hide the helper copy and switch when no video input is available
|
||||
4. Preserve the existing webcam start/stop flow when a camera exists
|
||||
5. Verify with build and update acceptance criteria
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented a hero fallback raster that activates when WebcamPixelGrid reports unavailable or denied camera access.
|
||||
|
||||
Added an explicit mediaDevices/getUserMedia availability check before requesting camera access.
|
||||
|
||||
Verified with npm run build; Astro built 3 static pages successfully. Dev server is running at http://127.0.0.1:4322/ for manual testing.
|
||||
|
||||
Changed direction after feedback: removed the visual fallback and now hide the switch unless enumerateDevices reports a videoinput.
|
||||
|
||||
Added a devicechange listener so the switch can appear or disappear if camera hardware availability changes during the session.
|
||||
|
||||
Verified the revised behavior compiles with npm run build.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
id: TASK-6
|
||||
title: Modularisiere die Landingpage und entferne ungenutzte Komponenten
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-05-07 06:21'
|
||||
updated_date: '2026-05-07 06:23'
|
||||
labels:
|
||||
- refactor
|
||||
- frontend
|
||||
dependencies: []
|
||||
modified_files:
|
||||
- src/pages/index.astro
|
||||
- tests/landing-content.test.mjs
|
||||
- src/components/landing-page-sections.tsx
|
||||
- src/components/landing/services-section.tsx
|
||||
- src/components/landing/deliverables-section.tsx
|
||||
- src/components/landing/packages-section.tsx
|
||||
- src/components/landing/contact-section.tsx
|
||||
- src/components/landing.tsx
|
||||
- src/components/about19.tsx
|
||||
- src/components/contact21.tsx
|
||||
- src/components/cta.tsx
|
||||
- src/components/faq7.tsx
|
||||
- src/components/feature284.tsx
|
||||
- src/components/hero235.tsx
|
||||
- src/components/pricing4.tsx
|
||||
- src/components/stats11.tsx
|
||||
- src/components/ui/accordion.tsx
|
||||
- src/components/ui/badge.tsx
|
||||
- src/components/ui/button.tsx
|
||||
- src/components/ui/field.tsx
|
||||
- src/components/ui/glowing-effect.tsx
|
||||
- src/components/ui/input.tsx
|
||||
- src/components/ui/label.tsx
|
||||
- src/components/ui/separator.tsx
|
||||
- src/components/ui/tabs.tsx
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Die bestehende Landingpage soll in klarere, kleinere Komponenten aufgeteilt werden. Nicht mehr referenzierte Komponenten sollen entfernt werden, ohne vorhandene Nutzer- oder laufende Änderungen zurückzudrehen.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Die Hauptseite nutzt klar benannte modulare Komponenten statt einer monolithischen LandingRest-Komponente.
|
||||
- [x] #2 Nicht benötigte Komponenten im Komponentenordner sind entfernt oder nicht mehr Teil der Codebasis.
|
||||
- [x] #3 Build und vorhandene Tests laufen nach der Änderung ohne Fehler.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Bestehende Landingpage-Struktur und Import-Verwendung prüfen.
|
||||
2. LandingRest in klar benannte Sektionen extrahieren und Index-Import aktualisieren.
|
||||
3. Nicht referenzierte Template- und UI-Komponenten entfernen.
|
||||
4. Tests an neue Struktur anpassen und Build/Test ausführen.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
LandingRest wurde in ServicesSection, DeliverablesSection, PackagesSection und ContactSection extrahiert; index.astro nutzt nun LandingPageSections.
|
||||
|
||||
Nicht referenzierte Template-Komponenten sowie deren ungenutzte UI-Hilfskomponenten wurden entfernt. Vorbestehende Änderungen an landing-hero-section.tsx und ui/webcam-pixel-grid.tsx blieben unangetastet.
|
||||
|
||||
Verifikation: node --test tests/*.mjs und pnpm build laufen erfolgreich.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -28,6 +28,7 @@
|
||||
"Authorization": "Bearer ${SHADCNBLOCKS_API_KEY}"
|
||||
}
|
||||
},
|
||||
"@aceternity": "https://ui.aceternity.com/registry/{name}.json"
|
||||
"@aceternity": "https://ui.aceternity.com/registry/{name}.json",
|
||||
"@mapcn": "https://mapcn.dev/r/{name}.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"motion": "^12.38.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
|
||||
190
pnpm-lock.yaml
generated
190
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.0(react@19.2.5)
|
||||
maplibre-gl:
|
||||
specifier: ^5.24.0
|
||||
version: 5.24.0
|
||||
motion:
|
||||
specifier: ^12.38.0
|
||||
version: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
@@ -657,6 +660,42 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@mapbox/jsonlint-lines-primitives@2.0.2':
|
||||
resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
'@mapbox/point-geometry@1.1.0':
|
||||
resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==}
|
||||
|
||||
'@mapbox/tiny-sdf@2.2.0':
|
||||
resolution: {integrity: sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==}
|
||||
|
||||
'@mapbox/unitbezier@0.0.1':
|
||||
resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==}
|
||||
|
||||
'@mapbox/vector-tile@2.0.4':
|
||||
resolution: {integrity: sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==}
|
||||
|
||||
'@mapbox/whoots-js@3.1.0':
|
||||
resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@maplibre/geojson-vt@5.0.4':
|
||||
resolution: {integrity: sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==}
|
||||
|
||||
'@maplibre/geojson-vt@6.1.0':
|
||||
resolution: {integrity: sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==}
|
||||
|
||||
'@maplibre/maplibre-gl-style-spec@24.8.5':
|
||||
resolution: {integrity: sha512-EzEJmMt6thioRH7GI9LWS7ahXTcAhAPGWCe6oTP2Ps4YnsXOOAfeqx854lZaiDnwURfHmcCKV1mr6oo0i23x6w==}
|
||||
hasBin: true
|
||||
|
||||
'@maplibre/mlt@1.1.9':
|
||||
resolution: {integrity: sha512-g/tD8EYJB97udq33ipuJ9a4Q7fcbZnTEnUrgnEc/tLMmEL+zaCbR+X5fkDBO2dgpaAMsLH179qE3UXg2N0Nc/g==}
|
||||
|
||||
'@maplibre/vt-pbf@4.3.0':
|
||||
resolution: {integrity: sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.29.0':
|
||||
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1706,6 +1745,9 @@ packages:
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/geojson@7946.0.16':
|
||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||
|
||||
@@ -1735,6 +1777,9 @@ packages:
|
||||
'@types/statuses@2.0.6':
|
||||
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
||||
|
||||
'@types/supercluster@7.1.3':
|
||||
resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==}
|
||||
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
@@ -1743,6 +1788,7 @@ packages:
|
||||
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
|
||||
|
||||
'@vitejs/plugin-react@5.2.0':
|
||||
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
|
||||
@@ -2111,6 +2157,9 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
earcut@3.0.2:
|
||||
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==}
|
||||
|
||||
eciesjs@0.4.18:
|
||||
resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==}
|
||||
engines: {bun: '>=1', deno: '>=2', node: '>=16'}
|
||||
@@ -2372,6 +2421,9 @@ packages:
|
||||
github-slugger@2.0.0:
|
||||
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
|
||||
|
||||
gl-matrix@3.4.4:
|
||||
resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -2601,6 +2653,9 @@ packages:
|
||||
json-schema-typed@8.0.2:
|
||||
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
|
||||
|
||||
json-stringify-pretty-compact@4.0.0:
|
||||
resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==}
|
||||
|
||||
json5@2.2.3:
|
||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2609,6 +2664,9 @@ packages:
|
||||
jsonfile@6.2.1:
|
||||
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
|
||||
|
||||
kdbush@4.0.2:
|
||||
resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==}
|
||||
|
||||
kleur@3.0.3:
|
||||
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2719,6 +2777,10 @@ packages:
|
||||
magicast@0.5.2:
|
||||
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
|
||||
|
||||
maplibre-gl@5.24.0:
|
||||
resolution: {integrity: sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==}
|
||||
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
|
||||
|
||||
markdown-table@3.0.4:
|
||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||
|
||||
@@ -2934,6 +2996,9 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
murmurhash-js@1.0.0:
|
||||
resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==}
|
||||
|
||||
mute-stream@3.0.0:
|
||||
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
@@ -3094,6 +3159,10 @@ packages:
|
||||
path-to-regexp@8.4.2:
|
||||
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
|
||||
|
||||
pbf@4.0.1:
|
||||
resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==}
|
||||
hasBin: true
|
||||
|
||||
piccolore@0.1.3:
|
||||
resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==}
|
||||
|
||||
@@ -3120,6 +3189,9 @@ packages:
|
||||
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
potpack@2.1.0:
|
||||
resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==}
|
||||
|
||||
powershell-utils@0.1.0:
|
||||
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -3139,6 +3211,9 @@ packages:
|
||||
property-information@7.1.0:
|
||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||
|
||||
protocol-buffers-schema@3.6.1:
|
||||
resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -3150,6 +3225,9 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
quickselect@3.0.0:
|
||||
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
|
||||
|
||||
radix-ui@1.4.3:
|
||||
resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==}
|
||||
peerDependencies:
|
||||
@@ -3280,6 +3358,9 @@ packages:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
resolve-protobuf-schema@2.1.0:
|
||||
resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==}
|
||||
|
||||
restore-cursor@5.1.0:
|
||||
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3459,6 +3540,9 @@ packages:
|
||||
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
supercluster@8.0.1:
|
||||
resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==}
|
||||
|
||||
svgo@4.0.1:
|
||||
resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -3496,6 +3580,9 @@ packages:
|
||||
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinyqueue@3.0.0:
|
||||
resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==}
|
||||
|
||||
tldts-core@7.0.28:
|
||||
resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==}
|
||||
|
||||
@@ -4411,6 +4498,51 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@mapbox/jsonlint-lines-primitives@2.0.2': {}
|
||||
|
||||
'@mapbox/point-geometry@1.1.0': {}
|
||||
|
||||
'@mapbox/tiny-sdf@2.2.0': {}
|
||||
|
||||
'@mapbox/unitbezier@0.0.1': {}
|
||||
|
||||
'@mapbox/vector-tile@2.0.4':
|
||||
dependencies:
|
||||
'@mapbox/point-geometry': 1.1.0
|
||||
'@types/geojson': 7946.0.16
|
||||
pbf: 4.0.1
|
||||
|
||||
'@mapbox/whoots-js@3.1.0': {}
|
||||
|
||||
'@maplibre/geojson-vt@5.0.4': {}
|
||||
|
||||
'@maplibre/geojson-vt@6.1.0':
|
||||
dependencies:
|
||||
kdbush: 4.0.2
|
||||
|
||||
'@maplibre/maplibre-gl-style-spec@24.8.5':
|
||||
dependencies:
|
||||
'@mapbox/jsonlint-lines-primitives': 2.0.2
|
||||
'@mapbox/unitbezier': 0.0.1
|
||||
json-stringify-pretty-compact: 4.0.0
|
||||
minimist: 1.2.8
|
||||
quickselect: 3.0.0
|
||||
tinyqueue: 3.0.0
|
||||
|
||||
'@maplibre/mlt@1.1.9':
|
||||
dependencies:
|
||||
'@mapbox/point-geometry': 1.1.0
|
||||
|
||||
'@maplibre/vt-pbf@4.3.0':
|
||||
dependencies:
|
||||
'@mapbox/point-geometry': 1.1.0
|
||||
'@mapbox/vector-tile': 2.0.4
|
||||
'@maplibre/geojson-vt': 5.0.4
|
||||
'@types/geojson': 7946.0.16
|
||||
'@types/supercluster': 7.1.3
|
||||
pbf: 4.0.1
|
||||
supercluster: 8.0.1
|
||||
|
||||
'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.14(hono@4.12.14)
|
||||
@@ -5454,6 +5586,8 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/geojson@7946.0.16': {}
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
@@ -5486,6 +5620,10 @@ snapshots:
|
||||
|
||||
'@types/statuses@2.0.6': {}
|
||||
|
||||
'@types/supercluster@7.1.3':
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.16
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/validate-npm-package-name@4.0.2': {}
|
||||
@@ -5891,6 +6029,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
earcut@3.0.2: {}
|
||||
|
||||
eciesjs@0.4.18:
|
||||
dependencies:
|
||||
'@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0)
|
||||
@@ -6190,6 +6330,8 @@ snapshots:
|
||||
|
||||
github-slugger@2.0.0: {}
|
||||
|
||||
gl-matrix@3.4.4: {}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
@@ -6424,6 +6566,8 @@ snapshots:
|
||||
|
||||
json-schema-typed@8.0.2: {}
|
||||
|
||||
json-stringify-pretty-compact@4.0.0: {}
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonfile@6.2.1:
|
||||
@@ -6432,6 +6576,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
kdbush@4.0.2: {}
|
||||
|
||||
kleur@3.0.3: {}
|
||||
|
||||
kleur@4.1.5: {}
|
||||
@@ -6514,6 +6660,28 @@ snapshots:
|
||||
'@babel/types': 7.29.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
maplibre-gl@5.24.0:
|
||||
dependencies:
|
||||
'@mapbox/jsonlint-lines-primitives': 2.0.2
|
||||
'@mapbox/point-geometry': 1.1.0
|
||||
'@mapbox/tiny-sdf': 2.2.0
|
||||
'@mapbox/unitbezier': 0.0.1
|
||||
'@mapbox/vector-tile': 2.0.4
|
||||
'@mapbox/whoots-js': 3.1.0
|
||||
'@maplibre/geojson-vt': 6.1.0
|
||||
'@maplibre/maplibre-gl-style-spec': 24.8.5
|
||||
'@maplibre/mlt': 1.1.9
|
||||
'@maplibre/vt-pbf': 4.3.0
|
||||
'@types/geojson': 7946.0.16
|
||||
earcut: 3.0.2
|
||||
gl-matrix: 3.4.4
|
||||
kdbush: 4.0.2
|
||||
murmurhash-js: 1.0.0
|
||||
pbf: 4.0.1
|
||||
potpack: 2.1.0
|
||||
quickselect: 3.0.0
|
||||
tinyqueue: 3.0.0
|
||||
|
||||
markdown-table@3.0.4: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
@@ -6903,6 +7071,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
|
||||
murmurhash-js@1.0.0: {}
|
||||
|
||||
mute-stream@3.0.0: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
@@ -7058,6 +7228,10 @@ snapshots:
|
||||
|
||||
path-to-regexp@8.4.2: {}
|
||||
|
||||
pbf@4.0.1:
|
||||
dependencies:
|
||||
resolve-protobuf-schema: 2.1.0
|
||||
|
||||
piccolore@0.1.3: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
@@ -7079,6 +7253,8 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
potpack@2.1.0: {}
|
||||
|
||||
powershell-utils@0.1.0: {}
|
||||
|
||||
pretty-ms@9.3.0:
|
||||
@@ -7094,6 +7270,8 @@ snapshots:
|
||||
|
||||
property-information@7.1.0: {}
|
||||
|
||||
protocol-buffers-schema@3.6.1: {}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
@@ -7105,6 +7283,8 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
quickselect@3.0.0: {}
|
||||
|
||||
radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
@@ -7311,6 +7491,10 @@ snapshots:
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-protobuf-schema@2.1.0:
|
||||
dependencies:
|
||||
protocol-buffers-schema: 3.6.1
|
||||
|
||||
restore-cursor@5.1.0:
|
||||
dependencies:
|
||||
onetime: 7.0.0
|
||||
@@ -7608,6 +7792,10 @@ snapshots:
|
||||
|
||||
strip-final-newline@4.0.0: {}
|
||||
|
||||
supercluster@8.0.1:
|
||||
dependencies:
|
||||
kdbush: 4.0.2
|
||||
|
||||
svgo@4.0.1:
|
||||
dependencies:
|
||||
commander: 11.1.0
|
||||
@@ -7639,6 +7827,8 @@ snapshots:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
tinyqueue@3.0.0: {}
|
||||
|
||||
tldts-core@7.0.28: {}
|
||||
|
||||
tldts@7.0.28:
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface About19Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const About19 = ({ className }: About19Props) => {
|
||||
return (
|
||||
<section className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.88fr)] lg:items-start lg:gap-14">
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<img
|
||||
src="/about.jpg"
|
||||
alt="Matthias Meister bei der Webentwicklung"
|
||||
className="h-[20rem] w-full object-cover sm:h-[28rem] lg:h-[34rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xl">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Über die Zusammenarbeit
|
||||
</p>
|
||||
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
||||
Hallo, ich bin Matthias. Zurück in der Region und hier, um zu
|
||||
bleiben.
|
||||
</h2>
|
||||
<div className="mt-8 space-y-5 text-base leading-8 text-muted-foreground">
|
||||
<p>
|
||||
Ich bin in der Region aufgewachsen, war durch die Bundeswehr
|
||||
viele Jahre weg und bin jetzt zurück.
|
||||
</p>
|
||||
<p>
|
||||
Seit über 15 Jahren beschäftige ich mich mit Webentwicklung und
|
||||
Software. Einen Großteil davon intern für die Bundeswehr:
|
||||
Projekte, die ich Ihnen leider nicht zeigen kann. Was ich Ihnen
|
||||
zeigen kann: wie ich arbeite. Zuverlässig, präzise und ohne
|
||||
unnötigen Schnickschnack.
|
||||
</p>
|
||||
<p>
|
||||
Neben Websites für regionale Unternehmen entwickle ich eigene
|
||||
Software und Apps. Wenn Ihre Anforderungen irgendwann über eine
|
||||
einfache Website hinausgehen, bleibt der Ansprechpartner also
|
||||
derselbe.
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-8 rounded-lg border border-border bg-card p-5 text-base font-medium leading-7 text-foreground">
|
||||
Mein Ziel: Unternehmen aus der Region mit einer Website
|
||||
ausstatten, die funktioniert, gefunden wird und Ihnen keine
|
||||
Kopfschmerzen macht.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { About19 };
|
||||
@@ -1,219 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CornerDownRight, LoaderIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const contactFormSchema = z.object({
|
||||
name: z.string().min(1, "Bitte geben Sie Ihren Namen ein"),
|
||||
email: z
|
||||
.string()
|
||||
.min(1, "Bitte geben Sie Ihre E-Mail ein")
|
||||
.email("Bitte geben Sie eine gültige E-Mail ein"),
|
||||
message: z.string().min(1, "Bitte beschreiben Sie kurz Ihr Anliegen"),
|
||||
});
|
||||
|
||||
type ContactFormData = z.infer<typeof contactFormSchema>;
|
||||
|
||||
interface Contact21Props {
|
||||
className?: string;
|
||||
onSubmit?: (data: ContactFormData) => Promise<void>;
|
||||
}
|
||||
|
||||
const Contact21 = ({ className, onSubmit }: Contact21Props) => {
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
const form = useForm<ContactFormData>({
|
||||
resolver: zodResolver(contactFormSchema),
|
||||
mode: "onSubmit",
|
||||
reValidateMode: "onSubmit",
|
||||
defaultValues: {
|
||||
name: "",
|
||||
email: "",
|
||||
message: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleFormSubmit = async (data: ContactFormData) => {
|
||||
try {
|
||||
if (onSubmit) {
|
||||
await onSubmit(data);
|
||||
} else {
|
||||
console.log("Form submitted:", data);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
setIsSubmitted(true);
|
||||
setShowSuccess(true);
|
||||
form.reset();
|
||||
setTimeout(() => setShowSuccess(false), 4500);
|
||||
setTimeout(() => setIsSubmitted(false), 5000);
|
||||
} catch {
|
||||
form.setError("root", {
|
||||
message: "Beim Senden ist etwas schiefgelaufen. Bitte versuchen Sie es erneut.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id="kontakt"
|
||||
className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}
|
||||
>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="grid gap-10 rounded-lg border border-border bg-card p-5 sm:p-8 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)] lg:gap-14 lg:p-10">
|
||||
<div className="flex w-full max-w-lg flex-col justify-between gap-10">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Kontakt
|
||||
</p>
|
||||
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
||||
Erzählen Sie kurz, worum es geht.
|
||||
</h2>
|
||||
<p className="mt-5 text-base leading-7 text-muted-foreground">
|
||||
Ich melde mich innerhalb von 24 Stunden mit einer ersten
|
||||
Einschätzung und dem passenden nächsten Schritt.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground">
|
||||
MM
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium tracking-tight">
|
||||
Matthias Meister
|
||||
</h3>
|
||||
<p className="text-sm text-foreground/40">
|
||||
Freelance Webdesigner
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
{isSubmitted && (
|
||||
<div
|
||||
className={cn(
|
||||
"mb-4 rounded-lg border border-primary/20 bg-primary/10 p-4 text-center transition-opacity duration-500",
|
||||
showSuccess ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium text-primary">
|
||||
Vielen Dank! Ich melde mich in Kürze bei Ihnen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={form.handleSubmit(handleFormSubmit)}>
|
||||
<FieldGroup className="gap-0">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor={field.name} className="sr-only">
|
||||
Name
|
||||
</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id={field.name}
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="Ihr Name*"
|
||||
className="h-14 rounded-none border-0 border-b border-b-border bg-transparent! px-0 shadow-none placeholder:text-muted-foreground/65 focus-visible:border-b-primary focus-visible:ring-0 lg:text-base"
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor={field.name} className="sr-only">
|
||||
E-Mail
|
||||
</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id={field.name}
|
||||
type="email"
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="Ihre E-Mail*"
|
||||
className="h-14 rounded-none border-0 border-b border-b-border bg-transparent! px-0 shadow-none placeholder:text-muted-foreground/65 focus-visible:border-b-primary focus-visible:ring-0 lg:text-base"
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="message"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor={field.name} className="sr-only">
|
||||
Nachricht
|
||||
</FieldLabel>
|
||||
<textarea
|
||||
{...field}
|
||||
id={field.name}
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="Nachricht: Worum geht es bei Ihrem Projekt?"
|
||||
rows={4}
|
||||
className="min-h-36 w-full rounded-none border-0 border-b border-b-border bg-transparent px-0 py-4 text-base text-foreground shadow-none outline-none placeholder:text-muted-foreground/65 focus-visible:border-b-primary focus-visible:ring-0 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive lg:text-base"
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.formState.errors.root && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.root.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="mt-8 flex h-11 w-full items-center justify-center gap-2 rounded-md px-6 lg:w-fit lg:text-base"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<>
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
Wird gesendet...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CornerDownRight className="size-5" />
|
||||
Anfrage senden
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Contact21 };
|
||||
@@ -1,68 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const trustAnchors = [
|
||||
{
|
||||
title: "Direkter Kontakt",
|
||||
description:
|
||||
"Sie sprechen mit dem Menschen, der die Website auch plant und baut.",
|
||||
note: "Keine Vertriebsrunde, keine unklaren Übergänge.",
|
||||
},
|
||||
{
|
||||
title: "15+ Jahre Erfahrung",
|
||||
description:
|
||||
"Webentwicklung und Software mit Fokus auf robuste, wartbare Lösungen.",
|
||||
note: "Praxis statt Buzzwords und Technik nur dort, wo sie wirklich hilft.",
|
||||
},
|
||||
{
|
||||
title: "Hosting in Sachsen",
|
||||
description:
|
||||
"Deutsche Server, DSGVO-konform und passend für regionale Unternehmen.",
|
||||
note: "Greifbar, nachvollziehbar und ohne unnötiges Zusatztheater.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CTASection() {
|
||||
return (
|
||||
<section className="px-4 pb-14 sm:px-6 lg:px-8 lg:pb-20">
|
||||
<div className="mx-auto max-w-6xl border-y border-border/80 py-9 lg:grid lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.55fr)] lg:gap-14 lg:py-11">
|
||||
<div className="max-w-md space-y-4 lg:pt-1">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Vor dem Angebot
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-balance lg:text-3xl">
|
||||
Erst verstehen, dann bauen.
|
||||
</h2>
|
||||
<p className="text-base leading-7 text-muted-foreground">
|
||||
Die Zusammenarbeit ist bewusst direkt gehalten: ein Gespräch, eine
|
||||
klare Empfehlung und ein Vorschlag, der zu Ihrem Betrieb passt.
|
||||
</p>
|
||||
</div>
|
||||
<dl className="mt-8 grid gap-6 sm:grid-cols-3 lg:mt-0 lg:gap-0">
|
||||
{trustAnchors.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className={cn(
|
||||
"space-y-3",
|
||||
index === 0
|
||||
? ""
|
||||
: "border-t border-border/70 pt-5 sm:border-t-0 sm:border-l sm:pl-6 sm:pt-0 lg:pl-8",
|
||||
)}
|
||||
>
|
||||
<dt className="text-sm font-semibold text-foreground">
|
||||
{item.title}
|
||||
</dt>
|
||||
<dd className="space-y-2">
|
||||
<p className="text-base font-medium leading-7 text-balance text-foreground lg:text-lg">
|
||||
{item.description}
|
||||
</p>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{item.note}
|
||||
</p>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: "Wie lange dauert es bis meine Website fertig ist?",
|
||||
answer:
|
||||
"In der Regel ist Ihre Website innerhalb von zwei Wochen fertig — vom ersten Gespräch bis zum Go-Live. Nach der Entwicklung bekommen Sie einen Vorschau-Link, damit Sie alles in Ruhe prüfen können. Erst wenn Sie zufrieden sind, geht die Seite online.",
|
||||
},
|
||||
{
|
||||
question: "Was passiert wenn ich das Hosting kündige?",
|
||||
answer:
|
||||
"Ihre Website und Ihre Domain gehören Ihnen — immer. Wenn Sie das Hosting kündigen, übertrage ich Ihnen alles ohne Wenn und Aber. Keine versteckten Abhängigkeiten, das ist vertraglich festgehalten.",
|
||||
},
|
||||
{
|
||||
question: "Ich habe schon eine Domain — was passiert damit?",
|
||||
answer:
|
||||
"Kein Problem. Wir zeigen Ihre bestehende Domain einfach auf die neue Website um. Falls Sie möchten, kann ich die Domain auch zu mir umziehen — das macht die Verwaltung einfacher, ist aber kein Muss.",
|
||||
},
|
||||
{
|
||||
question: "Brauche ich technisches Wissen?",
|
||||
answer:
|
||||
"Keins. Sie kümmern Sie um Ihr Geschäft, ich um alles Technische. Von der Domain über die E-Mails bis zu Updates — das liegt bei mir.",
|
||||
},
|
||||
{
|
||||
question: "Kümmern Sie sich auch um Impressum und Datenschutz?",
|
||||
answer:
|
||||
"Ja, jede Website die ich baue kommt mit einem rechtssicheren Impressum und einer DSGVO-konformen Datenschutzerklärung — inklusive übersichtlichem Cookie-Hinweis, wo nötig, statt Chaos und unnötigen Tracking-Dialogen.",
|
||||
},
|
||||
];
|
||||
|
||||
interface Faq7Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Faq7 = ({ className }: Faq7Props) => {
|
||||
return (
|
||||
<section className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="grid gap-10 border-t border-border/80 pt-10 md:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:gap-16">
|
||||
<div className="flex max-w-md flex-col gap-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Häufige Fragen
|
||||
</p>
|
||||
<h2 className="text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
||||
Vor dem Start soll nichts schwammig bleiben.
|
||||
</h2>
|
||||
<p className="text-base leading-7 text-muted-foreground">
|
||||
Falls noch etwas offen ist, schreiben Sie mir gern über das
|
||||
<a
|
||||
href="#kontakt"
|
||||
className="mx-1 whitespace-nowrap underline underline-offset-4 transition-colors hover:text-foreground"
|
||||
>
|
||||
Kontaktformular
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<Button asChild size="lg" variant="outline" className="w-fit rounded-md">
|
||||
<a href="#kontakt">Frage stellen</a>
|
||||
</Button>
|
||||
</div>
|
||||
<Accordion type="multiple" className="rounded-lg border border-border bg-card px-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<AccordionItem key={index} value={`item-${index}`}>
|
||||
<AccordionTrigger className="text-left text-base font-semibold">
|
||||
{faq.question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground">
|
||||
{faq.answer}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Faq7 };
|
||||
@@ -1,93 +0,0 @@
|
||||
import {
|
||||
Gauge,
|
||||
Handshake,
|
||||
MapPinned,
|
||||
Search,
|
||||
Smartphone,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const featureData = [
|
||||
{
|
||||
desc: "Die Startseite sagt schnell, für wen Sie arbeiten, was Sie anbieten und wie Interessenten Kontakt aufnehmen.",
|
||||
title: "Klare Positionierung",
|
||||
badgeTitle: "01",
|
||||
icon: MapPinned,
|
||||
},
|
||||
{
|
||||
desc: "Gestaltung, Texte und Struktur wirken seriös, ohne den Charakter eines regionalen Betriebs glattzubügeln.",
|
||||
title: "Glaubwürdiger Auftritt",
|
||||
badgeTitle: "02",
|
||||
icon: Handshake,
|
||||
},
|
||||
{
|
||||
desc: "Telefonnummer, Formular und zentrale Informationen bleiben auf Smartphone und Desktop leicht erreichbar.",
|
||||
title: "Mobil sauber geführt",
|
||||
badgeTitle: "03",
|
||||
icon: Smartphone,
|
||||
},
|
||||
{
|
||||
desc: "Technik, Bilder und Inhalte werden so umgesetzt, dass die Seite schnell lädt und stabil bleibt.",
|
||||
title: "Schnell und robust",
|
||||
badgeTitle: "04",
|
||||
icon: Gauge,
|
||||
},
|
||||
{
|
||||
desc: "Google findet die richtigen Inhalte: Leistungen, Region, Kontakt und die wichtigsten Suchbegriffe.",
|
||||
title: "Für Suche vorbereitet",
|
||||
badgeTitle: "05",
|
||||
icon: Search,
|
||||
},
|
||||
];
|
||||
|
||||
interface Feature284Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Feature284 = ({ className }: Feature284Props) => {
|
||||
return (
|
||||
<section className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="grid gap-8 border-t border-border/80 pt-10 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.4fr)] lg:gap-16">
|
||||
<div className="max-w-md">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Was die Seite leisten muss
|
||||
</p>
|
||||
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
||||
Professionell heißt hier: verständlich, erreichbar, belastbar.
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{featureData.map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group flex min-h-52 flex-col justify-between rounded-lg border border-border bg-card p-5 transition-colors hover:border-primary/40"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{feature.badgeTitle}
|
||||
</p>
|
||||
<feature.icon
|
||||
className="size-5 text-primary transition-transform group-hover:-translate-y-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-10 space-y-3">
|
||||
<h3 className="text-xl font-semibold tracking-tight">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{feature.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Feature284 };
|
||||
@@ -1,116 +0,0 @@
|
||||
import { ArrowRight, Mail, MapPin, Phone } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Hero235Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Hero235 = ({ className }: Hero235Props) => {
|
||||
return (
|
||||
<section className={cn("px-4 pt-5 sm:px-6 lg:px-8", className)}>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<header className="flex flex-col gap-4 border-b border-border/80 pb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<a
|
||||
href="/"
|
||||
className="text-sm font-semibold tracking-tight text-foreground"
|
||||
>
|
||||
Matthias Meister Webdesign
|
||||
</a>
|
||||
<nav
|
||||
aria-label="Direkte Kontaktwege"
|
||||
className="flex flex-wrap items-center gap-x-5 gap-y-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<a
|
||||
href="tel:037627984400"
|
||||
className="inline-flex items-center gap-1.5 transition-colors hover:text-foreground"
|
||||
>
|
||||
<Phone className="size-3.5" aria-hidden />
|
||||
03762 798 4400
|
||||
</a>
|
||||
<a
|
||||
href="mailto:info@matthias-meister-webdesign.de"
|
||||
className="inline-flex items-center gap-1.5 transition-colors hover:text-foreground"
|
||||
>
|
||||
<Mail className="size-3.5" aria-hidden />
|
||||
E-Mail
|
||||
</a>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MapPin className="size-3.5" aria-hidden />
|
||||
Crimmitschau
|
||||
</span>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-10 py-16 sm:py-20 lg:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)] lg:items-end lg:py-24">
|
||||
<div className="flex max-w-3xl flex-col gap-7">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Webdesign für regionale KMU
|
||||
</p>
|
||||
<h1 className="max-w-[12ch] text-5xl font-semibold leading-[0.95] tracking-tight text-balance text-foreground sm:text-6xl lg:text-7xl">
|
||||
Websites, die vor Ort Vertrauen schaffen.
|
||||
</h1>
|
||||
<p className="max-w-[62ch] text-lg leading-8 text-muted-foreground">
|
||||
Für Handwerk, Praxen und kleine Betriebe: klar erklärt, schnell
|
||||
gebaut und so strukturiert, dass Besucher ohne Umwege verstehen,
|
||||
warum sie gerade Sie anfragen sollten.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 pt-1 sm:flex-row sm:items-center">
|
||||
<Button asChild size="lg" className="h-11 rounded-md px-5">
|
||||
<a href="#kontakt">
|
||||
Projekt anfragen
|
||||
<ArrowRight className="shrink-0" aria-hidden />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="h-11 rounded-md px-5"
|
||||
>
|
||||
<a href="#preise">Pakete ansehen</a>
|
||||
</Button>
|
||||
</div>
|
||||
<dl className="grid gap-4 border-t border-border/80 pt-6 sm:grid-cols-3">
|
||||
{[
|
||||
["24h", "Rückmeldung"],
|
||||
["2 Wochen", "typischer Go-Live"],
|
||||
["Sachsen", "Hosting & Betrieb"],
|
||||
].map(([value, label]) => (
|
||||
<div key={label}>
|
||||
<dt className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
{value}
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-muted-foreground">
|
||||
{label}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<figure className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
<img
|
||||
src="/about.jpg"
|
||||
alt="Arbeitsplatz von Matthias Meister beim Entwickeln einer Website"
|
||||
className="h-[19rem] w-full object-cover sm:h-[24rem] lg:h-[31rem]"
|
||||
/>
|
||||
<figcaption className="grid gap-2 border-t border-border bg-card/95 p-5 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Direkt mit dem Entwickler statt mit wechselnden Agenturrollen.
|
||||
</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Persönlich geplant
|
||||
</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Hero235 };
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WebcamPixelGrid } from "@/components/ui/webcam-pixel-grid";
|
||||
@@ -11,6 +11,47 @@ const PRIMARY_HERO_BG = "#B54440";
|
||||
|
||||
const LandingHeroSection = () => {
|
||||
const [liveRasterOn, setLiveRasterOn] = useState(false);
|
||||
const [hasWebcam, setHasWebcam] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaDevices = navigator.mediaDevices;
|
||||
|
||||
if (!mediaDevices?.enumerateDevices) {
|
||||
setHasWebcam(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateWebcamAvailability = async () => {
|
||||
try {
|
||||
const devices = await mediaDevices.enumerateDevices();
|
||||
const hasVideoInput = devices.some(
|
||||
(device) => device.kind === "videoinput",
|
||||
);
|
||||
|
||||
setHasWebcam(hasVideoInput);
|
||||
if (!hasVideoInput) {
|
||||
setLiveRasterOn(false);
|
||||
}
|
||||
} catch {
|
||||
setHasWebcam(false);
|
||||
setLiveRasterOn(false);
|
||||
}
|
||||
};
|
||||
|
||||
updateWebcamAvailability();
|
||||
mediaDevices.addEventListener?.("devicechange", updateWebcamAvailability);
|
||||
|
||||
return () => {
|
||||
mediaDevices.removeEventListener?.(
|
||||
"devicechange",
|
||||
updateWebcamAvailability,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleToggleLiveRaster = () => {
|
||||
setLiveRasterOn((isOn) => !isOn);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative grid min-h-screen grid-cols-1 border-b border-border lg:grid-cols-[1.08fr_0.92fr]">
|
||||
@@ -21,6 +62,7 @@ const LandingHeroSection = () => {
|
||||
</a>
|
||||
<nav className="flex flex-wrap gap-5">
|
||||
<a href="#leistungen">Leistungen</a>
|
||||
<a href="#ablauf">Ablauf</a>
|
||||
<a href="#pakete">Pakete</a>
|
||||
<a href="#kontakt">Kontakt</a>
|
||||
</nav>
|
||||
@@ -35,9 +77,9 @@ const LandingHeroSection = () => {
|
||||
</h1>
|
||||
<div className="mt-8 grid gap-7 border-t border-border pt-7 lg:grid-cols-[0.72fr_1fr]">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-muted-foreground">
|
||||
Strategie trifft Umsetzung
|
||||
Ihr Betrieb im Netz — ohne Stress
|
||||
</p>
|
||||
<p className="max-w-2xl text-lg leading-8 text-foreground/78">
|
||||
<p className="max-w-2xl text-lg leading-8 text-foreground/80">
|
||||
Ich baue Websites für Handwerk, Praxen, Salons und
|
||||
Dienstleister aus der Region — klar genug, dass Besucher
|
||||
anrufen statt weiterklicken.
|
||||
@@ -70,52 +112,55 @@ const LandingHeroSection = () => {
|
||||
borderColor="#ffffff"
|
||||
borderOpacity={0}
|
||||
quietWebcamErrors
|
||||
onWebcamError={() => setLiveRasterOn(false)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative z-10 flex min-h-[520px] flex-1 flex-col lg:min-h-0">
|
||||
<div className="flex shrink-0 items-start justify-between border-b border-primary-foreground/30 pb-5 text-xs uppercase tracking-[0.28em]">
|
||||
<span></span>
|
||||
<span className="text-primary-foreground/60">Webdesign</span>
|
||||
<span>©2026</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden shrink-0 flex-col items-end gap-2 pt-4 lg:flex">
|
||||
<p
|
||||
className={cn(
|
||||
"max-w-[18ch] text-right text-[10px] leading-snug font-medium uppercase tracking-[0.22em] text-primary-foreground/85",
|
||||
!liveRasterOn && "motion-safe:animate-pulse",
|
||||
)}
|
||||
>
|
||||
{liveRasterOn
|
||||
? "Kamera aus? Schalter zurück, fertig."
|
||||
: "Psst ... einmal wippen, dann lebt die Seite."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={liveRasterOn}
|
||||
aria-label={
|
||||
liveRasterOn
|
||||
? "Live-Raster und Kamera beenden"
|
||||
: "Live-Raster mit Kamera starten"
|
||||
}
|
||||
onClick={() => setLiveRasterOn((v) => !v)}
|
||||
className={cn(
|
||||
"relative h-9 w-13 shrink-0 rounded-full border transition-colors duration-300 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-primary-foreground",
|
||||
liveRasterOn
|
||||
? "border-primary-foreground/50 bg-primary-foreground/20"
|
||||
: "border-primary-foreground/45 bg-primary-foreground/10 hover:border-primary-foreground/70",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
{hasWebcam ? (
|
||||
<div className="hidden shrink-0 flex-col items-end gap-2 pt-4 lg:flex">
|
||||
<p
|
||||
className={cn(
|
||||
"absolute top-1 left-1 size-7 rounded-full bg-primary-foreground shadow-sm transition-transform duration-300 ease-out",
|
||||
liveRasterOn ? "translate-x-4" : "translate-x-0",
|
||||
"max-w-[18ch] text-right text-[10px] leading-snug font-medium uppercase tracking-[0.22em] text-primary-foreground/85",
|
||||
!liveRasterOn && "motion-safe:animate-pulse",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
>
|
||||
{liveRasterOn
|
||||
? "Kamera aus? Schalter zurück, fertig."
|
||||
: "Psst ... einmal wippen, dann lebt die Seite."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={liveRasterOn}
|
||||
aria-label={
|
||||
liveRasterOn
|
||||
? "Live-Raster beenden"
|
||||
: "Live-Raster mit Kamera starten"
|
||||
}
|
||||
onClick={handleToggleLiveRaster}
|
||||
className={cn(
|
||||
"relative h-9 w-13 shrink-0 rounded-full border transition-colors duration-300 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-primary-foreground",
|
||||
liveRasterOn
|
||||
? "border-primary-foreground/50 bg-primary-foreground/20"
|
||||
: "border-primary-foreground/45 bg-primary-foreground/10 hover:border-primary-foreground/70",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-1 left-1 size-7 rounded-full bg-primary-foreground shadow-sm transition-transform duration-300 ease-out",
|
||||
liveRasterOn ? "translate-x-4" : "translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative mt-auto pt-16 pb-8 lg:pt-24 lg:pb-10">
|
||||
<div className="pointer-events-none absolute bottom-0 right-0 h-36 w-36 border border-primary-foreground/35 sm:right-2 lg:right-10" />
|
||||
@@ -133,7 +178,7 @@ const LandingHeroSection = () => {
|
||||
href="#kontakt"
|
||||
className="group relative z-10 inline-flex w-fit shrink-0 items-center gap-3 border border-primary-foreground px-5 py-4 text-sm font-semibold uppercase tracking-[0.18em] transition hover:bg-primary-foreground hover:text-primary"
|
||||
>
|
||||
Projekt anfragen
|
||||
Unverbindlich anfragen
|
||||
<ArrowUpRight className="size-5 transition group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
17
src/components/landing-page-sections.tsx
Normal file
17
src/components/landing-page-sections.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AboutSection } from "@/components/landing/about-section";
|
||||
import { PackagesSection } from "@/components/landing/packages-section";
|
||||
import { ProcessSection } from "@/components/landing/process-section";
|
||||
import { ServicesSection } from "@/components/landing/services-section";
|
||||
|
||||
const LandingPageSections = () => {
|
||||
return (
|
||||
<>
|
||||
<ServicesSection />
|
||||
<AboutSection />
|
||||
<ProcessSection />
|
||||
<PackagesSection />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { LandingPageSections };
|
||||
@@ -1,185 +0,0 @@
|
||||
import {
|
||||
Check,
|
||||
CornerDownRight,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
} from "lucide-react";
|
||||
|
||||
const services = [
|
||||
{
|
||||
number: "01",
|
||||
title: "Website",
|
||||
text: "Eine klare Startseite oder ein kompletter Auftritt, der sofort zeigt, warum man Ihnen vertrauen kann.",
|
||||
},
|
||||
{
|
||||
number: "02",
|
||||
title: "Struktur",
|
||||
text: "Angebot, Referenzen, Ablauf und Kontakt werden so sortiert, dass Besucher nicht suchen müssen.",
|
||||
},
|
||||
{
|
||||
number: "03",
|
||||
title: "Technik",
|
||||
text: "Schnell, mobil sauber, DSGVO-sauber und so gebaut, dass spätere Änderungen nicht zum Projekt werden.",
|
||||
},
|
||||
];
|
||||
|
||||
const deliverables = [
|
||||
"Strategie und Seitenstruktur",
|
||||
"Individuelles Screen-Design",
|
||||
"Moderne, schnelle Umsetzung",
|
||||
"Kontaktformular und Datenschutz",
|
||||
"Hosting, Wartung und Analytics",
|
||||
];
|
||||
|
||||
const packages = [
|
||||
{
|
||||
name: "Basis",
|
||||
price: "799 EUR",
|
||||
detail: "Eine starke Seite für ein klares Angebot.",
|
||||
},
|
||||
{
|
||||
name: "Profi",
|
||||
price: "1.499 EUR",
|
||||
detail: "Mehrere Seiten für Betriebe mit mehr zu zeigen.",
|
||||
},
|
||||
{
|
||||
name: "Maßarbeit",
|
||||
price: "2.499 EUR+",
|
||||
detail: "Eigene Struktur, selbst pflegbar, für besondere Anforderungen.",
|
||||
},
|
||||
];
|
||||
|
||||
const LandingRest = () => {
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
id="leistungen"
|
||||
className="grid border-b border-border lg:grid-cols-[0.36fr_0.64fr]"
|
||||
>
|
||||
<div className="border-b border-border px-5 py-12 sm:px-8 lg:border-b-0 lg:border-r lg:px-12 lg:py-20">
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||
Leistungen (02)
|
||||
</p>
|
||||
<h2 className="mt-6 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||
Drei Schritte. Eine Website.
|
||||
</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{services.map((service) => (
|
||||
<article
|
||||
key={service.number}
|
||||
className="grid gap-8 px-5 py-10 sm:px-8 md:grid-cols-[7rem_0.4fr_1fr] lg:px-12"
|
||||
>
|
||||
<span className="text-5xl font-black text-primary">
|
||||
{service.number}
|
||||
</span>
|
||||
<h3 className="text-3xl font-black uppercase leading-none">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="max-w-2xl text-lg leading-8 text-muted-foreground">
|
||||
{service.text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid border-b border-border lg:grid-cols-2">
|
||||
<div className="bg-foreground px-5 py-14 text-background sm:px-8 lg:px-12 lg:py-24">
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||
Ergebnis (03)
|
||||
</p>
|
||||
<h2 className="mt-8 max-w-[10ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||
Was am Ende steht
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid content-center gap-4 px-5 py-12 sm:px-8 lg:px-12">
|
||||
{deliverables.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex items-center gap-4 border-b border-border pb-4 text-lg font-semibold uppercase tracking-[0.08em]"
|
||||
>
|
||||
<Check className="size-5 text-primary" />
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="pakete" className="border-b border-border px-5 py-14 sm:px-8 lg:px-12 lg:py-24">
|
||||
<div className="grid gap-8 lg:grid-cols-[0.45fr_0.55fr]">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||
Pakete (04)
|
||||
</p>
|
||||
<h2 className="mt-8 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||
Kosten ohne Nebel
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
{packages.map((item) => (
|
||||
<article
|
||||
key={item.name}
|
||||
className="grid gap-4 border border-border p-5 sm:grid-cols-[0.5fr_0.5fr] sm:p-6"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-primary">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="mt-4 text-4xl font-black uppercase">
|
||||
{item.price}
|
||||
</p>
|
||||
</div>
|
||||
<p className="self-end text-lg leading-7 text-muted-foreground">
|
||||
{item.detail}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="kontakt"
|
||||
className="grid min-h-[620px] lg:grid-cols-[0.72fr_0.28fr]"
|
||||
>
|
||||
<div className="px-5 py-14 sm:px-8 lg:px-12 lg:py-24">
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||
Kontakt (05)
|
||||
</p>
|
||||
<h2 className="mt-8 max-w-[12ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl lg:text-8xl">
|
||||
Erzählen Sie mir kurz von Ihrem Betrieb
|
||||
</h2>
|
||||
<p className="mt-8 max-w-2xl text-xl leading-8 text-muted-foreground">
|
||||
Ein paar Sätze reichen: Was bieten Sie an, was soll die Website
|
||||
für Sie tun, und wann soll sie online sein?
|
||||
</p>
|
||||
<a
|
||||
href="mailto:hallo@matthias-meister.com"
|
||||
className="mt-10 inline-flex items-center gap-3 bg-primary px-6 py-5 text-sm font-black uppercase tracking-[0.18em] text-primary-foreground transition hover:bg-foreground hover:text-background"
|
||||
>
|
||||
<CornerDownRight className="size-5" />
|
||||
Anfrage per Mail senden
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-col justify-end gap-6 bg-primary px-5 py-10 text-primary-foreground sm:px-8 lg:px-10">
|
||||
<div className="flex gap-3">
|
||||
<Mail className="size-5" />
|
||||
<span>hallo@matthias-meister.com</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Phone className="size-5" />
|
||||
<span>Rückmeldung innerhalb von 24 Stunden</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<MapPin className="size-5" />
|
||||
<span>Betriebe aus der Region</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { LandingRest };
|
||||
36
src/components/landing/about-section.tsx
Normal file
36
src/components/landing/about-section.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
const AboutSection = () => {
|
||||
return (
|
||||
<section
|
||||
id="ueber"
|
||||
className="grid border-b border-border lg:grid-cols-[0.36fr_0.64fr]"
|
||||
>
|
||||
<div className="border-b border-border bg-foreground px-5 py-12 text-background sm:px-8 lg:border-b-0 lg:border-r lg:px-12 lg:py-20">
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||
Über mich (03)
|
||||
</p>
|
||||
<h2 className="mt-6 max-w-[12ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||
Ein Mensch. Kein Ticketsystem.
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-8 px-5 py-12 sm:px-8 lg:px-12 lg:py-20">
|
||||
<p className="max-w-xl text-[clamp(1.5rem,3vw,2rem)] font-black uppercase leading-[0.95] text-foreground">
|
||||
Ich bin Matthias — Webdesigner aus Sachsen.
|
||||
</p>
|
||||
<div className="flex flex-col gap-5">
|
||||
<p className="max-w-2xl text-lg leading-8 text-muted-foreground">
|
||||
Ich baue Websites für Betriebe, die ihre Energie lieber in Kunden
|
||||
stecken als in Technik. Kein Großraumbüro, kein Agentur-Overhead —
|
||||
ein Ansprechpartner von der ersten Idee bis zum Go-live.
|
||||
</p>
|
||||
<p className="max-w-2xl text-lg leading-8 text-muted-foreground">
|
||||
Meine Kunden sind Handwerker, Praxen, Salons und Dienstleister aus
|
||||
der Region. Menschen, die eine Website wollen, die funktioniert —
|
||||
und sich dann wieder um ihren Betrieb kümmern möchten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { AboutSection };
|
||||
68
src/components/landing/contact-section.tsx
Normal file
68
src/components/landing/contact-section.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { CornerDownRight, Mail, MapPin, Phone } from "lucide-react";
|
||||
import { Map, MapControls, MapMarker, MarkerContent } from "@/components/ui/map";
|
||||
|
||||
/** Karl-Marx-Str. 22, 08451 Crimmitschau (OpenStreetMap) */
|
||||
const OFFICE: [number, number] = [12.3829769, 50.8131218];
|
||||
|
||||
const ContactSection = () => {
|
||||
return (
|
||||
<section id="kontakt" className="grid min-h-[620px] lg:grid-cols-2">
|
||||
<div className="flex flex-col justify-between px-5 py-14 sm:px-8 lg:px-12 lg:py-24">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||
Kontakt (06)
|
||||
</p>
|
||||
<h2 className="mt-8 max-w-[12ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl lg:text-8xl">
|
||||
Erzählen Sie mir kurz von Ihrem Betrieb
|
||||
</h2>
|
||||
<p className="mt-8 max-w-2xl text-xl leading-8 text-muted-foreground">
|
||||
Ein paar Sätze reichen: Was bieten Sie an, was soll die Website für
|
||||
Sie tun, und wann soll sie online sein?
|
||||
</p>
|
||||
<a
|
||||
href="mailto:support@matthias-meister-webdesign.de"
|
||||
className="mt-10 inline-flex items-center gap-3 bg-primary px-6 py-5 text-sm font-black uppercase tracking-[0.18em] text-primary-foreground transition hover:bg-foreground hover:text-background"
|
||||
>
|
||||
<CornerDownRight className="size-5" />
|
||||
Kurze Nachricht schreiben
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex flex-col gap-5 border-t border-border pt-8 text-sm text-muted-foreground">
|
||||
<div className="flex gap-3">
|
||||
<Mail className="size-4 shrink-0 text-primary" />
|
||||
<span>support@matthias-meister-webdesign.de</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Phone className="size-4 shrink-0 text-primary" />
|
||||
<span>Rückmeldung innerhalb von 24 Stunden</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<MapPin className="size-4 shrink-0 text-primary" />
|
||||
<span>Karl-Marx-Str. 22, 08451 Crimmitschau</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="contact-map relative min-h-[400px] bg-primary lg:min-h-0">
|
||||
<Map
|
||||
center={OFFICE}
|
||||
zoom={15}
|
||||
theme="dark"
|
||||
className="h-full w-full"
|
||||
>
|
||||
<MapControls position="bottom-right" showZoom />
|
||||
<MapMarker longitude={OFFICE[0]} latitude={OFFICE[1]}>
|
||||
<MarkerContent>
|
||||
<div className="size-5 rounded-full border-[3px] border-primary-foreground bg-primary shadow-lg ring-4 ring-primary-foreground/50" />
|
||||
</MarkerContent>
|
||||
</MapMarker>
|
||||
</Map>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { ContactSection };
|
||||
37
src/components/landing/deliverables-section.tsx
Normal file
37
src/components/landing/deliverables-section.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
const deliverables = [
|
||||
"Strategie und Seitenstruktur",
|
||||
"Individuelles Screen-Design",
|
||||
"Moderne, schnelle Umsetzung",
|
||||
"Kontaktformular und Datenschutz",
|
||||
"Hosting, Wartung und Analytics",
|
||||
];
|
||||
|
||||
const DeliverablesSection = () => {
|
||||
return (
|
||||
<section className="grid border-b border-border lg:grid-cols-2">
|
||||
<div className="bg-foreground px-5 py-14 text-background sm:px-8 lg:px-12 lg:py-24">
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||
Ergebnis (03)
|
||||
</p>
|
||||
<h2 className="mt-8 max-w-[10ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||
Was am Ende steht
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid content-center gap-4 px-5 py-12 sm:px-8 lg:px-12">
|
||||
{deliverables.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex items-center gap-4 border-b border-border pb-4 text-lg font-semibold uppercase tracking-[0.08em]"
|
||||
>
|
||||
<Check className="size-5 text-primary" />
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { DeliverablesSection };
|
||||
110
src/components/landing/packages-section.tsx
Normal file
110
src/components/landing/packages-section.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
const packages = [
|
||||
{
|
||||
name: "Basis",
|
||||
price: "799 EUR",
|
||||
detail: "Eine starke Seite für ein klares Angebot.",
|
||||
highlighted: false,
|
||||
features: [
|
||||
"One-Page-Website",
|
||||
"Mobil optimiert",
|
||||
"Kontaktformular",
|
||||
"DSGVO & Impressum",
|
||||
"Hosting für 1 Jahr",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Profi",
|
||||
price: "1.499 EUR",
|
||||
detail: "Mehrere Seiten für Betriebe mit mehr zu zeigen.",
|
||||
highlighted: true,
|
||||
features: [
|
||||
"Alles aus Basis",
|
||||
"Bis zu 5 Unterseiten",
|
||||
"Individuelles Design",
|
||||
"SEO-Grundoptimierung",
|
||||
"Google Maps Einbindung",
|
||||
"Kontaktformular",
|
||||
"DSGVO & Impressum",
|
||||
"Hosting für 1 Jahr",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Maßarbeit",
|
||||
price: "2.499 EUR+",
|
||||
detail: "Ihr Betrieb, Ihre Regeln. Inhalte selbst ändern, Seiten ergänzen, wachsen.",
|
||||
highlighted: false,
|
||||
features: [
|
||||
"Alles aus Profi",
|
||||
"Unbegrenzte Seiten",
|
||||
"Inhalte selbst pflegbar (CMS)",
|
||||
"Individuelles Design & Struktur",
|
||||
"SEO-Optimierung",
|
||||
"Blog oder News-Bereich",
|
||||
"Erweiterte Funktionen",
|
||||
"DSGVO & Impressum",
|
||||
"Hosting für 1 Jahr",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const PackagesSection = () => {
|
||||
return (
|
||||
<section
|
||||
id="pakete"
|
||||
className="border-b border-border px-5 py-14 sm:px-8 lg:px-12 lg:py-24"
|
||||
>
|
||||
<div className="grid gap-8 lg:grid-cols-[0.45fr_0.55fr]">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||
Pakete (05)
|
||||
</p>
|
||||
<h2 className="mt-8 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||
Festpreis. Punkt.
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
{packages.map((item) => (
|
||||
<article
|
||||
key={item.name}
|
||||
className={`grid gap-6 border p-5 sm:grid-cols-[0.5fr_0.5fr] sm:p-6 ${item.highlighted ? "border-primary bg-primary/6" : "border-border"}`}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-primary">
|
||||
{item.name}
|
||||
</p>
|
||||
{item.highlighted ? (
|
||||
<span className="bg-primary px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.2em] text-primary-foreground">
|
||||
Beliebteste Wahl
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-4 text-4xl font-black uppercase">
|
||||
{item.price}
|
||||
</p>
|
||||
<p className="mt-3 text-lg leading-7 text-muted-foreground">
|
||||
{item.detail}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-2.5 self-center">
|
||||
{item.features.map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex items-center gap-3 text-sm text-foreground/85"
|
||||
>
|
||||
<Check className="size-4 shrink-0 text-primary" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { PackagesSection };
|
||||
61
src/components/landing/process-section.tsx
Normal file
61
src/components/landing/process-section.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
const steps = [
|
||||
{
|
||||
number: "01",
|
||||
title: "Gespräch",
|
||||
text: "15 Minuten telefonieren. Sie erzählen, ich höre zu.",
|
||||
},
|
||||
{
|
||||
number: "02",
|
||||
title: "Konzept",
|
||||
text: "Seitenstruktur und Design-Vorschlag innerhalb einer Woche.",
|
||||
},
|
||||
{
|
||||
number: "03",
|
||||
title: "Umsetzung",
|
||||
text: "Fertige Website in 2–4 Wochen. Feedback, Anpassung, fertig.",
|
||||
},
|
||||
{
|
||||
number: "04",
|
||||
title: "Online",
|
||||
text: "Ich schalte live, richte Hosting ein, kümmere mich um den Rest.",
|
||||
},
|
||||
];
|
||||
|
||||
const ProcessSection = () => {
|
||||
return (
|
||||
<section
|
||||
id="ablauf"
|
||||
className="border-b border-border px-5 py-14 sm:px-8 lg:px-12 lg:py-24"
|
||||
>
|
||||
<div className="mb-12">
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||
Ablauf (04)
|
||||
</p>
|
||||
<h2 className="mt-6 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||
Vier Schritte. Fertig.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-0 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{steps.map((step, i) => (
|
||||
<article
|
||||
key={step.number}
|
||||
className={`relative flex flex-col gap-4 border-t border-border py-8 pr-6 lg:border-t-0 lg:border-l lg:py-0 lg:pl-8 lg:pr-10 ${i === 0 ? "lg:border-l-0 lg:pl-0" : ""}`}
|
||||
>
|
||||
<span className="text-6xl font-black leading-none text-primary/25">
|
||||
{step.number}
|
||||
</span>
|
||||
<h3 className="text-xl font-black uppercase leading-none">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="max-w-xs text-base leading-7 text-muted-foreground">
|
||||
{step.text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { ProcessSection };
|
||||
55
src/components/landing/services-section.tsx
Normal file
55
src/components/landing/services-section.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
const services = [
|
||||
{
|
||||
number: "01",
|
||||
title: "Website",
|
||||
text: "Vom Elektriker bis zur Physiotherapie — eine Seite, die in drei Sekunden zeigt, was Sie machen und wie man Sie erreicht.",
|
||||
},
|
||||
{
|
||||
number: "02",
|
||||
title: "Struktur",
|
||||
text: "Angebot, Leistungen, Ablauf und Kontakt — alles dort, wo Besucher es erwarten. Damit aus Klicks Anrufe werden.",
|
||||
},
|
||||
{
|
||||
number: "03",
|
||||
title: "Technik",
|
||||
text: "Lädt in unter zwei Sekunden, sieht auf jedem Handy gut aus und ist rechtssicher. Änderungen später? Ein Anruf genügt.",
|
||||
},
|
||||
];
|
||||
|
||||
const ServicesSection = () => {
|
||||
return (
|
||||
<section
|
||||
id="leistungen"
|
||||
className="grid border-b border-border lg:grid-cols-[0.36fr_0.64fr]"
|
||||
>
|
||||
<div className="border-b border-border px-5 py-12 sm:px-8 lg:border-b-0 lg:border-r lg:px-12 lg:py-20">
|
||||
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||
Leistungen (02)
|
||||
</p>
|
||||
<h2 className="mt-6 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||
Drei Schritte. Eine Website.
|
||||
</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{services.map((service) => (
|
||||
<article
|
||||
key={service.number}
|
||||
className="grid gap-8 px-5 py-10 sm:px-8 md:grid-cols-[7rem_0.4fr_1fr] lg:px-12"
|
||||
>
|
||||
<span className="text-5xl font-black text-primary">
|
||||
{service.number}
|
||||
</span>
|
||||
<h3 className="text-3xl font-black uppercase leading-none">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="max-w-2xl text-lg leading-8 text-muted-foreground">
|
||||
{service.text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { ServicesSection };
|
||||
@@ -1,219 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PricingPlan {
|
||||
name: string;
|
||||
price: string;
|
||||
period: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
isPopular?: boolean;
|
||||
}
|
||||
|
||||
interface Pricing4Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const developmentPlans: PricingPlan[] = [
|
||||
{
|
||||
name: "Basis",
|
||||
price: "799 €",
|
||||
period: "Einmalpreis",
|
||||
description: "Für einen klaren Webauftritt mit den wichtigsten Inhalten.",
|
||||
features: [
|
||||
"Eine Seite mit fünf Sektionen",
|
||||
"Kontaktformular",
|
||||
"Impressum und Datenschutz",
|
||||
"Mobilfreundlich und für Google vorbereitet",
|
||||
"Cookiefreie Analytics ohne Banner",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Profi",
|
||||
price: "1.499 €",
|
||||
period: "Einmalpreis",
|
||||
description: "Für Betriebe, die mehrere Leistungen sauber erklären wollen.",
|
||||
features: [
|
||||
"Bis zu fünf Unterseiten",
|
||||
"Google Maps Integration",
|
||||
"SEO-Basis für lokale Auffindbarkeit",
|
||||
"Optionaler Blog",
|
||||
"Alles aus Basis inklusive",
|
||||
],
|
||||
isPopular: true,
|
||||
},
|
||||
{
|
||||
name: "Maßarbeit",
|
||||
price: "2.499 €",
|
||||
period: "Einmalpreis",
|
||||
description: "Für individuelle Anforderungen, CMS und spätere Erweiterungen.",
|
||||
features: [
|
||||
"Individuelles Design nach Ihren Anforderungen",
|
||||
"CMS zur eigenen Inhaltspflege",
|
||||
"Erweiterbare Struktur",
|
||||
"Alles aus Profi inklusive",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const servicePlans: PricingPlan[] = [
|
||||
{
|
||||
name: "Hosting",
|
||||
price: "19 €",
|
||||
period: "pro Monat",
|
||||
description: "Solide technische Basis für kleine Unternehmensseiten.",
|
||||
features: [
|
||||
"Hosting auf deutschen Servern in Sachsen",
|
||||
"SSL, Domain und tägliche Backups",
|
||||
"Monatlicher Einblick in Besucherzahlen",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Wartung",
|
||||
price: "39 €",
|
||||
period: "pro Monat",
|
||||
description: "Für Unternehmen, die Betrieb und Sicherheit abgeben möchten.",
|
||||
features: [
|
||||
"Alles aus Hosting inklusive",
|
||||
"Regelmäßige Updates und Sicherheitschecks",
|
||||
"1 Stunde Support pro Monat",
|
||||
"Monitoring bei technischen Problemen",
|
||||
],
|
||||
isPopular: true,
|
||||
},
|
||||
{
|
||||
name: "Full Service",
|
||||
price: "69 €",
|
||||
period: "pro Monat",
|
||||
description: "Für laufende Änderungen ohne jedes Mal ein neues Projekt.",
|
||||
features: [
|
||||
"Alles aus Wartung inklusive",
|
||||
"Kleinere Inhaltsänderungen bis 2 Stunden pro Monat",
|
||||
"Häufigerer Einblick in Besucherzahlen",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const Pricing4 = ({
|
||||
title = "Pakete mit klarer Kante.",
|
||||
description =
|
||||
"Die Preise sind bewusst nachvollziehbar gehalten. Im Gespräch klären wir, welches Paket passt und wo ein schlankerer Weg sinnvoller ist.",
|
||||
className,
|
||||
}: Pricing4Props) => {
|
||||
const [activeTab, setActiveTab] = useState<"development" | "service">(
|
||||
"development",
|
||||
);
|
||||
const plans = activeTab === "development" ? developmentPlans : servicePlans;
|
||||
|
||||
return (
|
||||
<section
|
||||
id="preise"
|
||||
className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}
|
||||
>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.4fr)] lg:items-end lg:gap-16">
|
||||
<div className="max-w-xl">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||
Preise
|
||||
</p>
|
||||
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-5 text-base leading-7 text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value: string) =>
|
||||
setActiveTab(value as "development" | "service")
|
||||
}
|
||||
className="w-fit lg:justify-self-end"
|
||||
aria-label="Paketart auswählen"
|
||||
>
|
||||
<TabsList className="grid h-11 w-full grid-cols-2 rounded-md border border-border bg-card p-1 sm:w-max">
|
||||
<TabsTrigger
|
||||
value="development"
|
||||
className="h-full min-h-0 px-5 py-0 text-sm font-semibold data-active:bg-primary data-active:text-primary-foreground"
|
||||
>
|
||||
Entwicklung
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="service"
|
||||
className="h-full min-h-0 px-5 py-0 text-sm font-semibold data-active:bg-primary data-active:text-primary-foreground"
|
||||
>
|
||||
Hosting & Wartung
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-4 lg:grid-cols-3">
|
||||
{plans.map((plan) => (
|
||||
<article
|
||||
key={plan.name}
|
||||
className={cn(
|
||||
"flex min-h-[32rem] flex-col rounded-lg border bg-card p-6",
|
||||
plan.isPopular
|
||||
? "border-primary shadow-[0_18px_60px_oklch(0.285_0.045_148/0.12)]"
|
||||
: "border-border",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold tracking-tight">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
{plan.description}
|
||||
</p>
|
||||
</div>
|
||||
{plan.isPopular && (
|
||||
<Badge variant="outline" className="rounded-md">
|
||||
Empfohlen
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<p className="text-4xl font-semibold tracking-tight">
|
||||
{plan.price}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{plan.period}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="mt-8 flex-1 space-y-4 text-sm leading-6 text-muted-foreground">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex gap-3">
|
||||
<Check
|
||||
className="mt-0.5 size-4 shrink-0 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button asChild className="mt-8 w-full rounded-md">
|
||||
<a href="#kontakt">Kostenloses Angebot anfordern</a>
|
||||
</Button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Pricing4 };
|
||||
@@ -1,48 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Stats11Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Stats11 = ({ className }: Stats11Props) => {
|
||||
const stats = [
|
||||
["SEO-ready", "Leistungsseiten, Region und Kontakt sauber strukturiert."],
|
||||
["< 1 Sek.", "Auf schnelle Ladezeiten und klare Technik ausgelegt."],
|
||||
["ab 799 €", "Transparente Einstiegspreise ohne Paketnebel."],
|
||||
["2 Wochen", "Typischer Zeitraum vom Startgespräch bis zur Vorschau."],
|
||||
];
|
||||
|
||||
return (
|
||||
<section className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="rounded-lg border border-border bg-primary p-6 text-primary-foreground sm:p-8 lg:grid lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.45fr)] lg:gap-12 lg:p-10">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary-foreground/65">
|
||||
Messbare Grundlagen
|
||||
</p>
|
||||
<h2 className="mt-4 max-w-xl text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
||||
Gute Websites fühlen sich ruhig an, weil die Basis stimmt.
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-10 grid gap-4 sm:grid-cols-2 lg:mt-0">
|
||||
{stats.map(([value, label]) => (
|
||||
<div
|
||||
key={value}
|
||||
className="rounded-md border border-primary-foreground/15 bg-primary-foreground/[0.06] p-5"
|
||||
>
|
||||
<p className="text-3xl font-semibold tracking-tight">
|
||||
{value}
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-6 text-primary-foreground/72">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Stats11 };
|
||||
@@ -1,79 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Accordion({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn("flex w-full flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("not-last:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@@ -1,49 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -1,67 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -1,236 +0,0 @@
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||
"last:mt-0 nth-last-2:-mt-1",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { animate } from "motion/react";
|
||||
|
||||
interface GlowingEffectProps {
|
||||
blur?: number;
|
||||
inactiveZone?: number;
|
||||
proximity?: number;
|
||||
spread?: number;
|
||||
variant?: "default" | "white";
|
||||
glow?: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
movementDuration?: number;
|
||||
borderWidth?: number;
|
||||
}
|
||||
const GlowingEffect = memo(
|
||||
({
|
||||
blur = 0,
|
||||
inactiveZone = 0.7,
|
||||
proximity = 0,
|
||||
spread = 20,
|
||||
variant = "default",
|
||||
glow = false,
|
||||
className,
|
||||
movementDuration = 2,
|
||||
borderWidth = 1,
|
||||
disabled = true,
|
||||
}: GlowingEffectProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const lastPosition = useRef({ x: 0, y: 0 });
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
|
||||
const handleMove = useCallback(
|
||||
(e?: MouseEvent | { x: number; y: number }) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(() => {
|
||||
const element = containerRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const { left, top, width, height } = element.getBoundingClientRect();
|
||||
const mouseX = e?.x ?? lastPosition.current.x;
|
||||
const mouseY = e?.y ?? lastPosition.current.y;
|
||||
|
||||
if (e) {
|
||||
lastPosition.current = { x: mouseX, y: mouseY };
|
||||
}
|
||||
|
||||
const center = [left + width * 0.5, top + height * 0.5];
|
||||
const distanceFromCenter = Math.hypot(
|
||||
mouseX - center[0],
|
||||
mouseY - center[1]
|
||||
);
|
||||
const inactiveRadius = 0.5 * Math.min(width, height) * inactiveZone;
|
||||
|
||||
if (distanceFromCenter < inactiveRadius) {
|
||||
element.style.setProperty("--active", "0");
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive =
|
||||
mouseX > left - proximity &&
|
||||
mouseX < left + width + proximity &&
|
||||
mouseY > top - proximity &&
|
||||
mouseY < top + height + proximity;
|
||||
|
||||
element.style.setProperty("--active", isActive ? "1" : "0");
|
||||
|
||||
if (!isActive) return;
|
||||
|
||||
const currentAngle =
|
||||
parseFloat(element.style.getPropertyValue("--start")) || 0;
|
||||
let targetAngle =
|
||||
(180 * Math.atan2(mouseY - center[1], mouseX - center[0])) /
|
||||
Math.PI +
|
||||
90;
|
||||
|
||||
const angleDiff = ((targetAngle - currentAngle + 180) % 360) - 180;
|
||||
const newAngle = currentAngle + angleDiff;
|
||||
|
||||
animate(currentAngle, newAngle, {
|
||||
duration: movementDuration,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
onUpdate: (value) => {
|
||||
element.style.setProperty("--start", String(value));
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
[inactiveZone, proximity, movementDuration]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
|
||||
const handleScroll = () => handleMove();
|
||||
const handlePointerMove = (e: PointerEvent) => handleMove(e);
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
document.body.addEventListener("pointermove", handlePointerMove, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
document.body.removeEventListener("pointermove", handlePointerMove);
|
||||
};
|
||||
}, [handleMove, disabled]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute -inset-px hidden rounded-[inherit] border opacity-0 transition-opacity",
|
||||
glow && "opacity-100",
|
||||
variant === "white" && "border-white",
|
||||
disabled && "!block"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={
|
||||
{
|
||||
"--blur": `${blur}px`,
|
||||
"--spread": spread,
|
||||
"--start": "0",
|
||||
"--active": "0",
|
||||
"--glowingeffect-border-width": `${borderWidth}px`,
|
||||
"--repeating-conic-gradient-times": "5",
|
||||
"--gradient":
|
||||
variant === "white"
|
||||
? `repeating-conic-gradient(
|
||||
from 236.84deg at 50% 50%,
|
||||
var(--black),
|
||||
var(--black) calc(25% / var(--repeating-conic-gradient-times))
|
||||
)`
|
||||
: `radial-gradient(circle, #dd7bbb 10%, #dd7bbb00 20%),
|
||||
radial-gradient(circle at 40% 40%, #d79f1e 5%, #d79f1e00 15%),
|
||||
radial-gradient(circle at 60% 60%, #5a922c 10%, #5a922c00 20%),
|
||||
radial-gradient(circle at 40% 60%, #4c7894 10%, #4c789400 20%),
|
||||
repeating-conic-gradient(
|
||||
from 236.84deg at 50% 50%,
|
||||
#dd7bbb 0%,
|
||||
#d79f1e calc(25% / var(--repeating-conic-gradient-times)),
|
||||
#5a922c calc(50% / var(--repeating-conic-gradient-times)),
|
||||
#4c7894 calc(75% / var(--repeating-conic-gradient-times)),
|
||||
#dd7bbb calc(100% / var(--repeating-conic-gradient-times))
|
||||
)`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-[inherit] opacity-100 transition-opacity",
|
||||
glow && "opacity-100",
|
||||
blur > 0 && "blur-[var(--blur)] ",
|
||||
className,
|
||||
disabled && "!hidden"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"glow",
|
||||
"rounded-[inherit]",
|
||||
'after:content-[""] after:rounded-[inherit] after:absolute after:inset-[calc(-1*var(--glowingeffect-border-width))]',
|
||||
"after:[border:var(--glowingeffect-border-width)_solid_transparent]",
|
||||
"after:[background:var(--gradient)] after:[background-attachment:fixed]",
|
||||
"after:opacity-[var(--active)] after:transition-opacity after:duration-300",
|
||||
"after:[mask-clip:padding-box,border-box]",
|
||||
"after:[mask-composite:intersect]",
|
||||
"after:[mask-image:linear-gradient(#0000,#0000),conic-gradient(from_calc((var(--start)-var(--spread))*1deg),#00000000_0deg,#fff,#00000000_calc(var(--spread)*2deg))]"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
GlowingEffect.displayName = "GlowingEffect";
|
||||
|
||||
export { GlowingEffect };
|
||||
@@ -1,19 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
1844
src/components/ui/map.tsx
Normal file
1844
src/components/ui/map.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -1,90 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -165,6 +165,10 @@ export const WebcamPixelGrid: React.FC<WebcamPixelGridProps> = ({
|
||||
// Request camera access
|
||||
const requestCameraAccess = useCallback(async () => {
|
||||
try {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error("Webcam access is not available in this browser");
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: 640 },
|
||||
|
||||
@@ -16,7 +16,9 @@ import "@/styles/global.css";
|
||||
defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="min-h-screen bg-background px-5 py-8 text-foreground sm:px-8 lg:px-12">
|
||||
<main
|
||||
class="min-h-screen bg-background px-5 py-8 text-foreground sm:px-8 lg:px-12"
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
class="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground transition hover:text-foreground"
|
||||
@@ -24,19 +26,25 @@ import "@/styles/global.css";
|
||||
Matthias Meister
|
||||
</a>
|
||||
|
||||
<section class="mx-auto grid max-w-5xl gap-12 py-16 lg:grid-cols-[0.42fr_0.58fr] lg:py-24">
|
||||
<div>
|
||||
<section
|
||||
class="mx-auto grid max-w-5xl gap-12 py-16 lg:grid-cols-[0.42fr_0.58fr] lg:py-24"
|
||||
>
|
||||
<div class="lg:sticky lg:top-8 lg:self-start">
|
||||
<p class="text-sm uppercase tracking-[0.3em] text-primary">
|
||||
Datenschutz
|
||||
</p>
|
||||
<h1 class="mt-6 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||
<h1
|
||||
class="mt-6 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl"
|
||||
>
|
||||
Ihre Daten
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-10 text-lg leading-8 text-foreground/85">
|
||||
<section>
|
||||
<h2 class="text-sm font-black uppercase tracking-[0.22em] text-primary">
|
||||
<h2
|
||||
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
|
||||
>
|
||||
Verantwortlicher
|
||||
</h2>
|
||||
<div class="mt-4 space-y-1 text-muted-foreground">
|
||||
@@ -48,27 +56,31 @@ import "@/styles/global.css";
|
||||
E-Mail:
|
||||
<a
|
||||
class="underline underline-offset-4 transition hover:text-foreground"
|
||||
href="mailto:hallo@matthias-meister.com"
|
||||
href="mailto:support@matthias-meister-webdesign.de"
|
||||
>
|
||||
hallo@matthias-meister.com
|
||||
support@matthias-meister-webdesign.de
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-black uppercase tracking-[0.22em] text-primary">
|
||||
<h2
|
||||
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
|
||||
>
|
||||
Keine Cookies
|
||||
</h2>
|
||||
<p class="mt-4 text-muted-foreground">
|
||||
Diese Website setzt keine Cookies. Es gibt keine Nutzerkonten,
|
||||
keinen Newsletter, keine Zahlungsabwicklung und keine eingebetteten
|
||||
Drittanbieter-Medien.
|
||||
keinen Newsletter, keine Zahlungsabwicklung und keine
|
||||
eingebetteten Drittanbieter-Medien.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-black uppercase tracking-[0.22em] text-primary">
|
||||
<h2
|
||||
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
|
||||
>
|
||||
Kontakt per E-Mail
|
||||
</h2>
|
||||
<p class="mt-4 text-muted-foreground">
|
||||
@@ -80,27 +92,31 @@ import "@/styles/global.css";
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-black uppercase tracking-[0.22em] text-primary">
|
||||
<h2
|
||||
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
|
||||
>
|
||||
Reichweitenmessung mit Rybbit
|
||||
</h2>
|
||||
<p class="mt-4 text-muted-foreground">
|
||||
Diese Website nutzt Rybbit Analytics über
|
||||
https://rybbit.matthias.lol/api/script.js, um anonymisierte und
|
||||
Diese Website nutzt Rybbit Analytics, um anonymisierte und
|
||||
aggregierte Statistiken zur Nutzung der Website zu erhalten.
|
||||
Rybbit arbeitet cookielos und verwendet nach Anbieterangaben keine
|
||||
Cookies oder local storage für das Tracking.
|
||||
Rybbit verwendet keine Cookies oder local storage für das
|
||||
Tracking.
|
||||
</p>
|
||||
<p class="mt-4 text-muted-foreground">
|
||||
Dabei können technische Besuchsdaten wie aufgerufene Seiten,
|
||||
Referrer, Browser- und Geräteinformationen sowie aus der
|
||||
IP-Adresse abgeleitete ungefähre Standortdaten verarbeitet werden.
|
||||
Die IP-Adresse wird dabei nur vorübergehend zur Verarbeitung
|
||||
genutzt und nicht als Klartext in der Analysedatenbank gespeichert.
|
||||
genutzt und nicht als Klartext in der Analysedatenbank
|
||||
gespeichert.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-black uppercase tracking-[0.22em] text-primary">
|
||||
<h2
|
||||
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
|
||||
>
|
||||
Ihre Rechte
|
||||
</h2>
|
||||
<p class="mt-4 text-muted-foreground">
|
||||
@@ -113,15 +129,20 @@ import "@/styles/global.css";
|
||||
</section>
|
||||
|
||||
<p class="border-t border-border pt-8 text-sm text-muted-foreground">
|
||||
Hinweis: Dieser Text beschreibt die technische Umsetzung dieser
|
||||
Website und ersetzt keine anwaltliche Prüfung.
|
||||
Hinweis: Dieser Text wird fortlaufend aktualisiert.
|
||||
</p>
|
||||
|
||||
<nav class="flex flex-wrap gap-5 text-sm text-muted-foreground">
|
||||
<a class="underline underline-offset-4 transition hover:text-foreground" href="/">
|
||||
<a
|
||||
class="underline underline-offset-4 transition hover:text-foreground"
|
||||
href="/"
|
||||
>
|
||||
Startseite
|
||||
</a>
|
||||
<a class="underline underline-offset-4 transition hover:text-foreground" href="/impressum">
|
||||
<a
|
||||
class="underline underline-offset-4 transition hover:text-foreground"
|
||||
href="/impressum"
|
||||
>
|
||||
Impressum
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -16,7 +16,9 @@ import "@/styles/global.css";
|
||||
defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="min-h-screen bg-background px-5 py-8 text-foreground sm:px-8 lg:px-12">
|
||||
<main
|
||||
class="min-h-screen bg-background px-5 py-8 text-foreground sm:px-8 lg:px-12"
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
class="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground transition hover:text-foreground"
|
||||
@@ -24,19 +26,25 @@ import "@/styles/global.css";
|
||||
Matthias Meister
|
||||
</a>
|
||||
|
||||
<section class="mx-auto grid max-w-5xl gap-12 py-16 lg:grid-cols-[0.42fr_0.58fr] lg:py-24">
|
||||
<section
|
||||
class="mx-auto grid max-w-5xl gap-12 py-16 lg:grid-cols-[0.42fr_0.58fr] lg:py-24"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm uppercase tracking-[0.3em] text-primary">
|
||||
Rechtliches
|
||||
</p>
|
||||
<h1 class="mt-6 max-w-[8ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||
<h1
|
||||
class="mt-6 max-w-[8ch] text-5xl font-black uppercase leading-[0.86] sm:text-6xl"
|
||||
>
|
||||
Impressum
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-10 text-lg leading-8 text-foreground/85">
|
||||
<section>
|
||||
<h2 class="text-sm font-black uppercase tracking-[0.22em] text-primary">
|
||||
<h2
|
||||
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
|
||||
>
|
||||
Angaben gemäß § 5 TMG
|
||||
</h2>
|
||||
<div class="mt-4 space-y-1 text-muted-foreground">
|
||||
@@ -48,7 +56,9 @@ import "@/styles/global.css";
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-black uppercase tracking-[0.22em] text-primary">
|
||||
<h2
|
||||
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
|
||||
>
|
||||
Umsatzsteuer
|
||||
</h2>
|
||||
<p class="mt-4 text-muted-foreground">
|
||||
@@ -57,25 +67,35 @@ import "@/styles/global.css";
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-black uppercase tracking-[0.22em] text-primary">
|
||||
<h2
|
||||
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
|
||||
>
|
||||
Kontakt
|
||||
</h2>
|
||||
<p class="mt-4 text-muted-foreground">
|
||||
E-Mail:
|
||||
<a
|
||||
class="underline underline-offset-4 transition hover:text-foreground"
|
||||
href="mailto:hallo@matthias-meister.com"
|
||||
href="mailto:support@matthias-meister-webdesign.de"
|
||||
>
|
||||
hallo@matthias-meister.com
|
||||
support@matthias-meister-webdesign.de
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<nav class="flex flex-wrap gap-5 border-t border-border pt-8 text-sm text-muted-foreground">
|
||||
<a class="underline underline-offset-4 transition hover:text-foreground" href="/">
|
||||
<nav
|
||||
class="flex flex-wrap gap-5 border-t border-border pt-8 text-sm text-muted-foreground"
|
||||
>
|
||||
<a
|
||||
class="underline underline-offset-4 transition hover:text-foreground"
|
||||
href="/"
|
||||
>
|
||||
Startseite
|
||||
</a>
|
||||
<a class="underline underline-offset-4 transition hover:text-foreground" href="/datenschutz">
|
||||
<a
|
||||
class="underline underline-offset-4 transition hover:text-foreground"
|
||||
href="/datenschutz"
|
||||
>
|
||||
Datenschutz
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import { LandingHeroSection } from "@/components/landing-hero-section";
|
||||
import { LandingRest } from "@/components/landing";
|
||||
import { LandingPageSections } from "@/components/landing-page-sections";
|
||||
import { ContactSection } from "@/components/landing/contact-section";
|
||||
import { Footer27 } from "@/components/footer27";
|
||||
import "@/styles/global.css";
|
||||
---
|
||||
@@ -21,7 +22,8 @@ import "@/styles/global.css";
|
||||
<body>
|
||||
<main class="min-h-screen overflow-hidden bg-background text-foreground">
|
||||
<LandingHeroSection client:media="(min-width: 1024px)" />
|
||||
<LandingRest />
|
||||
<LandingPageSections />
|
||||
<ContactSection client:visible />
|
||||
<Footer27 />
|
||||
</main>
|
||||
<script>
|
||||
|
||||
@@ -129,4 +129,31 @@
|
||||
@apply font-sans;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
.maplibregl-popup-content {
|
||||
@apply bg-transparent! shadow-none! p-0! rounded-none!;
|
||||
}
|
||||
.maplibregl-popup-tip {
|
||||
@apply hidden!;
|
||||
}
|
||||
.contact-map .maplibregl-canvas {
|
||||
/* Dunkle Carto-Tiles: Sepia kippt alles in Braun, hue-rotate(325deg)
|
||||
schiebt Braun (~38°) auf Rot (~3°). Helle Straßen/Labels werden
|
||||
zu hellem Rot, der dunkle Hintergrund zu dunklem Rot. */
|
||||
filter: sepia(1) hue-rotate(325deg) saturate(4.5) brightness(0.82);
|
||||
}
|
||||
.contact-map .maplibregl-ctrl-attrib {
|
||||
background: oklch(0.115 0.012 22 / 70%) !important;
|
||||
}
|
||||
.contact-map .maplibregl-ctrl-attrib a {
|
||||
color: oklch(0.985 0.01 76 / 80%) !important;
|
||||
}
|
||||
.contact-map .maplibregl-ctrl-group {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.contact-map .maplibregl-ctrl-group button {
|
||||
background: oklch(0.115 0.012 22 / 80%) !important;
|
||||
color: oklch(0.985 0.01 76) !important;
|
||||
border-color: oklch(0.985 0.01 76 / 20%) !important;
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const sourcePaths = [
|
||||
new URL("../src/components/landing.tsx", import.meta.url),
|
||||
new URL("../src/components/landing-hero-section.tsx", import.meta.url),
|
||||
new URL("../src/components/landing/services-section.tsx", import.meta.url),
|
||||
new URL("../src/components/landing/deliverables-section.tsx", import.meta.url),
|
||||
new URL("../src/components/landing/packages-section.tsx", import.meta.url),
|
||||
new URL("../src/components/landing/contact-section.tsx", import.meta.url),
|
||||
];
|
||||
|
||||
const footerPath = new URL("../src/components/footer27.tsx", import.meta.url);
|
||||
|
||||
Reference in New Issue
Block a user