Compare commits

..

9 Commits

Author SHA1 Message Date
Matthias
61e05910a9 Merge branch 'design/critique-fixes'
Design-Verbesserungen aus Code-Review: Copy, Layout-Rhythmus,
About/Process-Sektionen, Pakete mit Features, interaktive Karte.
2026-05-18 18:12:30 +02:00
Matthias
cee5f470ad Design-Verbesserungen: Copy, Layout, Karte & Social-Proof
- Hero: Sub-Headline und CTA-Text niedrigschwelliger formuliert
- Services: Konkretere, ergebnisorientierte Beschreibungen
- Neue About-Section mit persönlichem Pull-Quote
- Neue Process-Section als 4-Spalten-Grid (statt Services-Klon)
- Packages: Feature-Listen, Profi-Paket hervorgehoben, neue Texte
- Contact: Infos links, interaktive mapcn-Karte rechts (Crimmitschau)
- Karte rot eingefärbt via sepia/hue-rotate CSS-Filter
- Nav um "Ablauf"-Link ergänzt, fehlende IDs gesetzt
- Kleinere Copy-Fixes (Opacity, leerer Span, Region konkretisiert)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 18:12:25 +02:00
8a4ec60655 Update contact email and improve code formatting across multiple pages
- Change contact email from hallo@matthias-meister.com to support@matthias-meister-webdesign.de in the contact section, Datenschutz, and Impressum pages.
- Enhance code readability by adjusting formatting and indentation in the main and section elements.
- Ensure consistent styling and structure across the affected components.
2026-05-07 10:40:59 +02:00
e039fdf555 Remove deprecated components from the project
- Delete unused components including About19, Contact21, CTA, Faq7, Feature284, Hero235, Landing, Pricing4, Stats11, and Accordion.
- Clean up the codebase by removing unnecessary files to improve maintainability and reduce clutter.
- Ensure that the removal of these components does not affect the existing functionality of the application.
2026-05-07 08:25:55 +02:00
3440508bac Refactor footer component and integrate into landing page
- Remove unnecessary elements and simplify the footer layout
- Update copyright notice and styling for consistency
- Add Footer27 component to the landing page
- Enhance tests to verify footer rendering and legal links
2026-05-06 22:06:43 +02:00
d2ba994fad Add native cookie consent and move hero intro above CTA
- Restore CookieConsent banner behavior with the new dependency
- Reposition the right-panel intro above the Projekt anfragen button
- Add focused tests and update build metadata
2026-05-06 21:22:12 +02:00
ed74fd0652 Clarify cookie and privacy messaging
- Update FAQ copy to mention a concise cookie notice when required
- Add footer note about anonymous, cookiefree analytics via Rybbit
2026-05-06 14:10:35 +02:00
41120664f5 Update landing hero section text and layout for improved clarity and engagement
- Revise main heading and service descriptions to better reflect offerings
- Adjust copyright notice for consistency
- Enhance button and layout styles for better user experience
2026-05-06 11:55:07 +02:00
d92ff8c065 Update landing page copy for clarity and engagement
- Revise text in the landing hero section to better resonate with target audience
- Adjust service descriptions for improved understanding
- Enhance package details for clearer value proposition
- Modify headings and labels for consistency and impact
2026-05-06 09:45:14 +02:00
45 changed files with 3220 additions and 1971 deletions

