Compare commits

...

21 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
243978bfdd Resolve merge conflicts from Canva redesign; update configuration and component files for consistency and alignment with new design. 2026-05-05 22:46:11 +02:00
aac9e52bfb Merge branch 'codex/canva-redesign'
# Conflicts:
#	backlog/config.yml
#	src/components/cta.tsx
#	src/components/faq7.tsx
#	src/components/feature284.tsx
#	src/pages/index.astro
#	src/styles/global.css
2026-05-05 22:41:58 +02:00
e57678a68d Cleanly separate hero color fields 2026-05-05 22:37:20 +02:00
55a189e78e Use German umlauts in landing copy 2026-05-05 22:35:43 +02:00
e2ca8074b8 Redesign landing page from Canva brief 2026-05-05 22:33:46 +02:00
0d8b56864a Prepare Canva redesign worktree 2026-05-05 22:25:34 +02:00
73299fd18a Initialize redesign task tracking 2026-05-05 22:21:21 +02:00
117839058b Professionalize landing page design 2026-05-05 22:20:09 +02:00
50 changed files with 4109 additions and 1809 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@ dist/
# dependencies
node_modules/
.worktrees/
# logs
npm-debug.log*

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

Binary file not shown.

14
backlog/config.yml Normal file
View File

@@ -0,0 +1,14 @@
project_name: "Dev Landing"
default_status: "To Do"
statuses: ["To Do", "In Progress", "Done"]
labels: []
date_format: yyyy-mm-dd
max_column_width: 20
auto_open_browser: true
default_port: 6420
remote_operations: false
auto_commit: false
bypass_git_hooks: false
check_active_branches: false
active_branch_days: 30
task_prefix: "task"

View File

@@ -0,0 +1,42 @@
---
id: TASK-1
title: Professionalize landing page design
status: In Progress
assignee: []
created_date: '2026-05-05 19:55'
updated_date: '2026-05-05 20:13'
labels: []
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Refresh the existing landing page so it feels more professional while preserving the current regional KMU positioning and current content structure.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Visual hierarchy is clearer across the first viewport and content sections
- [x] #2 Styling feels cohesive and professional without generic AI visual patterns
- [x] #3 Responsive layout remains usable on mobile and desktop
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Audit current structure and design context
2. Tighten theme tokens and page rhythm
3. Refresh hero/trust/features/pricing/contact styling
4. Build and visual-check responsive behavior
5. Mark acceptance criteria verified, leave task In Progress for user confirmation
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Backlog was missing in the repo, so I initialized it with CLI defaults and disabled remote branch checks after sandbox git fetch failed. Design context exists in .impeccable.md: regional KMU, calm/direct/human, warm light theme.
Implemented the professionalization pass: warm OKLCH theme, full-width page shell, redesigned hero with real workspace image, calmer trust/features/stats sections, clearer pricing tabs, refined FAQ/about/contact/footer. Verified with pnpm build, git diff --check, and in-app browser checks for hero visibility and pricing tab interaction. Dev-server hydration errors in browser logs were from the initial Vite dependency optimization at 20:10:49 and did not recur after reload.
<!-- SECTION:NOTES:END -->

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",
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -19,14 +21,14 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {
"@shadcnblocks": {
"url": "https://shadcnblocks.com/r/{name}",
"headers": {
"Authorization": "Bearer ${SHADCNBLOCKS_API_KEY}"
}
}
},
"@aceternity": "https://ui.aceternity.com/registry/{name}.json",
"@mapcn": "https://mapcn.dev/r/{name}.json"
}
}

View File

@@ -0,0 +1,41 @@
# Canva Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rework the dev landing page into a bold black, red, and white editorial site inspired by the Canva creative brief deck.
**Architecture:** Replace the current stacked section imports on the homepage with one focused React landing component. Keep existing UI primitives available but avoid broad refactors of old sections so the redesign remains easy to review or revert.
**Tech Stack:** Astro 6, React 19, Tailwind CSS 4, lucide-react, Node test runner.
---
### Task 1: Landing Smoke Test
**Files:**
- Create: `tests/landing-content.test.mjs`
- Modify: `package.json`
- [x] Add a Node smoke test that checks the new component source for key content anchors: `Projektbrief`, `01`, `Website`, `Kontakt`.
- [x] Run `node --test tests/landing-content.test.mjs` and confirm it fails before the component exists.
### Task 2: Canva-Inspired Landing Page
**Files:**
- Create: `src/components/landing.tsx`
- Modify: `src/pages/index.astro`
- 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.
- [ ] Replace the existing homepage component stack with the new `Landing` component.
- [ ] Update global tokens for the dark, high-contrast Canva reference style.
### Task 3: Verification
**Files:**
- Modify: `backlog/tasks/task-1 - Redesign-dev-website-from-Canva-reference.md`
- [ ] Run `node --test tests/landing-content.test.mjs` and confirm it passes.
- [ ] Run `CI=true pnpm run build` and confirm Astro builds the page.
- [ ] Start the local dev server and visually review the page in a browser/screenshot.
- [ ] Check off remaining acceptance criteria that have direct evidence.

