Compare commits

...

13 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
6083844c24 Hero: rote Rasterfugen zwischen Webcam-Pixel-Kacheln entfernen
gapRatio 0 und borderOpacity 0, damit der Primary-Hintergrund nicht als rotes Gitter zwischen den Tiles sichtbar wird.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 09:30:34 +02:00
9faca2a859 Split landing hero into interactive webcam grid
- Move hero into its own client component
- Add webcam-backed pixel grid background
- Update landing wiring and content test coverage
2026-05-06 09:10:14 +02:00
2032395472 Add workspace configuration for pnpm 2026-05-06 08:42:23 +02:00
67411ecaff Rename landing component 2026-05-06 08:29:44 +02:00
47 changed files with 3929 additions and 1992 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

@@ -12,6 +12,8 @@
}, },
"iconLibrary": "lucide", "iconLibrary": "lucide",
"rtl": false, "rtl": false,
"menuColor": "default",
"menuAccent": "subtle",
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils", "utils": "@/lib/utils",
@@ -19,14 +21,14 @@
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
"menuColor": "default",
"menuAccent": "subtle",
"registries": { "registries": {
"@shadcnblocks": { "@shadcnblocks": {
"url": "https://shadcnblocks.com/r/{name}", "url": "https://shadcnblocks.com/r/{name}",
"headers": { "headers": {
"Authorization": "Bearer ${SHADCNBLOCKS_API_KEY}" "Authorization": "Bearer ${SHADCNBLOCKS_API_KEY}"
} }
} },
"@aceternity": "https://ui.aceternity.com/registry/{name}.json",
"@mapcn": "https://mapcn.dev/r/{name}.json"
} }
} }

View File