BIN
.pnpm-store/v11/index.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,65 @@
---
id: TASK-2
title: Animate cookie banner transitions
status: In Progress
assignee: []
created_date: '2026-05-06 18:52'
updated_date: '2026-05-06 19:10'
labels:
- frontend
- animation
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Animate the privacy notice transition between the vanilla-cookieconsent banner and the floating cookie button in both directions using the existing Motion dependency, while preserving consent state and accessibility behavior.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 The banner shrinks animatedly into the round cookie button.
- [ ] #2 The round cookie button opens animatedly back into the banner.
- [x] #3 prefers-reduced-motion receives a reduced fade-only variant.
- [x] #4 The production build completes successfully.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect existing cookie animation and test setup.
2. Add a focused failing test for the exported animation helpers/guards where practical.
3. Implement Motion-based bidirectional banner/FAB transitions with reduced-motion fallback.
4. Run focused verification and production build.
5. Check acceptance criteria and report for manual testing.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented Motion-based bidirectional transition in src/lib/cookie-banner-info.ts using a ghost element, transform/opacity animation, and a reduced-motion fade path.
Verified focused cookie animation test passes with node --test tests/cookie-banner-info.test.mjs.
Verified production build passes with pnpm build.
Full node --test tests/*.mjs currently has an unrelated existing failure in tests/landing-content.test.mjs because the current landing content no longer contains the expected phrase "Projektbrief".
Follow-up bugfix: native CookieConsent wrapper could remain briefly visible during the custom open animation, so the temporary ghost and the native modal appeared together. Fixed by suppressing both #cc-main .cm-wrapper and #cc-main .cm during transitions.
Follow-up bugfix: the accept button listener used a one-shot binding, so after reopening the same modal could close with CookieConsent's native fade instead of the custom banner-to-FAB transition. Fixed by binding accept buttons through a WeakSet without once:true and rebinding after show(true).
Verified follow-up with node --test tests/cookie-banner-info.test.mjs and pnpm build.
Follow-up bugfix after manual report: clicking Verstanden could still leave no visible FAB if the Motion close sequence failed or did not resolve. Removed risky borderRadius/padding from the close Motion group, forced the ghost into layout before native modal suppression, and added an idempotent finalizeBannerToFab fallback timer so the FAB is always restored.
Verified with node --test tests/cookie-banner-info.test.mjs and pnpm build.
Rolled back the custom cookie circle and Motion transition work to the stable native CookieConsent behavior requested by the user: banner appears initially and clicking Verstanden lets CookieConsent close it normally. Removed custom FAB, ghost, Motion import, and event interception from src/lib/cookie-banner-info.ts.
Updated the focused cookie test to assert the native CookieConsent close behavior and absence of the custom FAB/Motion code.
Verified rollback with node --test tests/cookie-banner-info.test.mjs and pnpm build.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,40 @@
---
id: TASK-3
title: Move right-panel intro above inquiry button
status: In Progress
assignee: []
created_date: '2026-05-06 19:20'
updated_date: '2026-05-06 19:21'
labels: []
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Reposition the red-panel 01 headline and supporting copy so it sits directly above the Projekt anfragen button.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The 01 headline and supporting copy appear above the Projekt anfragen button on the right panel
- [x] #2 Responsive layout remains intact without overlapping content
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Locate the right-panel intro markup and layout rules
2. Reposition the intro block above the CTA while preserving responsive spacing
3. Run focused checks and report verification
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Moved the right-panel intro block out of vertical centering and placed it directly above the project inquiry CTA. Removed the CTA mt-auto so it follows the intro block naturally.
Verification: pnpm build passed.
Additional verification: node --test tests/landing-content.test.mjs tests/cookie-banner-info.test.mjs fails because tests/landing-content.test.mjs still expects the removed/renamed phrase Projektbrief. This appears unrelated to the layout-only change; pnpm build remains passing.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,55 @@
---
id: TASK-4
title: Add legal footer and pages
status: In Progress
assignee: []
created_date: '2026-05-06 19:43'
updated_date: '2026-05-06 19:51'
labels: []
dependencies: []
modified_files:
- src/pages/index.astro
- src/components/footer27.tsx
- src/pages/impressum.astro
- src/pages/datenschutz.astro
- tests/landing-content.test.mjs
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Bind the existing footer into the landing page and add real Impressum and Datenschutz routes so the legal links resolve correctly.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Landing page renders the footer component below the main landing content
- [x] #2 Footer links point to /impressum and /datenschutz
- [x] #3 /impressum contains the provided business and VAT ID information
- [x] #4 /datenschutz states that no cookies are used and documents the current Rybbit analytics usage
- [x] #5 Automated content tests and Astro build pass
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing content tests for footer rendering and legal pages
2. Implement footer wiring and legal Astro pages
3. Run automated tests and build
4. Check off verified acceptance criteria and leave task In Progress for user confirmation
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented footer rendering on the landing page and added /impressum plus /datenschutz Astro routes.
Verified with node --test tests/*.mjs and env CI=true pnpm build. The first sandboxed build attempt failed because pnpm needed network access to recreate node_modules; the escalated build completed successfully.
Task remains In Progress pending explicit user confirmation before moving to Done.
Adjusted footer per visual feedback: removed the CTA/contact block and kept only the compact copyright, analytics note, Impressum, and Datenschutz row. Footer now uses the same horizontal page padding as the landing sections instead of max-w-6xl centering.
Re-verified after the footer adjustment with node --test tests/*.mjs and env CI=true pnpm build.
<!-- SECTION:NOTES:END -->

View File

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

View File

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

View File

@@ -28,6 +28,7 @@
"Authorization": "Bearer ${SHADCNBLOCKS_API_KEY}" "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"
} }
} }

View File

@@ -21,6 +21,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"maplibre-gl": "^5.24.0",
"motion": "^12.38.0", "motion": "^12.38.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.5", "react": "^19.2.5",
@@ -30,6 +31,7 @@
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.3", "tailwindcss": "^4.2.3",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"vanilla-cookieconsent": "^3.1.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {

198
pnpm-lock.yaml generated
View File

@@ -35,6 +35,9 @@ importers:
lucide-react: lucide-react:
specifier: ^1.8.0 specifier: ^1.8.0
version: 1.8.0(react@19.2.5) version: 1.8.0(react@19.2.5)
maplibre-gl:
specifier: ^5.24.0
version: 5.24.0
motion: motion:
specifier: ^12.38.0 specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) version: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -62,6 +65,9 @@ importers:
tw-animate-css: tw-animate-css:
specifier: ^1.4.0 specifier: ^1.4.0
version: 1.4.0 version: 1.4.0
vanilla-cookieconsent:
specifier: ^3.1.0
version: 3.1.0
zod: zod:
specifier: ^4.3.6 specifier: ^4.3.6
version: 4.3.6 version: 4.3.6
@@ -654,6 +660,42 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 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': '@modelcontextprotocol/sdk@1.29.0':
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1703,6 +1745,9 @@ packages:
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/hast@3.0.4': '@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
@@ -1732,6 +1777,9 @@ packages:
'@types/statuses@2.0.6': '@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
'@types/supercluster@7.1.3':
resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==}
'@types/unist@3.0.3': '@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@@ -1740,6 +1788,7 @@ packages:
'@ungap/structured-clone@1.3.0': '@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@vitejs/plugin-react@5.2.0': '@vitejs/plugin-react@5.2.0':
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
@@ -2108,6 +2157,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
earcut@3.0.2:
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==}
eciesjs@0.4.18: eciesjs@0.4.18:
resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==}
engines: {bun: '>=1', deno: '>=2', node: '>=16'} engines: {bun: '>=1', deno: '>=2', node: '>=16'}
@@ -2369,6 +2421,9 @@ packages:
github-slugger@2.0.0: github-slugger@2.0.0:
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
gl-matrix@3.4.4:
resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==}
glob-parent@5.1.2: glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -2598,6 +2653,9 @@ packages:
json-schema-typed@8.0.2: json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
json-stringify-pretty-compact@4.0.0:
resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==}
json5@2.2.3: json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2606,6 +2664,9 @@ packages:
jsonfile@6.2.1: jsonfile@6.2.1:
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
kdbush@4.0.2:
resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==}
kleur@3.0.3: kleur@3.0.3:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2716,6 +2777,10 @@ packages:
magicast@0.5.2: magicast@0.5.2:
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} 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: markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
@@ -2931,6 +2996,9 @@ packages:
typescript: typescript:
optional: true optional: true
murmurhash-js@1.0.0:
resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==}
mute-stream@3.0.0: mute-stream@3.0.0:
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
engines: {node: ^20.17.0 || >=22.9.0} engines: {node: ^20.17.0 || >=22.9.0}
@@ -3091,6 +3159,10 @@ packages:
path-to-regexp@8.4.2: path-to-regexp@8.4.2:
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
pbf@4.0.1:
resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==}
hasBin: true
piccolore@0.1.3: piccolore@0.1.3:
resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==}
@@ -3117,6 +3189,9 @@ packages:
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
potpack@2.1.0:
resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==}
powershell-utils@0.1.0: powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -3136,6 +3211,9 @@ packages:
property-information@7.1.0: property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
protocol-buffers-schema@3.6.1:
resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==}
proxy-addr@2.0.7: proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -3147,6 +3225,9 @@ packages:
queue-microtask@1.2.3: queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
quickselect@3.0.0:
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
radix-ui@1.4.3: radix-ui@1.4.3:
resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==}
peerDependencies: peerDependencies:
@@ -3277,6 +3358,9 @@ packages:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
resolve-protobuf-schema@2.1.0:
resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==}
restore-cursor@5.1.0: restore-cursor@5.1.0:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -3456,6 +3540,9 @@ packages:
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
engines: {node: '>=18'} engines: {node: '>=18'}
supercluster@8.0.1:
resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==}
svgo@4.0.1: svgo@4.0.1:
resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==}
engines: {node: '>=16'} engines: {node: '>=16'}
@@ -3493,6 +3580,9 @@ packages:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tinyqueue@3.0.0:
resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==}
tldts-core@7.0.28: tldts-core@7.0.28:
resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==}
@@ -3709,6 +3799,9 @@ packages:
resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==}
engines: {node: ^20.17.0 || >=22.9.0} engines: {node: ^20.17.0 || >=22.9.0}
vanilla-cookieconsent@3.1.0:
resolution: {integrity: sha512-/McNRtm/3IXzb9dhqMIcbquoU45SzbN2VB+To4jxEPqMmp7uVniP6BhGLjU8MC7ZCDsNQVOp27fhQTM/ruIXAA==}
vary@1.1.2: vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -4405,6 +4498,51 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@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)': '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
dependencies: dependencies:
'@hono/node-server': 1.19.14(hono@4.12.14) '@hono/node-server': 1.19.14(hono@4.12.14)
@@ -5448,6 +5586,8 @@ snapshots:
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/geojson@7946.0.16': {}
'@types/hast@3.0.4': '@types/hast@3.0.4':
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
@@ -5480,6 +5620,10 @@ snapshots:
'@types/statuses@2.0.6': {} '@types/statuses@2.0.6': {}
'@types/supercluster@7.1.3':
dependencies:
'@types/geojson': 7946.0.16
'@types/unist@3.0.3': {} '@types/unist@3.0.3': {}
'@types/validate-npm-package-name@4.0.2': {} '@types/validate-npm-package-name@4.0.2': {}
@@ -5885,6 +6029,8 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
gopd: 1.2.0 gopd: 1.2.0
earcut@3.0.2: {}
eciesjs@0.4.18: eciesjs@0.4.18:
dependencies: dependencies:
'@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0)
@@ -6184,6 +6330,8 @@ snapshots:
github-slugger@2.0.0: {} github-slugger@2.0.0: {}
gl-matrix@3.4.4: {}
glob-parent@5.1.2: glob-parent@5.1.2:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
@@ -6418,6 +6566,8 @@ snapshots:
json-schema-typed@8.0.2: {} json-schema-typed@8.0.2: {}
json-stringify-pretty-compact@4.0.0: {}
json5@2.2.3: {} json5@2.2.3: {}
jsonfile@6.2.1: jsonfile@6.2.1:
@@ -6426,6 +6576,8 @@ snapshots:
optionalDependencies: optionalDependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
kdbush@4.0.2: {}
kleur@3.0.3: {} kleur@3.0.3: {}
kleur@4.1.5: {} kleur@4.1.5: {}
@@ -6508,6 +6660,28 @@ snapshots:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
source-map-js: 1.2.1 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: {} markdown-table@3.0.4: {}
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
@@ -6897,6 +7071,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@types/node' - '@types/node'
murmurhash-js@1.0.0: {}
mute-stream@3.0.0: {} mute-stream@3.0.0: {}
nanoid@3.3.11: {} nanoid@3.3.11: {}
@@ -7052,6 +7228,10 @@ snapshots:
path-to-regexp@8.4.2: {} path-to-regexp@8.4.2: {}
pbf@4.0.1:
dependencies:
resolve-protobuf-schema: 2.1.0
piccolore@0.1.3: {} piccolore@0.1.3: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
@@ -7073,6 +7253,8 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
potpack@2.1.0: {}
powershell-utils@0.1.0: {} powershell-utils@0.1.0: {}
pretty-ms@9.3.0: pretty-ms@9.3.0:
@@ -7088,6 +7270,8 @@ snapshots:
property-information@7.1.0: {} property-information@7.1.0: {}
protocol-buffers-schema@3.6.1: {}
proxy-addr@2.0.7: proxy-addr@2.0.7:
dependencies: dependencies:
forwarded: 0.2.0 forwarded: 0.2.0
@@ -7099,6 +7283,8 @@ snapshots:
queue-microtask@1.2.3: {} 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): 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: dependencies:
'@radix-ui/primitive': 1.1.3 '@radix-ui/primitive': 1.1.3
@@ -7305,6 +7491,10 @@ snapshots:
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
resolve-protobuf-schema@2.1.0:
dependencies:
protocol-buffers-schema: 3.6.1
restore-cursor@5.1.0: restore-cursor@5.1.0:
dependencies: dependencies:
onetime: 7.0.0 onetime: 7.0.0
@@ -7602,6 +7792,10 @@ snapshots:
strip-final-newline@4.0.0: {} strip-final-newline@4.0.0: {}
supercluster@8.0.1:
dependencies:
kdbush: 4.0.2
svgo@4.0.1: svgo@4.0.1:
dependencies: dependencies:
commander: 11.1.0 commander: 11.1.0
@@ -7633,6 +7827,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4 picomatch: 4.0.4
tinyqueue@3.0.0: {}
tldts-core@7.0.28: {} tldts-core@7.0.28: {}
tldts@7.0.28: tldts@7.0.28:
@@ -7794,6 +7990,8 @@ snapshots:
validate-npm-package-name@7.0.2: {} validate-npm-package-name@7.0.2: {}
vanilla-cookieconsent@3.1.0: {}
vary@1.1.2: {} vary@1.1.2: {}
vfile-location@5.0.3: vfile-location@5.0.3:

View File

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

View File

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

View File

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

View File

@@ -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. Kein Cookie-Banner-Chaos, kein Abmahnrisiko.",
},
];
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 };

View File

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

View File

@@ -1,6 +1,3 @@
import { ArrowRight, Mail, Phone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface Footer27Props { interface Footer27Props {
@@ -9,47 +6,15 @@ interface Footer27Props {
const Footer27 = ({ className }: Footer27Props) => { const Footer27 = ({ className }: Footer27Props) => {
return ( return (
<footer className={cn("px-4 pb-10 sm:px-6 lg:px-8", className)}> <footer className={cn("px-5 pb-10 sm:px-8 lg:px-12", className)}>
<div className="mx-auto max-w-6xl border-t border-border/80 pt-8"> <div className="border-t border-border/80 pt-6">
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-start"> <div className="flex flex-col gap-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<div> <div className="space-y-1">
<h2 className="max-w-2xl text-3xl font-semibold tracking-tight text-balance sm:text-4xl"> <p>© 2026 Matthias Meister Webdesign</p>
Bereit für eine Website, die Ihr Unternehmen klarer erklärt? <p className="text-xs text-muted-foreground/90">
</h2> Diese Website misst Nutzung anonym und cookiefrei (Rybbit).
<p className="mt-4 max-w-xl text-base leading-7 text-muted-foreground">
Eine kurze Nachricht reicht. Ich prüfe, welcher Weg sinnvoll ist,
und melde mich mit einer ehrlichen Einschätzung.
</p> </p>
<Button asChild size="lg" className="mt-6 h-11 rounded-md px-5">
<a href="#kontakt">
Kostenloses Angebot anfordern
<ArrowRight className="shrink-0" aria-hidden />
</a>
</Button>
</div> </div>
<address className="not-italic">
<div className="space-y-3 text-sm text-muted-foreground">
<a
href="mailto:info@matthias-meister-webdesign.de"
className="flex items-center gap-2 transition-colors hover:text-foreground"
>
<Mail className="size-4" aria-hidden />
info@matthias-meister-webdesign.de
</a>
<a
href="tel:037627984400"
className="flex items-center gap-2 transition-colors hover:text-foreground"
>
<Phone className="size-4" aria-hidden />
03762 798 4400
</a>
</div>
</address>
</div>
<div className="mt-10 flex flex-col gap-4 border-t border-border/80 pt-6 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<p>© 2026 Matthias Meister Webdesign Crimmitschau</p>
<div className="flex flex-wrap items-center gap-x-5 gap-y-2"> <div className="flex flex-wrap items-center gap-x-5 gap-y-2">
<a <a
href="/impressum" href="/impressum"

View File

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

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { ArrowUpRight } from "lucide-react"; import { ArrowUpRight } from "lucide-react";
import { useState } from "react"; import { useEffect, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { WebcamPixelGrid } from "@/components/ui/webcam-pixel-grid"; import { WebcamPixelGrid } from "@/components/ui/webcam-pixel-grid";
@@ -11,6 +11,47 @@ const PRIMARY_HERO_BG = "#B54440";
const LandingHeroSection = () => { const LandingHeroSection = () => {
const [liveRasterOn, setLiveRasterOn] = useState(false); 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 ( return (
<section className="relative grid min-h-screen grid-cols-1 border-b border-border lg:grid-cols-[1.08fr_0.92fr]"> <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> </a>
<nav className="flex flex-wrap gap-5"> <nav className="flex flex-wrap gap-5">
<a href="#leistungen">Leistungen</a> <a href="#leistungen">Leistungen</a>
<a href="#ablauf">Ablauf</a>
<a href="#pakete">Pakete</a> <a href="#pakete">Pakete</a>
<a href="#kontakt">Kontakt</a> <a href="#kontakt">Kontakt</a>
</nav> </nav>
@@ -28,27 +70,27 @@ const LandingHeroSection = () => {
<div className="max-w-5xl py-16 sm:py-20 lg:py-24"> <div className="max-w-5xl py-16 sm:py-20 lg:py-24">
<p className="mb-6 max-w-sm text-sm uppercase tracking-[0.32em] text-primary"> <p className="mb-6 max-w-sm text-sm uppercase tracking-[0.32em] text-primary">
Projektbrief für regionale Unternehmen Für Betriebe, die lieber arbeiten als basteln
</p> </p>
<h1 className="max-w-[11ch] text-[clamp(4.25rem,13vw,11.5rem)] font-black uppercase leading-[0.78] tracking-normal text-foreground"> <h1 className="max-w-[11ch] text-[clamp(4.25rem,13vw,11.5rem)] font-black uppercase leading-[0.79] tracking-normal text-foreground">
Website ohne Umweg Online Fertig Passt
</h1> </h1>
<div className="mt-8 grid gap-7 border-t border-border pt-7 lg:grid-cols-[0.72fr_1fr]"> <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"> <p className="text-sm uppercase tracking-[0.24em] text-muted-foreground">
Strategie trifft Umsetzung Ihr Betrieb im Netz ohne Stress
</p> </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 Ich baue Websites für Handwerk, Praxen, Salons und
Dienstleister aus der Region. Direkt, glaubwürdig und so Dienstleister aus der Region klar genug, dass Besucher
reduziert, dass der nächste Kontakt naheliegt. anrufen statt weiterklicken.
</p> </p>
</div> </div>
</div> </div>
<div className="grid gap-4 border-t border-border pt-5 text-sm uppercase tracking-[0.2em] text-muted-foreground sm:grid-cols-3"> <div className="grid gap-4 border-t border-border pt-5 text-sm uppercase tracking-[0.2em] text-muted-foreground sm:grid-cols-3">
<span>Antwort in 24h</span> <span>Antwort in 24h</span>
<span>DSGVO-arm</span> <span>abmahnsicher</span>
<span>Hosting aus DE</span> <span>Hosting in Sachsen</span>
</div> </div>
</div> </div>
@@ -70,70 +112,73 @@ const LandingHeroSection = () => {
borderColor="#ffffff" borderColor="#ffffff"
borderOpacity={0} borderOpacity={0}
quietWebcamErrors quietWebcamErrors
onWebcamError={() => setLiveRasterOn(false)}
/> />
</div> </div>
) : null} ) : null}
<div className="relative z-10 flex min-h-[520px] flex-1 flex-col lg:min-h-0"> <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]"> <div className="flex shrink-0 items-start justify-between border-b border-primary-foreground/30 pb-5 text-xs uppercase tracking-[0.28em]">
<span>Creative Direction</span> <span className="text-primary-foreground/60">Webdesign</span>
<span>2026</span> <span>&copy;2026</span>
</div> </div>
<div className="hidden shrink-0 flex-col items-end gap-2 pt-4 lg:flex"> {hasWebcam ? (
<p <div className="hidden shrink-0 flex-col items-end gap-2 pt-4 lg:flex">
className={cn( <p
"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 das Raster."}
</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-[3.25rem] shrink-0 rounded-full border transition-colors duration-300 focus-visible:outline focus-visible:outline-2 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( className={cn(
"absolute top-1 left-1 size-7 rounded-full bg-primary-foreground shadow-sm transition-transform duration-300 ease-out", "max-w-[18ch] text-right text-[10px] leading-snug font-medium uppercase tracking-[0.22em] text-primary-foreground/85",
liveRasterOn ? "translate-x-4" : "translate-x-0", !liveRasterOn && "motion-safe:animate-pulse",
)} )}
/> >
</button> {liveRasterOn
</div> ? "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 flex min-h-0 flex-1 flex-col justify-center py-10 lg:py-14"> <div className="relative mt-auto pt-16 pb-8 lg:pt-24 lg:pb-10">
<div className="pointer-events-none absolute bottom-8 right-0 h-36 w-36 border border-primary-foreground/35 sm:bottom-10 sm:right-2 lg:bottom-16 lg:right-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" />
<div className="relative max-w-xl"> <div className="relative max-w-xl">
<p className="text-[clamp(4rem,10vw,9rem)] font-black uppercase leading-[0.75] tracking-normal"> <p className="text-[clamp(4rem,10vw,9rem)] font-black uppercase leading-[0.75] tracking-normal">
01 01
</p> </p>
<p className="mt-7 max-w-sm text-2xl font-semibold uppercase leading-none"> <p className="mt-7 max-w-sm text-2xl font-semibold uppercase leading-none">
Klarer Auftritt. Harte Kante. Weniger Agenturlärm. Geradeaus. Ohne Buzzwords. Für echte Betriebe.
</p> </p>
</div> </div>
</div> </div>
<a <a
href="#kontakt" href="#kontakt"
className="group relative z-10 mt-auto 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" 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" /> <ArrowUpRight className="size-5 transition group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
</a> </a>
</div> </div>

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

View File

@@ -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, Beweise, Ablauf und Kontakt werden so sortiert, dass Besucher nicht suchen müssen.",
},
{
number: "03",
title: "Technik",
text: "Schnell, mobil sauber, DSGVO-arm und so gebaut, dass spätere Änderungen nicht zum Projekt werden.",
},
];
const deliverables = [
"Strategie und Seitenstruktur",
"Individuelles Screen-Design",
"Astro/React 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 erklärungsbedürftigem Angebot.",
},
{
name: "Maßarbeit",
price: "2.499 EUR+",
detail: "Individuelle Struktur, CMS und 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">
Vom Brief zur Seite
</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">
Deliverables (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 vom Projekt
</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
leisten, 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>Regionale KMU in Deutschland</span>
</div>
</div>
</section>
</>
);
};
export { LandingRest };

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

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

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

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

View 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 24 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 };

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -165,6 +165,10 @@ export const WebcamPixelGrid: React.FC<WebcamPixelGridProps> = ({
// Request camera access // Request camera access
const requestCameraAccess = useCallback(async () => { const requestCameraAccess = useCallback(async () => {
try { try {
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error("Webcam access is not available in this browser");
}
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
video: { video: {
width: { ideal: 640 }, width: { ideal: 640 },

View File

@@ -0,0 +1,38 @@
import { run } from "vanilla-cookieconsent";
import "vanilla-cookieconsent/dist/cookieconsent.css";
const bannerText =
"Diese Website setzt keine Cookies. Wir erfassen ausschließlich anonymisierte Analytics-Daten, die keinen Rückschluss auf einzelne Nutzer zulassen.";
export function initCookieInfoBanner(): void {
if (typeof globalThis.window === "undefined") return;
void run({
hideFromBots: false,
categories: {
necessary: {
enabled: true,
readOnly: true,
},
},
cookie: {
name: "cc_notice",
useLocalStorage: true,
expiresAfterDays: 365,
},
language: {
default: "de",
translations: {
de: {
consentModal: {
title: "Datenschutzhinweis",
description: bannerText,
acceptNecessaryBtn: "Verstanden",
},
},
},
},
}).catch((err) => {
console.error("[CookieBanner]", err);
});
}

153
src/pages/datenschutz.astro Normal file
View File

@@ -0,0 +1,153 @@
---
import "@/styles/global.css";
---
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Datenschutz | Matthias Meister Softwareentwicklung</title>
<script
src="https://rybbit.matthias.lol/api/script.js"
data-site-id="60abc81e438a"
defer></script>
</head>
<body>
<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"
>
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 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"
>
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"
>
Verantwortlicher
</h2>
<div class="mt-4 space-y-1 text-muted-foreground">
<p>Matthias Meister Softwareentwicklung</p>
<p>Inhaber: Matthias Meister</p>
<p>Karl-Marx-Str. 22</p>
<p>08451 Crimmitschau</p>
<p>
E-Mail:
<a
class="underline underline-offset-4 transition hover:text-foreground"
href="mailto:support@matthias-meister-webdesign.de"
>
support@matthias-meister-webdesign.de
</a>
</p>
</div>
</section>
<section>
<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.
</p>
</section>
<section>
<h2
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
>
Kontakt per E-Mail
</h2>
<p class="mt-4 text-muted-foreground">
Wenn Sie über einen mailto-Link Kontakt aufnehmen, werden die von
Ihnen per E-Mail übermittelten Angaben zur Bearbeitung Ihrer
Anfrage verarbeitet. Auf dieser Website wird dafür kein
Kontaktformular gespeichert.
</p>
</section>
<section>
<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, um anonymisierte und
aggregierte Statistiken zur Nutzung der Website zu erhalten.
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.
</p>
</section>
<section>
<h2
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
>
Ihre Rechte
</h2>
<p class="mt-4 text-muted-foreground">
Sie haben im Rahmen der gesetzlichen Vorgaben insbesondere Rechte
auf Auskunft, Berichtigung, Löschung, Einschränkung der
Verarbeitung und Widerspruch gegen die Verarbeitung Ihrer
personenbezogenen Daten. Bitte wenden Sie sich dafür an die oben
genannte Kontaktadresse.
</p>
</section>
<p class="border-t border-border pt-8 text-sm text-muted-foreground">
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="/"
>
Startseite
</a>
<a
class="underline underline-offset-4 transition hover:text-foreground"
href="/impressum"
>
Impressum
</a>
</nav>
</div>
</section>
</main>
</body>
</html>

106
src/pages/impressum.astro Normal file
View File

@@ -0,0 +1,106 @@
---
import "@/styles/global.css";
---
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Impressum | Matthias Meister Softwareentwicklung</title>
<script
src="https://rybbit.matthias.lol/api/script.js"
data-site-id="60abc81e438a"
defer></script>
</head>
<body>
<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"
>
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>
<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-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"
>
Angaben gemäß § 5 TMG
</h2>
<div class="mt-4 space-y-1 text-muted-foreground">
<p>Matthias Meister Softwareentwicklung</p>
<p>Inhaber: Matthias Meister</p>
<p>Karl-Marx-Str. 22</p>
<p>08451 Crimmitschau</p>
</div>
</section>
<section>
<h2
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
>
Umsatzsteuer
</h2>
<p class="mt-4 text-muted-foreground">
Umsatzsteuer-Identifikationsnummer: DE460155697
</p>
</section>
<section>
<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:support@matthias-meister-webdesign.de"
>
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="/"
>
Startseite
</a>
<a
class="underline underline-offset-4 transition hover:text-foreground"
href="/datenschutz"
>
Datenschutz
</a>
</nav>
</div>
</section>
</main>
</body>
</html>

View File

@@ -1,6 +1,8 @@
--- ---
import { LandingHeroSection } from "@/components/landing-hero-section"; 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"; import "@/styles/global.css";
--- ---
@@ -20,7 +22,13 @@ import "@/styles/global.css";
<body> <body>
<main class="min-h-screen overflow-hidden bg-background text-foreground"> <main class="min-h-screen overflow-hidden bg-background text-foreground">
<LandingHeroSection client:media="(min-width: 1024px)" /> <LandingHeroSection client:media="(min-width: 1024px)" />
<LandingRest /> <LandingPageSections />
<ContactSection client:visible />
<Footer27 />
</main> </main>
<script>
import { initCookieInfoBanner } from "@/lib/cookie-banner-info";
initCookieInfoBanner();
</script>
</body> </body>
</html> </html>

View File

@@ -129,4 +129,31 @@
@apply font-sans; @apply font-sans;
scroll-behavior: smooth; 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;
}
}

View File

@@ -0,0 +1,14 @@
import { readFile } from "node:fs/promises";
import test from "node:test";
import assert from "node:assert/strict";
const sourcePath = new URL("../src/lib/cookie-banner-info.ts", import.meta.url);
test("cookie banner uses the native CookieConsent close behavior", async () => {
const source = await readFile(sourcePath, "utf8");
assert.match(source, /from "vanilla-cookieconsent"/);
assert.match(source, /acceptNecessaryBtn: "Verstanden"/);
assert.doesNotMatch(source, /motion\/mini/);
assert.doesNotMatch(source, /cookie-info-fab/);
});

View File

@@ -3,16 +3,24 @@ import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
const sourcePaths = [ 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-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);
const indexPath = new URL("../src/pages/index.astro", import.meta.url);
const impressumPath = new URL("../src/pages/impressum.astro", import.meta.url);
const datenschutzPath = new URL("../src/pages/datenschutz.astro", import.meta.url);
test("Landing component contains the core brief anchors", async () => { test("Landing component contains the core brief anchors", async () => {
const source = ( const source = (
await Promise.all(sourcePaths.map((p) => readFile(p, "utf8"))) await Promise.all(sourcePaths.map((p) => readFile(p, "utf8")))
).join("\n"); ).join("\n");
for (const phrase of ["Projektbrief", "01", "Website", "Kontakt", "für", "müssen", "Änderungen"]) { for (const phrase of ["Online Fertig Passt", "01", "Website", "Kontakt", "für", "müssen", "Änderungen"]) {
assert.match(source, new RegExp(phrase)); assert.match(source, new RegExp(phrase));
} }
}); });
@@ -37,3 +45,44 @@ test("Landing component uses real German umlauts in visible copy", async () => {
assert.doesNotMatch(source, new RegExp(asciiFallback)); assert.doesNotMatch(source, new RegExp(asciiFallback));
} }
}); });
test("Landing page renders the footer with legal links", async () => {
const [indexSource, footerSource] = await Promise.all([
readFile(indexPath, "utf8"),
readFile(footerPath, "utf8"),
]);
assert.match(indexSource, /import\s+\{\s*Footer27\s*\}/);
assert.match(indexSource, /<Footer27\s*\/>/);
assert.match(footerSource, /href="\/impressum"/);
assert.match(footerSource, /href="\/datenschutz"/);
assert.match(footerSource, /© 2026 Matthias Meister Webdesign/);
assert.doesNotMatch(footerSource, /Bereit für eine Website/);
assert.doesNotMatch(footerSource, /Kostenloses Angebot anfordern/);
assert.doesNotMatch(footerSource, /max-w-6xl/);
});
test("Legal pages contain required business and privacy information", async () => {
const [impressumSource, datenschutzSource] = await Promise.all([
readFile(impressumPath, "utf8"),
readFile(datenschutzPath, "utf8"),
]);
for (const phrase of [
"Matthias Meister Softwareentwicklung",
"Karl-Marx-Str. 22",
"08451 Crimmitschau",
"DE460155697",
]) {
assert.match(impressumSource, new RegExp(phrase));
}
for (const phrase of [
"keine Cookies",
"mailto",
"Rybbit",
"rybbit.matthias.lol",
]) {
assert.match(datenschutzSource, new RegExp(phrase));
}
});