View File

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

198
pnpm-lock.yaml generated
View File

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

7
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,7 @@
packages:
- "."
allowBuilds:
esbuild: true
msw: true
sharp: true

11
solo.yml Normal file
View File

@@ -0,0 +1,11 @@
# Visit https://soloterm.com to learn more
name: Dev-Landing
icon: null
processes:
Astro:
command: pnpm run dev
working_dir: null
auto_start: false
auto_restart: true
restart_when_changed: []
env: {}

View File

@@ -1,44 +0,0 @@
import { cn } from "@/lib/utils";
interface About19Props {
className?: string;
}
const About19 = ({ className }: About19Props) => {
return (
<section className={cn("py-32", className)}>
<div className="container">
<div className="grid grid-cols-1 gap-15 lg:grid-cols-7 lg:gap-1">
<div className="col-span-4 h-120">
<img
src="/about.jpg"
alt=""
className="h-full w-full object-cover rounded-xl shadow-md"
/>
</div>
<div className="col-span-3 ml-auto max-w-4xl space-y-15 lg:pl-15">
<h1 className="text-2xl font-medium tracking-tight">
Hallo, ich bin Matthias.
</h1>
<p className="text-base text-foreground/40 lg:text-lg">
Ich bin in der Region aufgewachsen, war durch die Bundeswehr viele Jahre weg und bin jetzt zurück. Und ich plane zu bleiben.
</p>
<p className="text-base text-foreground/40 lg:text-lg">
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 className="text-base text-foreground/40 lg:text-lg">
Neben Websites für regionale Unternehmen entwickle ich eigene Software und Apps. Das bedeutet: Wenn Ihre Anforderungen irgendwann über eine einfache Website hinausgehen, bin ich noch immer der richtige Ansprechpartner.
</p>
<p className="text-base text-foreground/40 lg:text-lg">
Mein Ziel ist es, Unternehmen aus der Region Handwerker, Friseure, Ärzte mit dem auszustatten, was Großstadtagenturen ihren Kunden für viel mehr Geld verkaufen. Eine Website die funktioniert, gefunden wird und Ihnen keine Kopfschmerzen macht.
</p>
</div>
</div>
</div>
</section>
);
};
export { About19 };

View File