@@ -22,12 +22,12 @@
### Task 2: Canva-Inspired Landing Page ### Task 2: Canva-Inspired Landing Page
**Files:** **Files:**
- Create: `src/components/canva-landing.tsx` - Create: `src/components/landing.tsx`
- Modify: `src/pages/index.astro` - Modify: `src/pages/index.astro`
- Modify: `src/styles/global.css` - Modify: `src/styles/global.css`
- [ ] Build a single-page layout with a dark editorial shell, red accent panels, large German headline, numbered sections, pricing/service strips, and a contact brief section. - [ ] Build a single-page layout with a dark editorial shell, red accent panels, large German headline, numbered sections, pricing/service strips, and a contact brief section.
- [ ] Replace the existing homepage component stack with the new `CanvaLanding` component. - [ ] Replace the existing homepage component stack with the new `Landing` component.
- [ ] Update global tokens for the dark, high-contrast Canva reference style. - [ ] Update global tokens for the dark, high-contrast Canva reference style.
### Task 3: Verification ### Task 3: Verification

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,3 +1,6 @@
packages:
- "."
allowBuilds: allowBuilds:
esbuild: true esbuild: true
msw: true msw: true

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,249 +0,0 @@
import {
ArrowUpRight,
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 CanvaLanding = () => {
return (
<main className="min-h-screen overflow-hidden bg-background text-foreground">
<section className="relative grid min-h-screen grid-cols-1 border-b border-border lg:grid-cols-[1.08fr_0.92fr]">
<div className="flex min-h-[640px] flex-col justify-between px-5 py-5 sm:px-8 lg:px-12">
<header className="grid gap-4 border-b border-border pb-5 text-xs uppercase tracking-[0.28em] text-muted-foreground sm:grid-cols-[1fr_auto]">
<a href="/" className="font-semibold text-foreground">
Matthias Meister
</a>
<nav className="flex flex-wrap gap-5">
<a href="#leistungen">Leistungen</a>
<a href="#pakete">Pakete</a>
<a href="#kontakt">Kontakt</a>
</nav>
</header>
<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">
Projektbrief für regionale Unternehmen
</p>
<h1 className="max-w-[11ch] text-[clamp(4.25rem,13vw,11.5rem)] font-black uppercase leading-[0.78] tracking-normal text-foreground">
Website ohne Umweg
</h1>
<div className="mt-8 grid gap-7 border-t border-border pt-7 lg:grid-cols-[0.72fr_1fr]">
<p className="text-sm uppercase tracking-[0.24em] text-muted-foreground">
Strategie trifft Umsetzung
</p>
<p className="max-w-2xl text-lg leading-8 text-foreground/78">
Ich baue Websites für Handwerk, Praxen, Salons und
Dienstleister aus der Region. Direkt, glaubwürdig und so
reduziert, dass der nächste Kontakt naheliegt.
</p>
</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">
<span>Antwort in 24h</span>
<span>DSGVO-arm</span>
<span>Hosting aus DE</span>
</div>
</div>
<aside className="relative flex min-h-[520px] flex-col justify-between border-t border-primary-foreground/25 bg-primary px-5 py-5 text-primary-foreground sm:px-8 lg:border-l lg:border-t-0 lg:px-12">
<div className="flex items-start justify-between border-b border-primary-foreground/30 pb-5 text-xs uppercase tracking-[0.28em]">
<span>Creative Direction</span>
<span>2026</span>
</div>
<div className="absolute bottom-32 right-10 h-36 w-36 border border-primary-foreground/35" />
<div className="relative mt-28 max-w-xl">
<p className="text-[clamp(4rem,10vw,9rem)] font-black uppercase leading-[0.75] tracking-normal">
01
</p>
<p className="mt-7 max-w-sm text-2xl font-semibold uppercase leading-none">
Klarer Auftritt. Harte Kante. Weniger Agenturlärm.
</p>
</div>
<a
href="#kontakt"
className="group inline-flex w-fit 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
<ArrowUpRight className="size-5 transition group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
</a>
</aside>
</section>
<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>
</main>
);
};
export { CanvaLanding };

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

@@ -0,0 +1,190 @@
"use client";
import { ArrowUpRight } from "lucide-react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { WebcamPixelGrid } from "@/components/ui/webcam-pixel-grid";
/** Canvas-Hintergrund = Basiston der Raster-Palette (schließt optisch an bg-primary an). */
const PRIMARY_HERO_BG = "#B54440";
const LandingHeroSection = () => {
const [liveRasterOn, setLiveRasterOn] = useState(false);
const [hasWebcam, setHasWebcam] = useState(false);
useEffect(() => {
const mediaDevices = navigator.mediaDevices;
if (!mediaDevices?.enumerateDevices) {
setHasWebcam(false);
return;
}
const updateWebcamAvailability = async () => {
try {
const devices = await mediaDevices.enumerateDevices();
const hasVideoInput = devices.some(
(device) => device.kind === "videoinput",
);
setHasWebcam(hasVideoInput);
if (!hasVideoInput) {
setLiveRasterOn(false);
}
} catch {
setHasWebcam(false);
setLiveRasterOn(false);
}
};
updateWebcamAvailability();
mediaDevices.addEventListener?.("devicechange", updateWebcamAvailability);
return () => {
mediaDevices.removeEventListener?.(
"devicechange",
updateWebcamAvailability,
);
};
}, []);
const handleToggleLiveRaster = () => {
setLiveRasterOn((isOn) => !isOn);
};
return (
<section className="relative grid min-h-screen grid-cols-1 border-b border-border lg:grid-cols-[1.08fr_0.92fr]">
<div className="flex min-h-[640px] flex-col justify-between px-5 py-5 sm:px-8 lg:px-12">
<header className="grid gap-4 border-b border-border pb-5 text-xs uppercase tracking-[0.28em] text-muted-foreground sm:grid-cols-[1fr_auto]">
<a href="/" className="font-semibold text-foreground">
Matthias Meister
</a>
<nav className="flex flex-wrap gap-5">
<a href="#leistungen">Leistungen</a>
<a href="#ablauf">Ablauf</a>
<a href="#pakete">Pakete</a>
<a href="#kontakt">Kontakt</a>
</nav>
</header>
<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">
Für Betriebe, die lieber arbeiten als basteln
</p>
<h1 className="max-w-[11ch] text-[clamp(4.25rem,13vw,11.5rem)] font-black uppercase leading-[0.79] tracking-normal text-foreground">
Online Fertig Passt
</h1>
<div className="mt-8 grid gap-7 border-t border-border pt-7 lg:grid-cols-[0.72fr_1fr]">
<p className="text-sm uppercase tracking-[0.24em] text-muted-foreground">
Ihr Betrieb im Netz ohne Stress
</p>
<p className="max-w-2xl text-lg leading-8 text-foreground/80">
Ich baue Websites für Handwerk, Praxen, Salons und
Dienstleister aus der Region klar genug, dass Besucher
anrufen statt weiterklicken.
</p>
</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">
<span>Antwort in 24h</span>
<span>abmahnsicher</span>
<span>Hosting in Sachsen</span>
</div>
</div>
<aside className="relative flex min-h-[520px] flex-col overflow-hidden border-t border-primary-foreground/25 bg-primary px-5 py-5 text-primary-foreground sm:px-8 lg:min-h-screen lg:border-t-0 lg:border-l lg:px-12">
{liveRasterOn ? (
<div className="pointer-events-none absolute inset-0 z-0 hidden min-h-full lg:block">
<WebcamPixelGrid
className="min-h-full"
gridCols={40}
gridRows={30}
maxElevation={10}
motionSensitivity={0.52}
elevationSmoothing={0.12}
colorMode="redscale"
backgroundColor={PRIMARY_HERO_BG}
mirror
gapRatio={0}
darken={0.08}
borderColor="#ffffff"
borderOpacity={0}
quietWebcamErrors
onWebcamError={() => setLiveRasterOn(false)}
/>
</div>
) : null}
<div className="relative z-10 flex min-h-[520px] flex-1 flex-col lg:min-h-0">
<div className="flex shrink-0 items-start justify-between border-b border-primary-foreground/30 pb-5 text-xs uppercase tracking-[0.28em]">
<span className="text-primary-foreground/60">Webdesign</span>
<span>&copy;2026</span>
</div>
{hasWebcam ? (
<div className="hidden shrink-0 flex-col items-end gap-2 pt-4 lg:flex">
<p
className={cn(
"max-w-[18ch] text-right text-[10px] leading-snug font-medium uppercase tracking-[0.22em] text-primary-foreground/85",
!liveRasterOn && "motion-safe:animate-pulse",
)}
>
{liveRasterOn
? "Kamera aus? Schalter zurück, fertig."
: "Psst ... einmal wippen, dann lebt die Seite."}
</p>
<button
type="button"
role="switch"
aria-checked={liveRasterOn}
aria-label={
liveRasterOn
? "Live-Raster beenden"
: "Live-Raster mit Kamera starten"
}
onClick={handleToggleLiveRaster}
className={cn(
"relative h-9 w-13 shrink-0 rounded-full border transition-colors duration-300 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-primary-foreground",
liveRasterOn
? "border-primary-foreground/50 bg-primary-foreground/20"
: "border-primary-foreground/45 bg-primary-foreground/10 hover:border-primary-foreground/70",
)}
>
<span
className={cn(
"absolute top-1 left-1 size-7 rounded-full bg-primary-foreground shadow-sm transition-transform duration-300 ease-out",
liveRasterOn ? "translate-x-4" : "translate-x-0",
)}
/>
</button>
</div>
) : null}
<div className="relative mt-auto pt-16 pb-8 lg:pt-24 lg:pb-10">
<div className="pointer-events-none absolute bottom-0 right-0 h-36 w-36 border border-primary-foreground/35 sm:right-2 lg:right-10" />
<div className="relative max-w-xl">
<p className="text-[clamp(4rem,10vw,9rem)] font-black uppercase leading-[0.75] tracking-normal">
01
</p>
<p className="mt-7 max-w-sm text-2xl font-semibold uppercase leading-none">
Geradeaus. Ohne Buzzwords. Für echte Betriebe.
</p>
</div>
</div>
<a
href="#kontakt"
className="group relative z-10 inline-flex w-fit shrink-0 items-center gap-3 border border-primary-foreground px-5 py-4 text-sm font-semibold uppercase tracking-[0.18em] transition hover:bg-primary-foreground hover:text-primary"
>
Unverbindlich anfragen
<ArrowUpRight className="size-5 transition group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
</a>
</div>
</aside>
</section>
);
};
export { LandingHeroSection };

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

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

@@ -0,0 +1,596 @@
"use client";
import React, { useRef, useEffect, useState, useCallback } from "react";
import { cn } from "@/lib/utils";
/** Rotstufen: dunkel → hell (Helligkeit der Webcam steuert Position im Verlauf). */
const DEFAULT_REDSCALE_STOPS: string[] = [
"#4B1C1B",
"#6A2725",
"#883330",
"#B54440",
"#C55D59",
"#CF7A77",
];
function parseHexRgb(hex: string): { r: number; g: number; b: number } {
const h = hex.replace("#", "");
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
};
}
function sampleRedscale(
t: number,
stops: { r: number; g: number; b: number }[],
): { r: number; g: number; b: number } {
if (stops.length === 0) return { r: 0, g: 0, b: 0 };
if (stops.length === 1) return stops[0];
const tClamped = Math.min(1, Math.max(0, t));
const scaled = tClamped * (stops.length - 1);
const i = Math.min(stops.length - 2, Math.floor(scaled));
const f = scaled - i;
const a = stops[i];
const b = stops[i + 1];
return {
r: Math.round(a.r + (b.r - a.r) * f),
g: Math.round(a.g + (b.g - a.g) * f),
b: Math.round(a.b + (b.b - a.b) * f),
};
}
type WebcamPixelGridProps = {
/** Number of columns in the grid */
gridCols?: number;
/** Number of rows in the grid */
gridRows?: number;
/** Maximum elevation for motion detection */
maxElevation?: number;
/** Motion sensitivity (0-1) */
motionSensitivity?: number;
/** Smoothing factor for elevation transitions */
elevationSmoothing?: number;
/** Color mode: webcam = Farben; monochrome = ein Kanal; redscale = Rotstufen nach Helligkeit */
colorMode?: "webcam" | "monochrome" | "redscale";
/** Monochrome mode: einzige Tinte (Hex) */
monochromeColor?: string;
/** Redscale mode: Stufen von dunkel zu hell (Hex), Standard = Marken-Rotverlauf */
redscaleStops?: string[];
/** Background color */
backgroundColor?: string;
/** Whether to mirror the webcam feed */
mirror?: boolean;
/** Gap between cells (0-1, fraction of cell size) */
gapRatio?: number;
/** Invert the colors */
invertColors?: boolean;
/** Darken factor (0-1, 0 = no darkening, 1 = fully dark) */
darken?: number;
/** Border color for cells */
borderColor?: string;
/** Border opacity (0-1) */
borderOpacity?: number;
/** Additional class name */
className?: string;
/** Callback when webcam access is denied */
onWebcamError?: (error: Error) => void;
/** Callback when webcam is ready */
onWebcamReady?: () => void;
/** If true, no toast or floating error UI (still calls onWebcamError) */
quietWebcamErrors?: boolean;
};
type PixelData = {
r: number;
g: number;
b: number;
motion: number;
targetElevation: number;
currentElevation: number;
};
export const WebcamPixelGrid: React.FC<WebcamPixelGridProps> = ({
gridCols = 64,
gridRows = 48,
maxElevation = 15,
motionSensitivity = 0.4,
elevationSmoothing = 0.1,
colorMode = "webcam",
monochromeColor = "#00ff88",
backgroundColor = "#0a0a0a",
mirror = true,
gapRatio = 0.1,
invertColors = false,
darken = 0,
borderColor = "#ffffff",
borderOpacity = 0.08,
className,
onWebcamError,
onWebcamReady,
quietWebcamErrors = false,
redscaleStops = DEFAULT_REDSCALE_STOPS,
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const processingCanvasRef = useRef<HTMLCanvasElement>(null);
const displayCanvasRef = useRef<HTMLCanvasElement>(null);
const previousFrameRef = useRef<Uint8ClampedArray | null>(null);
const pixelDataRef = useRef<PixelData[][]>([]);
const animationRef = useRef<number>(0);
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showErrorPopup, setShowErrorPopup] = useState(true);
// Parse monochrome color
const monoRGB = React.useMemo(() => {
const hex = monochromeColor.replace("#", "");
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
};
}, [monochromeColor]);
const redscaleRGBStops = React.useMemo(
() => redscaleStops.map(parseHexRgb),
[redscaleStops],
);
// Parse border color
const borderRGB = React.useMemo(() => {
const hex = borderColor.replace("#", "");
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
};
}, [borderColor]);
// Initialize pixel data
useEffect(() => {
pixelDataRef.current = Array.from({ length: gridRows }, () =>
Array.from({ length: gridCols }, () => ({
r: 30,
g: 30,
b: 30,
motion: 0,
targetElevation: 0,
currentElevation: 0,
})),
);
}, [gridCols, gridRows]);
const streamRef = useRef<MediaStream | null>(null);
// Request camera access
const requestCameraAccess = useCallback(async () => {
try {
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error("Webcam access is not available in this browser");
}
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 640 },
height: { ideal: 480 },
facingMode: "user",
},
});
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
setIsReady(true);
setError(null);
setShowErrorPopup(false);
onWebcamReady?.();
}
} catch (err) {
const error =
err instanceof Error ? err : new Error("Webcam access denied");
setError(error.message);
onWebcamError?.(error);
}
}, [onWebcamError, onWebcamReady]);
// Initialize webcam on mount
useEffect(() => {
requestCameraAccess();
return () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
}
};
}, [requestCameraAccess]);
// Main render loop
const render = useCallback(() => {
const video = videoRef.current;
const processingCanvas = processingCanvasRef.current;
const displayCanvas = displayCanvasRef.current;
if (!video || !processingCanvas || !displayCanvas || video.readyState < 2) {
animationRef.current = requestAnimationFrame(render);
return;
}
const procCtx = processingCanvas.getContext("2d", {
willReadFrequently: true,
});
const dispCtx = displayCanvas.getContext("2d");
if (!procCtx || !dispCtx) {
animationRef.current = requestAnimationFrame(render);
return;
}
// Set processing canvas size to grid dimensions
processingCanvas.width = gridCols;
processingCanvas.height = gridRows;
// Draw video to processing canvas (scaled down)
procCtx.save();
if (mirror) {
procCtx.scale(-1, 1);
procCtx.drawImage(video, -gridCols, 0, gridCols, gridRows);
} else {
procCtx.drawImage(video, 0, 0, gridCols, gridRows);
}
procCtx.restore();
// Get pixel data
const imageData = procCtx.getImageData(0, 0, gridCols, gridRows);
const currentData = imageData.data;
const previousData = previousFrameRef.current;
// Update pixel data with motion detection
const pixels = pixelDataRef.current;
for (let row = 0; row < gridRows; row++) {
for (let col = 0; col < gridCols; col++) {
const idx = (row * gridCols + col) * 4;
const r = currentData[idx];
const g = currentData[idx + 1];
const b = currentData[idx + 2];
const pixel = pixels[row]?.[col];
if (!pixel) continue;
// Calculate motion
let motion = 0;
if (previousData) {
const prevR = previousData[idx];
const prevG = previousData[idx + 1];
const prevB = previousData[idx + 2];
const diff =
Math.abs(r - prevR) + Math.abs(g - prevG) + Math.abs(b - prevB);
motion = Math.min(1, diff / 255 / motionSensitivity);
}
// Smooth motion
pixel.motion = pixel.motion * 0.7 + motion * 0.3;
// Set colors
let finalR = r;
let finalG = g;
let finalB = b;
if (colorMode === "monochrome") {
const brightness = (r + g + b) / 3 / 255;
finalR = Math.round(monoRGB.r * brightness);
finalG = Math.round(monoRGB.g * brightness);
finalB = Math.round(monoRGB.b * brightness);
} else if (colorMode === "redscale") {
const brightness = (r + g + b) / 3 / 255;
const t = Math.pow(Math.min(1, Math.max(0, brightness)), 0.88);
const { r: sr, g: sg, b: sb } = sampleRedscale(t, redscaleRGBStops);
finalR = sr;
finalG = sg;
finalB = sb;
}
// Apply invert
if (invertColors) {
finalR = 255 - finalR;
finalG = 255 - finalG;
finalB = 255 - finalB;
}
// Apply darken
if (darken > 0) {
const darkenFactor = 1 - darken;
finalR = Math.round(finalR * darkenFactor);
finalG = Math.round(finalG * darkenFactor);
finalB = Math.round(finalB * darkenFactor);
}
pixel.r = finalR;
pixel.g = finalG;
pixel.b = finalB;
// Set target elevation
pixel.targetElevation = pixel.motion * maxElevation;
// Smooth elevation transition
pixel.currentElevation +=
(pixel.targetElevation - pixel.currentElevation) * elevationSmoothing;
}
}
// Store current frame for next comparison
previousFrameRef.current = new Uint8ClampedArray(currentData);
// Render to display canvas
const dpr = window.devicePixelRatio || 1;
const displayWidth = displayCanvas.clientWidth;
const displayHeight = displayCanvas.clientHeight;
displayCanvas.width = displayWidth * dpr;
displayCanvas.height = displayHeight * dpr;
dispCtx.scale(dpr, dpr);
// Clear canvas
dispCtx.fillStyle = backgroundColor;
dispCtx.fillRect(0, 0, displayWidth, displayHeight);
// Calculate cell size (always square, cover entire container like object-fit: cover)
const cellSize = Math.max(
displayWidth / gridCols,
displayHeight / gridRows,
);
const gap = cellSize * gapRatio;
// Calculate offset to center the grid (negative offset for overflow, creating cover effect)
const gridWidth = cellSize * gridCols;
const gridHeight = cellSize * gridRows;
const offsetXGrid = (displayWidth - gridWidth) / 2;
const offsetYGrid = (displayHeight - gridHeight) / 2;
// Draw cells with 3D effect
for (let row = 0; row < gridRows; row++) {
for (let col = 0; col < gridCols; col++) {
const pixel = pixels[row]?.[col];
if (!pixel) continue;
const x = offsetXGrid + col * cellSize;
const y = offsetYGrid + row * cellSize;
const elevation = pixel.currentElevation;
// Calculate 3D offset (isometric-like projection) - MUCH larger effect
const offsetX = -elevation * 1.2;
const offsetY = -elevation * 1.8;
// Draw shadow - larger and more visible
if (elevation > 0.5) {
dispCtx.fillStyle = `rgba(0, 0, 0, ${Math.min(0.6, elevation * 0.04)})`;
dispCtx.fillRect(
x + gap / 2 + elevation * 1.5,
y + gap / 2 + elevation * 2.0,
cellSize - gap,
cellSize - gap,
);
}
// Draw side faces for 3D effect - thicker sides
if (elevation > 0.5) {
// Right side
dispCtx.fillStyle = `rgb(${Math.max(0, pixel.r - 80)}, ${Math.max(0, pixel.g - 80)}, ${Math.max(0, pixel.b - 80)})`;
dispCtx.beginPath();
dispCtx.moveTo(
x + cellSize - gap / 2 + offsetX,
y + gap / 2 + offsetY,
);
dispCtx.lineTo(x + cellSize - gap / 2, y + gap / 2);
dispCtx.lineTo(x + cellSize - gap / 2, y + cellSize - gap / 2);
dispCtx.lineTo(
x + cellSize - gap / 2 + offsetX,
y + cellSize - gap / 2 + offsetY,
);
dispCtx.closePath();
dispCtx.fill();
// Bottom side
dispCtx.fillStyle = `rgb(${Math.max(0, pixel.r - 50)}, ${Math.max(0, pixel.g - 50)}, ${Math.max(0, pixel.b - 50)})`;
dispCtx.beginPath();
dispCtx.moveTo(
x + gap / 2 + offsetX,
y + cellSize - gap / 2 + offsetY,
);
dispCtx.lineTo(x + gap / 2, y + cellSize - gap / 2);
dispCtx.lineTo(x + cellSize - gap / 2, y + cellSize - gap / 2);
dispCtx.lineTo(
x + cellSize - gap / 2 + offsetX,
y + cellSize - gap / 2 + offsetY,
);
dispCtx.closePath();
dispCtx.fill();
}
// Draw top face (main cell) - brighter when elevated
const brightness = 1 + elevation * 0.05;
dispCtx.fillStyle = `rgb(${Math.min(255, Math.round(pixel.r * brightness))}, ${Math.min(255, Math.round(pixel.g * brightness))}, ${Math.min(255, Math.round(pixel.b * brightness))})`;
dispCtx.fillRect(
x + gap / 2 + offsetX,
y + gap / 2 + offsetY,
cellSize - gap,
cellSize - gap,
);
// Draw light border around top face
dispCtx.strokeStyle = `rgba(${borderRGB.r}, ${borderRGB.g}, ${borderRGB.b}, ${borderOpacity + elevation * 0.008})`;
dispCtx.lineWidth = 0.5;
dispCtx.strokeRect(
x + gap / 2 + offsetX,
y + gap / 2 + offsetY,
cellSize - gap,
cellSize - gap,
);
}
}
animationRef.current = requestAnimationFrame(render);
}, [
gridCols,
gridRows,
mirror,
motionSensitivity,
colorMode,
monoRGB,
redscaleRGBStops,
maxElevation,
elevationSmoothing,
backgroundColor,
gapRatio,
invertColors,
darken,
borderRGB,
borderOpacity,
]);
// Start render loop when ready
useEffect(() => {
if (!isReady) return;
animationRef.current = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(animationRef.current);
};
}, [isReady, render]);
return (
<div className={cn("relative h-full w-full", className)}>
{/* Hidden video element */}
<video
ref={videoRef}
className="pointer-events-none absolute h-0 w-0 opacity-0"
playsInline
muted
/>
{/* Hidden processing canvas */}
<canvas
ref={processingCanvasRef}
className="pointer-events-none absolute h-0 w-0 opacity-0"
/>
{/* Display canvas with fade-in */}
<canvas
ref={displayCanvasRef}
className={cn(
"h-full w-full transition-opacity duration-1000",
isReady ? "opacity-100" : "opacity-0",
)}
style={{ backgroundColor }}
/>
{/* Error popup */}
{error && showErrorPopup && !quietWebcamErrors && (
<div className="animate-in fade-in slide-in-from-top-2 fixed top-4 right-4 z-50 duration-300">
<div className="relative flex max-w-sm items-start gap-3 rounded-lg border border-white/10 bg-black/80 p-4 shadow-2xl backdrop-blur-xl">
{/* Close button */}
<button
onClick={() => setShowErrorPopup(false)}
className="absolute top-2 right-2 rounded-md p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white/70"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/* Camera icon */}
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-white/10">
<svg
className="h-5 w-5 text-white/60"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</div>
{/* Content */}
<div className="flex-1 pr-4">
<p className="text-sm font-medium text-white/90">
Camera access needed
</p>
<p className="mt-1 text-xs text-white/50">
Enable camera for the interactive background effect
</p>
<button
onClick={requestCameraAccess}
className="mt-3 inline-flex items-center gap-1.5 rounded-md bg-white/10 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-white/20"
>
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
Enable Camera
</button>
</div>
</div>
</div>
)}
{/* Minimized error indicator */}
{error && !showErrorPopup && !quietWebcamErrors && (
<button
onClick={() => setShowErrorPopup(true)}
className="fixed top-4 right-4 z-50 flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-black/60 text-white/50 shadow-lg backdrop-blur-xl transition-all hover:scale-105 hover:bg-black/80 hover:text-white/80"
title="Camera access required"
>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 3l18 18"
className="text-red-400"
stroke="currentColor"
/>
</svg>
</button>
)}
</div>
);
};
export default WebcamPixelGrid;

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,5 +1,8 @@
--- ---
import { CanvaLanding } from "@/components/canva-landing"; import { LandingHeroSection } from "@/components/landing-hero-section";
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";
--- ---
@@ -17,6 +20,15 @@ import "@/styles/global.css";
defer></script> defer></script>
</head> </head>
<body> <body>
<CanvaLanding /> <main class="min-h-screen overflow-hidden bg-background text-foreground">
<LandingHeroSection client:media="(min-width: 1024px)" />
<LandingPageSections />
<ContactSection client:visible />
<Footer27 />
</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

@@ -2,18 +2,33 @@ import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
const componentPath = new URL("../src/components/canva-landing.tsx", import.meta.url); const sourcePaths = [
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),
];
test("Canva landing component contains the core brief anchors", async () => { const footerPath = new URL("../src/components/footer27.tsx", import.meta.url);
const source = await readFile(componentPath, "utf8"); 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);
for (const phrase of ["Projektbrief", "01", "Website", "Kontakt", "für", "müssen", "Änderungen"]) { test("Landing component contains the core brief anchors", async () => {
const source = (
await Promise.all(sourcePaths.map((p) => readFile(p, "utf8")))
).join("\n");
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));
} }
}); });
test("Canva landing component uses real German umlauts in visible copy", async () => { test("Landing component uses real German umlauts in visible copy", async () => {
const source = await readFile(componentPath, "utf8"); const source = (
await Promise.all(sourcePaths.map((p) => readFile(p, "utf8")))
).join("\n");
for (const asciiFallback of [ for (const asciiFallback of [
"fuer", "fuer",
@@ -30,3 +45,44 @@ test("Canva 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));
}
});