@@ -1,213 +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 gueltige 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("py-32", className)}>
<div className="container">
<div className="mt-20 flex flex-col justify-between gap-15 md:gap-10 lg:flex-row">
<div className="flex w-full max-w-lg flex-col justify-between gap-10">
<p className="indent-[22%] text-3xl font-medium tracking-tight text-muted-foreground/50 lg:text-4xl">
Erzählen Sie mir kurz von Ihrem Unternehmen ich melde mich innerhalb von 24 Stunden mit einem unverbindlichen Angebot.
</p>
<div className="mt-5 flex items-center gap-4 lg:mt-20">
<img
src="https://deifkwefumgah.cloudfront.net/shadcnblocks/block/guri3/avatar3.png"
className="size-12"
alt="Matthias Meister"
/>
<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="col-span-4 flex w-full flex-col gap-2 lg:pl-10">
<h2 className="mb-7 text-6xl font-semibold tracking-tight lg:text-5xl">
Jetzt Website anfordern
</h2>
{isSubmitted && (
<div
className={cn(
"mb-4 rounded-lg border border-green-500/20 bg-green-500/10 p-4 text-center transition-opacity duration-500",
showSuccess ? "opacity-100" : "opacity-0",
)}
>
<p className="text-sm font-medium text-green-600 dark:text-green-400">
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-15 rounded-none border-0 border-b border-b-foreground/25 bg-transparent! shadow-none placeholder:text-foreground/20 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-15 rounded-none border-0 border-b border-b-foreground/25 bg-transparent! shadow-none placeholder:text-foreground/20 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-32 w-full rounded-none border-0 border-b border-b-foreground/25 bg-transparent px-0 py-3 text-base text-foreground shadow-none outline-none placeholder:text-foreground/20 focus-visible:border-ring 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-15 flex items-center justify-start gap-2 rounded-none px-8! lg:h-12 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,69 +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-16 pt-4 sm:px-6 lg:px-8 lg:pb-24">
<div className="mx-auto max-w-6xl border-y border-border/80 py-8 lg:grid lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.45fr)] lg:gap-12 lg:py-10">
<div className="max-w-md space-y-4">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
Vertrauensanker
</p>
<h2 className="text-2xl font-semibold tracking-tight text-balance lg:text-3xl">
Ein gemeinsamer Startpunkt statt leerer Versprechen.
</h2>
<p className="text-base leading-7 text-muted-foreground">
Noch bevor es um Pakete oder Features geht, soll direkt klar sein,
warum diese Zusammenarbeit für regionale Unternehmen greifbar und
verlaesslich wirkt.
</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-4 sm:border-t-0 sm:border-l sm:pl-6 sm:pt-0 lg:pl-8",
)}
>
<dt className="text-sm font-medium text-foreground">
{item.title}
</dt>
<dd className="space-y-2">
<p className="text-lg font-semibold leading-7 text-balance text-foreground">
{item.description}
</p>
<p className="text-sm leading-6 text-muted-foreground">
{item.note}
</p>
</dd>
</div>
))}
</dl>
</div>
</section>
);
}

View File

@@ -1,82 +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("py-32", className)}>
<div className="container">
<div className="mx-auto grid max-w-7xl gap-10 md:grid-cols-2">
<div className="flex flex-col gap-6">
<h2 className="text-4xl font-semibold">
Fragen vor dem Start?
<br />
<span className="text-muted-foreground/70">
Hier finden Sie schnelle Antworten.
</span>
</h2>
<p className="text-lg text-muted-foreground md:text-xl">
Falls noch etwas offen ist, schreiben Sie mir gern ueber das
<a href="#" className="mx-1 whitespace-nowrap underline">
Kontaktformular
</a>
.
</p>
<Button size="lg" variant="outline" className="w-fit">
Alle Fragen ansehen
</Button>
</div>
<Accordion type="multiple">
{faqs.map((faq, index) => (
<AccordionItem key={index} value={`item-${index}`}>
<AccordionTrigger className="text-left">
{faq.question}
</AccordionTrigger>
<AccordionContent>{faq.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</div>
</section>
);
};
export { Faq7 };

View File

@@ -1,96 +0,0 @@
import { HelpCircleIcon } from "lucide-react";
import React from "react";
import { GlowingEffect } from "@/components/ui/glowing-effect";
import { cn } from "@/lib/utils";
const featureData = [
{
desc: "Ihre Website erklaert in wenigen Sekunden, fuer wen Sie arbeiten und was Sie konkret anbieten.",
img: "https://deifkwefumgah.cloudfront.net/shadcnblocks/block/guri3/img1.jpeg",
title: "Klare Positionierung",
badgeTitle: "Vorteil 01",
gridClass: "md:col-span-1",
},
{
desc: "Ein zeitgemaesses Design sorgt fuer einen starken ersten Eindruck und passt zu Ihrem Unternehmen.",
img: "https://deifkwefumgah.cloudfront.net/shadcnblocks/block/guri3/img7.jpeg",
title: "Modernes Erscheinungsbild",
badgeTitle: "Vorteil 02",
gridClass: "lg:col-span-2",
},
{
desc: "Ihre Inhalte funktionieren sauber auf Smartphone, Tablet und Desktop - ohne Umwege fuer Besucher.",
img: "https://deifkwefumgah.cloudfront.net/shadcnblocks/block/guri3/img11.jpeg",
title: "Mobil optimiert",
badgeTitle: "Vorteil 03",
gridClass: "md:col-span-1 lg:row-span-2 ",
},
{
desc: "Klare Kontaktwege mit gut sichtbaren Handlungsaufforderungen machen den naechsten Schritt leicht.",
img: "https://deifkwefumgah.cloudfront.net/shadcnblocks/block/guri3/img2.jpeg",
title: "Anfragen ohne Huerden",
badgeTitle: "Vorteil 04",
gridClass: "lg:col-span-2",
},
{
desc: "Die Seite bleibt wartbar aufgebaut, damit Inhalte spaeter schnell angepasst oder erweitert werden koennen.",
img: "https://deifkwefumgah.cloudfront.net/shadcnblocks/block/guri3/img4.jpeg",
title: "Pflegeleicht aufgebaut",
badgeTitle: "Vorteil 05",
gridClass: "md:col-span-1",
},
];
interface Feature284Props {
className?: string;
}
const Feature284 = ({ className }: Feature284Props) => {
return (
<section className={cn("h-full overflow-hidden py-32", className)}>
<div className="container flex h-full w-full items-center justify-center">
<div className="grid w-full max-w-6xl grid-cols-1 grid-rows-2 gap-4 md:grid-cols-2 lg:h-[800px] lg:grid-cols-4">
{featureData.map((feature, index) => (
<div
key={index}
className={cn(
"relative flex flex-col gap-2 rounded-3xl border p-4",
feature.gridClass,
)}
>
<GlowingEffect
spread={40}
glow={true}
disabled={false}
proximity={64}
inactiveZone={0.01}
/>
<div className="flex w-full items-center justify-between">
<p className="text-muted-foreground">{feature.badgeTitle}</p>
<HelpCircleIcon className="size-4 text-muted-foreground" />
</div>
<div
className={cn(
"w-full flex-1 overflow-hidden rounded-3xl bg-muted",
)}
>
<img
src={feature.img}
alt={feature.title}
className="pointer-events-none h-full w-full object-cover"
/>
</div>
<h3 className="mt-4 text-2xl font-semibold tracking-tight">
{feature.title}
</h3>
<p className="text-muted-foreground">{feature.desc}</p>
</div>
))}
</div>
</div>
</section>
);
};
export { Feature284 };

View File

@@ -1,10 +1,3 @@
"use client";
import { motion } from "framer-motion";
import { ArrowUpRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
interface Footer27Props {
@@ -12,137 +5,33 @@ interface Footer27Props {
}
const Footer27 = ({ className }: Footer27Props) => {
const socialLinks = [
{ name: "E-Mail", href: "#" },
{ name: "LinkedIn", href: "#" },
];
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
duration: 0.6,
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.5 },
},
};
return (
<section className={cn("py-32", className)}>
<div className="container">
<footer>
<div>
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="flex flex-col justify-between md:flex-row md:items-center"
>
<div className="space-y-8">
<motion.div variants={itemVariants} className="space-y-6">
<h2 className="text-4xl leading-tight font-bold text-foreground lg:text-5xl">
Bereit für eine Website, die Kunden bringt?
</h2>
<p className="max-w-md text-lg leading-relaxed text-muted-foreground">
Erzählen Sie mir kurz von Ihrem Unternehmen ich melde mich innerhalb von 24 Stunden mit einem unverbindlichen Angebot.
</p>
</motion.div>
<motion.div variants={itemVariants}>
<Button size="lg">Kostenloses Angebot anfordern</Button>
</motion.div>
</div>
<div className="mt-5 space-y-8 md:mt-0">
<motion.div variants={itemVariants}>
<div className="space-y-6">
{socialLinks.map((link) => (
<motion.div
key={link.name}
variants={itemVariants}
whileHover={{ x: 4 }}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
}}
>
<a
href={link.href}
className="group flex items-center gap-2 py-2 text-foreground transition-colors hover:text-foreground/80"
>
<span className="text-xl font-medium">
{link.name}
</span>
<ArrowUpRight className="h-6 w-6 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</a>
</motion.div>
))}
</div>
</motion.div>
</div>
</motion.div>
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="mt-16"
>
<motion.div variants={itemVariants}>
<Separator className="mb-8" />
</motion.div>
<motion.div
variants={itemVariants}
className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center"
>
<p className="text-sm text-muted-foreground">
© 2025 Matthias Meister Webdesign Crimmitschau
</p>
<div className="flex items-center gap-6 text-sm">
<span className="text-muted-foreground">
Kontakt:{" "}
<a href="mailto:info@matthias-meister-webdesign.de" className="underline underline-offset-4 transition-colors hover:text-foreground">
info@matthias-meister-webdesign.de
</a>
</span>
<span className="text-muted-foreground">
Tel:{" "}
<a href="tel:037627984400" className="underline underline-offset-4 transition-colors hover:text-foreground">
03762 798 4400
</a>
</span>
<span className="text-muted-foreground">
<a href="/impressum" className="underline underline-offset-4 transition-colors hover:text-foreground">
Impressum
</a>
</span>
<span className="text-muted-foreground">
<a href="/datenschutz" className="underline underline-offset-4 transition-colors hover:text-foreground">
Datenschutz
</a>
</span>
</div>
</motion.div>
</motion.div>
<footer className={cn("px-5 pb-10 sm:px-8 lg:px-12", className)}>
<div className="border-t border-border/80 pt-6">
<div className="flex flex-col gap-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p>© 2026 Matthias Meister Webdesign</p>
<p className="text-xs text-muted-foreground/90">
Diese Website misst Nutzung anonym und cookiefrei (Rybbit).
</p>
</div>
</footer>
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
<a
href="/impressum"
className="underline underline-offset-4 transition-colors hover:text-foreground"
>
Impressum
</a>
<a
href="/datenschutz"
className="underline underline-offset-4 transition-colors hover:text-foreground"
>
Datenschutz
</a>
</div>
</div>
</div>
</section>
</footer>
);
};

View File

@@ -1,51 +0,0 @@
import { ArrowRight } 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 sm:px-6 lg:px-8", className)}>
<div className="mx-auto max-w-5xl py-20 sm:py-24 lg:py-28">
<div className="mb-8 flex flex-col gap-3 border-b border-border/70 pb-5 sm:mb-10 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
Matthias Meister | Webdesign für KMU aus der Region
</p>
<p className="text-sm text-muted-foreground">
Rückmeldung innerhalb von 24 Stunden
</p>
</div>
<div className="flex max-w-4xl flex-col gap-7">
<h1 className="max-w-[13ch] text-4xl font-semibold tracking-tight text-balance text-foreground sm:text-5xl lg:text-6xl">
Websites für Unternehmen aus der Region - klar, schnell und
glaubwürdig.
</h1>
<p className="max-w-[65ch] text-base leading-7 text-muted-foreground sm:text-lg">
Ich arbeite direkt für Handwerk, Praxen und kleine Betriebe aus
der Region. Ohne Baukasten-Look, Agenturshow oder technischen
Umweg - sondern mit einer Website, die Ihr Angebot klar zeigt und
Anfragen leichter macht.
</p>
<div className="flex flex-wrap items-center gap-4 pt-1">
<Button asChild size="lg" className="h-11 rounded-full px-5">
<a href="#kontakt">
Projekt anfragen
<ArrowRight className="shrink-0" aria-hidden />
</a>
</Button>
<p className="text-sm text-muted-foreground">
Kurze Nachricht reicht - Sie erhalten direkt eine erste
Einschätzung und den passenden nächsten Schritt.
</p>
</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,225 +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 { Separator } from "@/components/ui/separator";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
interface PricingPlan {
name: string;
badge: string;
price: string;
description?: string;
features: string[];
buttonText: string;
isPopular?: boolean;
}
interface Pricing4Props {
title?: string;
description?: string;
plans?: PricingPlan[];
className?: string;
}
const Pricing4 = ({
title = "Entwicklungspakete",
description =
"Alle Websites laufen auf deutschen Servern, sind DSGVO-konform und kommen ohne Cookie-Banner aus. Auf Wunsch erhalten Sie monatlich einen Einblick, wie viele Menschen Ihre Website besuchen haben — und woher sie kommen.",
plans = [
{
name: "BASIS",
badge: "799 €",
price: "799 €",
features: [
"Eine Seite, fünf Sektionen",
"Kontaktformular",
"Impressum & Datenschutz",
"Mobilfreundlich & für Google optimiert",
"DSGVO-konformes Kontaktformular",
"Cookiefreies Analytics — ohne Abmahnrisiko",
],
buttonText: "Kostenloses Angebot anfordern",
},
{
name: "PROFI",
badge: "1.499 € ⭐ Empfehlung",
price: "1.499 €",
features: [
"Bis zu 5 Unterseiten",
"Google Maps Integration",
"SEO-Basis (bessere Auffindbarkeit bei Google)",
"Optionaler Blog",
"DSGVO-konformes Kontaktformular",
"Cookiefreies Analytics — ohne Abmahnrisiko",
"Alles aus Basis inklusive",
],
buttonText: "Kostenloses Angebot anfordern",
isPopular: true,
},
{
name: "MASSARBEIT",
badge: "2.499 €",
price: "2.499 €",
features: [
"Individuelles Design nach Ihren Wünschen",
"CMS — Sie pflegen Inhalte selbst",
"DSGVO-konformes Kontaktformular",
"Cookiefreies Analytics — ohne Abmahnrisiko",
"Alles aus Profi inklusive",
],
buttonText: "Kostenloses Angebot anfordern",
},
],
className,
}: Pricing4Props) => {
const [isMonthly, setIsMonthly] = useState(false);
return (
<section className={cn("py-32", className)}>
<div className="container mx-auto">
<div className="flex flex-col gap-6">
<h2 className="text-4xl font-semibold text-pretty lg:text-6xl">
{title}
</h2>
<div className="flex flex-col justify-between gap-10 md:flex-row">
<p className="max-w-3xl text-muted-foreground lg:text-xl">
{description}
</p>
<Tabs
value={isMonthly ? "monthly" : "yearly"}
onValueChange={(value: string) =>
setIsMonthly(value === "monthly")
}
className="w-fit shrink-0"
aria-label="Leistungsvariante"
>
<TabsList className="grid h-11 w-max grid-cols-2 gap-0 rounded-md p-1 text-lg">
<TabsTrigger
value="monthly"
className="h-full min-h-0 px-7 py-0 font-semibold text-muted-foreground data-active:text-foreground"
>
Entwicklung
</TabsTrigger>
<TabsTrigger
value="yearly"
className="h-full min-h-0 px-7 py-0 font-semibold text-muted-foreground data-active:text-foreground"
>
Hosting & Wartung
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex w-full flex-col items-stretch gap-6 md:flex-row">
{isMonthly ? (
plans.map((plan) => (
<div
key={plan.name}
className={`flex w-full flex-col rounded-xl border shadow-sm p-6 text-left ${
plan.isPopular ? "bg-muted" : ""
}`}
>
<Badge className="mb-8 block w-fit uppercase">
{plan.badge}
</Badge>
<h3 className="font-mono text-4xl lg:text-5xl">
{plan.price}
</h3>
<p className="text-muted-foreground">Einmalpreis</p>
<Separator className="my-6" />
<div className="flex h-full flex-col justify-between gap-20">
<ul className="space-y-4 text-muted-foreground md:leading-snug">
{plan.features.map((feature, featureIndex) => (
<li
key={featureIndex}
className="flex items-center gap-2"
>
<Check className="size-4 shrink-0" aria-hidden="true" />
<span>{feature}</span>
</li>
))}
</ul>
<Button className="w-full">{plan.buttonText}</Button>
</div>
</div>
))
) : (
[
{
name: "BASIC HOSTING",
badge: "19 €/Monat",
price: "19 €",
features: [
"Hosting auf deutschen Servern in Sachsen",
"Grünes Schloss im Browser (SSL) — sicher & von Google bevorzugt",
"Tägliche Backups — Ihre Daten sind immer geschützt",
"Domain inklusive",
"Monatlicher Einblick in Ihre Besucherzahlen",
],
},
{
name: "WARTUNG",
badge: "39 €/Monat ⭐ Empfehlung",
price: "39 €",
features: [
"Alles aus Basic Hosting inklusive",
"Regelmäßige Updates & Sicherheitschecks",
"1 Stunde Support pro Monat",
"Monitoring — ich merke bevor Sie es tun, wenn etwas nicht stimmt",
"Wöchentlicher Einblick in Ihre Besucherzahlen",
],
isPopular: true,
},
{
name: "FULL SERVICE",
badge: "69 €/Monat",
price: "69 €",
features: [
"Alles aus Wartung inklusive",
"Kleinere Inhaltsänderungen (bis 2 Stunden/Monat)",
"Täglicher Einblick in Ihre Besucherzahlen",
],
},
].map((plan) => (
<div
key={plan.name}
className={`flex w-full flex-col rounded-xl border shadow-sm p-6 text-left ${
plan.isPopular ? "bg-muted" : ""
}`}
>
<Badge className="mb-8 block w-fit uppercase">
{plan.badge}
</Badge>
<h3 className="font-mono text-4xl lg:text-5xl">
{plan.price}
</h3>
<p className="text-muted-foreground">Monatlicher Preis</p>
<Separator className="my-6" />
<div className="flex h-full flex-col justify-between gap-20">
<ul className="space-y-4 text-muted-foreground md:leading-snug">
{plan.features.map((feature, featureIndex) => (
<li
key={featureIndex}
className="flex items-center gap-2"
>
<Check className="size-4 shrink-0" aria-hidden="true" />
<span>{feature}</span>
</li>
))}
</ul>
<Button className="w-full">Kostenloses Angebot anfordern</Button>
</div>
</div>
))
)}
</div>
</div>
</div>
</section>
);
};
export { Pricing4 };

View File

@@ -1,68 +0,0 @@
import { cn } from "@/lib/utils";
interface Stats11Props {
className?: string;
}
const Stats11 = ({ className }: Stats11Props) => {
return (
<section className={cn("py-32", className)}>
<div className="container">
<div className="relative isolate overflow-hidden bg-linear-to-b from-primary/10 to-transparent md:border-x md:border-border">
<div className="absolute right-0 -left-px -z-20 h-full w-full bg-[linear-gradient(90deg,var(--muted-foreground)_1px,transparent_1px)] mask-[linear-gradient(transparent_25%,black_25%,black_75%,transparent_75%)] bg-size-[calc(100%/16)_100%] mask-size-[100%_16px] opacity-20 [-webkit-mask-image:linear-gradient(transparent_25%,black_25%,black_75%,transparent_75%)] [-webkit-mask-size:100%_16px]" />
<div>
<h2 className="mb-16 max-w-3xl text-3xl leading-10 font-semibold sm:mb-24 md:mx-10">
Für Google optimiert, schnell geladen und klar kalkulierbar.
<span className="font-medium text-primary/50">
{" "}
Genau die Zahlen, die bei einer Website wirklich zählen.
</span>
</h2>
<div className="relative grid max-w-2xl gap-4 border-x border-border pb-32 sm:grid-cols-2 sm:gap-10 sm:pb-44 md:ml-10 md:border-0">
<div className="absolute inset-0 top-[-1100px] -left-[calc(1000px-22vw)] -z-10 size-[1500px] rounded-full border border-primary bg-background sm:top-[-480%] sm:left-[-185%] sm:size-[2000px] md:top-[-906%] md:left-[-294%] md:size-[3500px] lg:top-[-1186%] lg:left-[-380%] lg:size-[4500px] xl:top-[-1200%] xl:left-[-350%] 2xl:top-[-1196%] 2xl:left-[-345%]"></div>
<div className="flex flex-col gap-2">
<span className="flex gap-5 text-3xl font-semibold">
<span className="relative -left-px w-px bg-primary/50"></span>
SEO-ready
</span>
<p className="pl-5 font-medium text-muted-foreground/80">
Für Google optimiert
</p>
</div>
<div className="flex flex-col gap-2">
<span className="flex gap-5 text-3xl font-semibold">
<span className="relative -left-px w-px bg-primary/50"></span>
{"< 1 Sek."}
</span>
<p className="pl-5 font-medium text-muted-foreground/80">
Ladezeit
</p>
</div>
<div className="flex flex-col gap-2">
<span className="flex gap-5 text-3xl font-semibold">
<span className="relative -left-px w-px bg-primary/50"></span>
ab 799
</span>
<p className="pl-5 font-medium text-muted-foreground/80">
Transparenter Einmalpreis
</p>
</div>
<div className="flex flex-col gap-2">
<span className="flex gap-5 text-3xl font-semibold">
<span className="relative -left-px w-px bg-primary/50"></span>
2 Wochen
</span>
<p className="pl-5 font-medium text-muted-foreground/80">
Bis zum Go-Live
</p>
</div>
</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,13 +1,8 @@
---
import { About19 } from "@/components/about19";
import { Contact21 } from "@/components/contact21";
import { Faq7 } from "@/components/faq7";
import { Feature284 } from "@/components/feature284";
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 { Hero235 } from "@/components/hero235";
import { Pricing4 } from "@/components/pricing4";
import { Stats11 } from "@/components/stats11";
import CTASection from "@/components/cta";
import "@/styles/global.css";
---
@@ -25,16 +20,15 @@ import "@/styles/global.css";
defer></script>
</head>
<body>
<div class="mx-auto w-full max-w-7xl">
<Hero235 client:load />
<CTASection client:load />
<Feature284 client:load />
<Stats11 client:load />
<Pricing4 client:load />
<Faq7 client:load />
<About19 client:load />
<Contact21 client:load />
<Footer27 client:load />
</div>
<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>
</html>

View File

@@ -49,38 +49,38 @@
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--background: oklch(0.115 0.012 22);
--foreground: oklch(0.965 0.013 76);
--card: oklch(0.16 0.014 22);
--card-foreground: oklch(0.965 0.013 76);
--popover: oklch(0.16 0.014 22);
--popover-foreground: oklch(0.965 0.013 76);
--primary: oklch(0.61 0.235 27);
--primary-foreground: oklch(0.985 0.01 76);
--secondary: oklch(0.22 0.016 22);
--secondary-foreground: oklch(0.965 0.013 76);
--muted: oklch(0.19 0.014 22);
--muted-foreground: oklch(0.73 0.021 76);
--accent: oklch(0.61 0.235 27);
--accent-foreground: oklch(0.985 0.01 76);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--border: oklch(0.965 0.013 76 / 20%);
--input: oklch(0.965 0.013 76 / 20%);
--ring: oklch(0.61 0.235 27);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--sidebar: oklch(0.16 0.014 22);
--sidebar-foreground: oklch(0.965 0.013 76);
--sidebar-primary: oklch(0.61 0.235 27);
--sidebar-primary-foreground: oklch(0.985 0.01 76);
--sidebar-accent: oklch(0.22 0.016 22);
--sidebar-accent-foreground: oklch(0.965 0.013 76);
--sidebar-border: oklch(0.965 0.013 76 / 20%);
--sidebar-ring: oklch(0.61 0.235 27);
}
.dark {
@@ -123,8 +123,37 @@
}
body {
@apply bg-background text-foreground;
text-rendering: geometricPrecision;
}
html {
@apply font-sans;
scroll-behavior: smooth;
}
.maplibregl-popup-content {
@apply bg-transparent! shadow-none! p-0! rounded-none!;
}
.maplibregl-popup-tip {
@apply hidden!;
}
.contact-map .maplibregl-canvas {
/* Dunkle Carto-Tiles: Sepia kippt alles in Braun, hue-rotate(325deg)
schiebt Braun (~38°) auf Rot (~3°). Helle Straßen/Labels werden
zu hellem Rot, der dunkle Hintergrund zu dunklem Rot. */
filter: sepia(1) hue-rotate(325deg) saturate(4.5) brightness(0.82);
}
.contact-map .maplibregl-ctrl-attrib {
background: oklch(0.115 0.012 22 / 70%) !important;
}
.contact-map .maplibregl-ctrl-attrib a {
color: oklch(0.985 0.01 76 / 80%) !important;
}
.contact-map .maplibregl-ctrl-group {
background: transparent !important;
box-shadow: none !important;
}
.contact-map .maplibregl-ctrl-group button {
background: oklch(0.115 0.012 22 / 80%) !important;
color: oklch(0.985 0.01 76) !important;
border-color: oklch(0.985 0.01 76 / 20%) !important;
}
}

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

@@ -0,0 +1,88 @@
import { readFile } from "node:fs/promises";
import test from "node:test";
import assert from "node:assert/strict";
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),
];
const footerPath = new URL("../src/components/footer27.tsx", import.meta.url);
const indexPath = new URL("../src/pages/index.astro", import.meta.url);
const impressumPath = new URL("../src/pages/impressum.astro", import.meta.url);
const datenschutzPath = new URL("../src/pages/datenschutz.astro", import.meta.url);
test("Landing component contains the core brief anchors", async () => {
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));
}
});
test("Landing component uses real German umlauts in visible copy", async () => {
const source = (
await Promise.all(sourcePaths.map((p) => readFile(p, "utf8")))
).join("\n");
for (const asciiFallback of [
"fuer",
"muessen",
"spaetere",
"Aenderungen",
"glaubwuerdig",
"naechste",
"Agenturlaerm",
"Erzaehlen",
"Saetze",
"Rueckmeldung",
]) {
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));
}
});