Compare commits
23 Commits
ed0d6b9210
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61e05910a9 | ||
|
|
cee5f470ad | ||
| 8a4ec60655 | |||
| e039fdf555 | |||
| 3440508bac | |||
| d2ba994fad | |||
| ed74fd0652 | |||
| 41120664f5 | |||
| d92ff8c065 | |||
| 6083844c24 | |||
| 9faca2a859 | |||
| 2032395472 | |||
| 67411ecaff | |||
| 243978bfdd | |||
| aac9e52bfb | |||
| e57678a68d | |||
| 55a189e78e | |||
| e2ca8074b8 | |||
| 0d8b56864a | |||
| 73299fd18a | |||
| 117839058b | |||
| add89b0f92 | |||
| ca8d18db2e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@ dist/
|
|||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
# logs
|
# logs
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
BIN
.pnpm-store/v11/index.db
Normal file
BIN
.pnpm-store/v11/index.db
Normal file
Binary file not shown.
14
backlog/config.yml
Normal file
14
backlog/config.yml
Normal 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"
|
||||||
@@ -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 -->
|
||||||
65
backlog/tasks/task-2 - Animate-cookie-banner-transitions.md
Normal file
65
backlog/tasks/task-2 - Animate-cookie-banner-transitions.md
Normal 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 -->
|
||||||
@@ -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 -->
|
||||||
55
backlog/tasks/task-4 - Add-legal-footer-and-pages.md
Normal file
55
backlog/tasks/task-4 - Add-legal-footer-and-pages.md
Normal 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 -->
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
id: TASK-5
|
||||||
|
title: Show the webcam toggle only when a webcam is available
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-05-06 20:11'
|
||||||
|
updated_date: '2026-05-07 05:58'
|
||||||
|
labels:
|
||||||
|
- frontend
|
||||||
|
- fallback
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Detect whether the browser reports an available webcam and only render the hero live-raster switch for visitors who can actually use the camera effect.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 The hero checks browser media devices for at least one video input without prompting for camera permission.
|
||||||
|
- [x] #2 The webcam switch and helper copy are hidden when no video input is reported or media-device enumeration is unavailable.
|
||||||
|
- [x] #3 The switch still starts and stops the existing webcam pixel grid for visitors with an available webcam.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Inspect current hero toggle and WebcamPixelGrid error behavior
|
||||||
|
2. Detect webcam availability with browser media-device enumeration
|
||||||
|
3. Hide the helper copy and switch when no video input is available
|
||||||
|
4. Preserve the existing webcam start/stop flow when a camera exists
|
||||||
|
5. Verify with build and update acceptance criteria
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Implemented a hero fallback raster that activates when WebcamPixelGrid reports unavailable or denied camera access.
|
||||||
|
|
||||||
|
Added an explicit mediaDevices/getUserMedia availability check before requesting camera access.
|
||||||
|
|
||||||
|
Verified with npm run build; Astro built 3 static pages successfully. Dev server is running at http://127.0.0.1:4322/ for manual testing.
|
||||||
|
|
||||||
|
Changed direction after feedback: removed the visual fallback and now hide the switch unless enumerateDevices reports a videoinput.
|
||||||
|
|
||||||
|
Added a devicechange listener so the switch can appear or disappear if camera hardware availability changes during the session.
|
||||||
|
|
||||||
|
Verified the revised behavior compiles with npm run build.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
id: TASK-6
|
||||||
|
title: Modularisiere die Landingpage und entferne ungenutzte Komponenten
|
||||||
|
status: In Progress
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-05-07 06:21'
|
||||||
|
updated_date: '2026-05-07 06:23'
|
||||||
|
labels:
|
||||||
|
- refactor
|
||||||
|
- frontend
|
||||||
|
dependencies: []
|
||||||
|
modified_files:
|
||||||
|
- src/pages/index.astro
|
||||||
|
- tests/landing-content.test.mjs
|
||||||
|
- src/components/landing-page-sections.tsx
|
||||||
|
- src/components/landing/services-section.tsx
|
||||||
|
- src/components/landing/deliverables-section.tsx
|
||||||
|
- src/components/landing/packages-section.tsx
|
||||||
|
- src/components/landing/contact-section.tsx
|
||||||
|
- src/components/landing.tsx
|
||||||
|
- src/components/about19.tsx
|
||||||
|
- src/components/contact21.tsx
|
||||||
|
- src/components/cta.tsx
|
||||||
|
- src/components/faq7.tsx
|
||||||
|
- src/components/feature284.tsx
|
||||||
|
- src/components/hero235.tsx
|
||||||
|
- src/components/pricing4.tsx
|
||||||
|
- src/components/stats11.tsx
|
||||||
|
- src/components/ui/accordion.tsx
|
||||||
|
- src/components/ui/badge.tsx
|
||||||
|
- src/components/ui/button.tsx
|
||||||
|
- src/components/ui/field.tsx
|
||||||
|
- src/components/ui/glowing-effect.tsx
|
||||||
|
- src/components/ui/input.tsx
|
||||||
|
- src/components/ui/label.tsx
|
||||||
|
- src/components/ui/separator.tsx
|
||||||
|
- src/components/ui/tabs.tsx
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Die bestehende Landingpage soll in klarere, kleinere Komponenten aufgeteilt werden. Nicht mehr referenzierte Komponenten sollen entfernt werden, ohne vorhandene Nutzer- oder laufende Änderungen zurückzudrehen.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Die Hauptseite nutzt klar benannte modulare Komponenten statt einer monolithischen LandingRest-Komponente.
|
||||||
|
- [x] #2 Nicht benötigte Komponenten im Komponentenordner sind entfernt oder nicht mehr Teil der Codebasis.
|
||||||
|
- [x] #3 Build und vorhandene Tests laufen nach der Änderung ohne Fehler.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Bestehende Landingpage-Struktur und Import-Verwendung prüfen.
|
||||||
|
2. LandingRest in klar benannte Sektionen extrahieren und Index-Import aktualisieren.
|
||||||
|
3. Nicht referenzierte Template- und UI-Komponenten entfernen.
|
||||||
|
4. Tests an neue Struktur anpassen und Build/Test ausführen.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
LandingRest wurde in ServicesSection, DeliverablesSection, PackagesSection und ContactSection extrahiert; index.astro nutzt nun LandingPageSections.
|
||||||
|
|
||||||
|
Nicht referenzierte Template-Komponenten sowie deren ungenutzte UI-Hilfskomponenten wurden entfernt. Vorbestehende Änderungen an landing-hero-section.tsx und ui/webcam-pixel-grid.tsx blieben unangetastet.
|
||||||
|
|
||||||
|
Verifikation: node --test tests/*.mjs und pnpm build laufen erfolgreich.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
"iconLibrary": "lucide",
|
||||||
"rtl": false,
|
"rtl": false,
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
@@ -19,14 +21,14 @@
|
|||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"menuColor": "default",
|
|
||||||
"menuAccent": "subtle",
|
|
||||||
"registries": {
|
"registries": {
|
||||||
"@shadcnblocks": {
|
"@shadcnblocks": {
|
||||||
"url": "https://shadcnblocks.com/r/{name}",
|
"url": "https://shadcnblocks.com/r/{name}",
|
||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Bearer ${SHADCNBLOCKS_API_KEY}"
|
"Authorization": "Bearer ${SHADCNBLOCKS_API_KEY}"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"@aceternity": "https://ui.aceternity.com/registry/{name}.json",
|
||||||
|
"@mapcn": "https://mapcn.dev/r/{name}.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
docs/superpowers/plans/2026-05-05-canva-redesign.md
Normal file
41
docs/superpowers/plans/2026-05-05-canva-redesign.md
Normal 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.
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
|
"maplibre-gl": "^5.24.0",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.3",
|
"tailwindcss": "^4.2.3",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"vanilla-cookieconsent": "^3.1.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
198
pnpm-lock.yaml
generated
198
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.8.0
|
specifier: ^1.8.0
|
||||||
version: 1.8.0(react@19.2.5)
|
version: 1.8.0(react@19.2.5)
|
||||||
|
maplibre-gl:
|
||||||
|
specifier: ^5.24.0
|
||||||
|
version: 5.24.0
|
||||||
motion:
|
motion:
|
||||||
specifier: ^12.38.0
|
specifier: ^12.38.0
|
||||||
version: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
version: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
@@ -62,6 +65,9 @@ importers:
|
|||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
|
vanilla-cookieconsent:
|
||||||
|
specifier: ^3.1.0
|
||||||
|
version: 3.1.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
@@ -654,6 +660,42 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@mapbox/jsonlint-lines-primitives@2.0.2':
|
||||||
|
resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
'@mapbox/point-geometry@1.1.0':
|
||||||
|
resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==}
|
||||||
|
|
||||||
|
'@mapbox/tiny-sdf@2.2.0':
|
||||||
|
resolution: {integrity: sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==}
|
||||||
|
|
||||||
|
'@mapbox/unitbezier@0.0.1':
|
||||||
|
resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==}
|
||||||
|
|
||||||
|
'@mapbox/vector-tile@2.0.4':
|
||||||
|
resolution: {integrity: sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==}
|
||||||
|
|
||||||
|
'@mapbox/whoots-js@3.1.0':
|
||||||
|
resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
'@maplibre/geojson-vt@5.0.4':
|
||||||
|
resolution: {integrity: sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==}
|
||||||
|
|
||||||
|
'@maplibre/geojson-vt@6.1.0':
|
||||||
|
resolution: {integrity: sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==}
|
||||||
|
|
||||||
|
'@maplibre/maplibre-gl-style-spec@24.8.5':
|
||||||
|
resolution: {integrity: sha512-EzEJmMt6thioRH7GI9LWS7ahXTcAhAPGWCe6oTP2Ps4YnsXOOAfeqx854lZaiDnwURfHmcCKV1mr6oo0i23x6w==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
'@maplibre/mlt@1.1.9':
|
||||||
|
resolution: {integrity: sha512-g/tD8EYJB97udq33ipuJ9a4Q7fcbZnTEnUrgnEc/tLMmEL+zaCbR+X5fkDBO2dgpaAMsLH179qE3UXg2N0Nc/g==}
|
||||||
|
|
||||||
|
'@maplibre/vt-pbf@4.3.0':
|
||||||
|
resolution: {integrity: sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==}
|
||||||
|
|
||||||
'@modelcontextprotocol/sdk@1.29.0':
|
'@modelcontextprotocol/sdk@1.29.0':
|
||||||
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
|
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1703,6 +1745,9 @@ packages:
|
|||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@types/geojson@7946.0.16':
|
||||||
|
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||||
|
|
||||||
'@types/hast@3.0.4':
|
'@types/hast@3.0.4':
|
||||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||||
|
|
||||||
@@ -1732,6 +1777,9 @@ packages:
|
|||||||
'@types/statuses@2.0.6':
|
'@types/statuses@2.0.6':
|
||||||
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
||||||
|
|
||||||
|
'@types/supercluster@7.1.3':
|
||||||
|
resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==}
|
||||||
|
|
||||||
'@types/unist@3.0.3':
|
'@types/unist@3.0.3':
|
||||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||||
|
|
||||||
@@ -1740,6 +1788,7 @@ packages:
|
|||||||
|
|
||||||
'@ungap/structured-clone@1.3.0':
|
'@ungap/structured-clone@1.3.0':
|
||||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||||
|
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
|
||||||
|
|
||||||
'@vitejs/plugin-react@5.2.0':
|
'@vitejs/plugin-react@5.2.0':
|
||||||
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
|
resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
|
||||||
@@ -2108,6 +2157,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
earcut@3.0.2:
|
||||||
|
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==}
|
||||||
|
|
||||||
eciesjs@0.4.18:
|
eciesjs@0.4.18:
|
||||||
resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==}
|
resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==}
|
||||||
engines: {bun: '>=1', deno: '>=2', node: '>=16'}
|
engines: {bun: '>=1', deno: '>=2', node: '>=16'}
|
||||||
@@ -2369,6 +2421,9 @@ packages:
|
|||||||
github-slugger@2.0.0:
|
github-slugger@2.0.0:
|
||||||
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
|
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
|
||||||
|
|
||||||
|
gl-matrix@3.4.4:
|
||||||
|
resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==}
|
||||||
|
|
||||||
glob-parent@5.1.2:
|
glob-parent@5.1.2:
|
||||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -2598,6 +2653,9 @@ packages:
|
|||||||
json-schema-typed@8.0.2:
|
json-schema-typed@8.0.2:
|
||||||
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
|
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
|
||||||
|
|
||||||
|
json-stringify-pretty-compact@4.0.0:
|
||||||
|
resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==}
|
||||||
|
|
||||||
json5@2.2.3:
|
json5@2.2.3:
|
||||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2606,6 +2664,9 @@ packages:
|
|||||||
jsonfile@6.2.1:
|
jsonfile@6.2.1:
|
||||||
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
|
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
|
||||||
|
|
||||||
|
kdbush@4.0.2:
|
||||||
|
resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==}
|
||||||
|
|
||||||
kleur@3.0.3:
|
kleur@3.0.3:
|
||||||
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2716,6 +2777,10 @@ packages:
|
|||||||
magicast@0.5.2:
|
magicast@0.5.2:
|
||||||
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
|
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
|
||||||
|
|
||||||
|
maplibre-gl@5.24.0:
|
||||||
|
resolution: {integrity: sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==}
|
||||||
|
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
|
||||||
|
|
||||||
markdown-table@3.0.4:
|
markdown-table@3.0.4:
|
||||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||||
|
|
||||||
@@ -2931,6 +2996,9 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
murmurhash-js@1.0.0:
|
||||||
|
resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==}
|
||||||
|
|
||||||
mute-stream@3.0.0:
|
mute-stream@3.0.0:
|
||||||
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
|
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
|
||||||
engines: {node: ^20.17.0 || >=22.9.0}
|
engines: {node: ^20.17.0 || >=22.9.0}
|
||||||
@@ -3091,6 +3159,10 @@ packages:
|
|||||||
path-to-regexp@8.4.2:
|
path-to-regexp@8.4.2:
|
||||||
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
|
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
|
||||||
|
|
||||||
|
pbf@4.0.1:
|
||||||
|
resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
piccolore@0.1.3:
|
piccolore@0.1.3:
|
||||||
resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==}
|
resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==}
|
||||||
|
|
||||||
@@ -3117,6 +3189,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
|
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
potpack@2.1.0:
|
||||||
|
resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==}
|
||||||
|
|
||||||
powershell-utils@0.1.0:
|
powershell-utils@0.1.0:
|
||||||
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -3136,6 +3211,9 @@ packages:
|
|||||||
property-information@7.1.0:
|
property-information@7.1.0:
|
||||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||||
|
|
||||||
|
protocol-buffers-schema@3.6.1:
|
||||||
|
resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==}
|
||||||
|
|
||||||
proxy-addr@2.0.7:
|
proxy-addr@2.0.7:
|
||||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@@ -3147,6 +3225,9 @@ packages:
|
|||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
|
quickselect@3.0.0:
|
||||||
|
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
|
||||||
|
|
||||||
radix-ui@1.4.3:
|
radix-ui@1.4.3:
|
||||||
resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==}
|
resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3277,6 +3358,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
resolve-protobuf-schema@2.1.0:
|
||||||
|
resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==}
|
||||||
|
|
||||||
restore-cursor@5.1.0:
|
restore-cursor@5.1.0:
|
||||||
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3456,6 +3540,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
|
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
supercluster@8.0.1:
|
||||||
|
resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==}
|
||||||
|
|
||||||
svgo@4.0.1:
|
svgo@4.0.1:
|
||||||
resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==}
|
resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -3493,6 +3580,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tinyqueue@3.0.0:
|
||||||
|
resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==}
|
||||||
|
|
||||||
tldts-core@7.0.28:
|
tldts-core@7.0.28:
|
||||||
resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==}
|
resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==}
|
||||||
|
|
||||||
@@ -3709,6 +3799,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==}
|
resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==}
|
||||||
engines: {node: ^20.17.0 || >=22.9.0}
|
engines: {node: ^20.17.0 || >=22.9.0}
|
||||||
|
|
||||||
|
vanilla-cookieconsent@3.1.0:
|
||||||
|
resolution: {integrity: sha512-/McNRtm/3IXzb9dhqMIcbquoU45SzbN2VB+To4jxEPqMmp7uVniP6BhGLjU8MC7ZCDsNQVOp27fhQTM/ruIXAA==}
|
||||||
|
|
||||||
vary@1.1.2:
|
vary@1.1.2:
|
||||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -4405,6 +4498,51 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@mapbox/jsonlint-lines-primitives@2.0.2': {}
|
||||||
|
|
||||||
|
'@mapbox/point-geometry@1.1.0': {}
|
||||||
|
|
||||||
|
'@mapbox/tiny-sdf@2.2.0': {}
|
||||||
|
|
||||||
|
'@mapbox/unitbezier@0.0.1': {}
|
||||||
|
|
||||||
|
'@mapbox/vector-tile@2.0.4':
|
||||||
|
dependencies:
|
||||||
|
'@mapbox/point-geometry': 1.1.0
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
pbf: 4.0.1
|
||||||
|
|
||||||
|
'@mapbox/whoots-js@3.1.0': {}
|
||||||
|
|
||||||
|
'@maplibre/geojson-vt@5.0.4': {}
|
||||||
|
|
||||||
|
'@maplibre/geojson-vt@6.1.0':
|
||||||
|
dependencies:
|
||||||
|
kdbush: 4.0.2
|
||||||
|
|
||||||
|
'@maplibre/maplibre-gl-style-spec@24.8.5':
|
||||||
|
dependencies:
|
||||||
|
'@mapbox/jsonlint-lines-primitives': 2.0.2
|
||||||
|
'@mapbox/unitbezier': 0.0.1
|
||||||
|
json-stringify-pretty-compact: 4.0.0
|
||||||
|
minimist: 1.2.8
|
||||||
|
quickselect: 3.0.0
|
||||||
|
tinyqueue: 3.0.0
|
||||||
|
|
||||||
|
'@maplibre/mlt@1.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@mapbox/point-geometry': 1.1.0
|
||||||
|
|
||||||
|
'@maplibre/vt-pbf@4.3.0':
|
||||||
|
dependencies:
|
||||||
|
'@mapbox/point-geometry': 1.1.0
|
||||||
|
'@mapbox/vector-tile': 2.0.4
|
||||||
|
'@maplibre/geojson-vt': 5.0.4
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
'@types/supercluster': 7.1.3
|
||||||
|
pbf: 4.0.1
|
||||||
|
supercluster: 8.0.1
|
||||||
|
|
||||||
'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
|
'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@hono/node-server': 1.19.14(hono@4.12.14)
|
'@hono/node-server': 1.19.14(hono@4.12.14)
|
||||||
@@ -5448,6 +5586,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@types/geojson@7946.0.16': {}
|
||||||
|
|
||||||
'@types/hast@3.0.4':
|
'@types/hast@3.0.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
@@ -5480,6 +5620,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/statuses@2.0.6': {}
|
'@types/statuses@2.0.6': {}
|
||||||
|
|
||||||
|
'@types/supercluster@7.1.3':
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
|
||||||
'@types/unist@3.0.3': {}
|
'@types/unist@3.0.3': {}
|
||||||
|
|
||||||
'@types/validate-npm-package-name@4.0.2': {}
|
'@types/validate-npm-package-name@4.0.2': {}
|
||||||
@@ -5885,6 +6029,8 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
earcut@3.0.2: {}
|
||||||
|
|
||||||
eciesjs@0.4.18:
|
eciesjs@0.4.18:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0)
|
'@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0)
|
||||||
@@ -6184,6 +6330,8 @@ snapshots:
|
|||||||
|
|
||||||
github-slugger@2.0.0: {}
|
github-slugger@2.0.0: {}
|
||||||
|
|
||||||
|
gl-matrix@3.4.4: {}
|
||||||
|
|
||||||
glob-parent@5.1.2:
|
glob-parent@5.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
@@ -6418,6 +6566,8 @@ snapshots:
|
|||||||
|
|
||||||
json-schema-typed@8.0.2: {}
|
json-schema-typed@8.0.2: {}
|
||||||
|
|
||||||
|
json-stringify-pretty-compact@4.0.0: {}
|
||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
jsonfile@6.2.1:
|
jsonfile@6.2.1:
|
||||||
@@ -6426,6 +6576,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
|
|
||||||
|
kdbush@4.0.2: {}
|
||||||
|
|
||||||
kleur@3.0.3: {}
|
kleur@3.0.3: {}
|
||||||
|
|
||||||
kleur@4.1.5: {}
|
kleur@4.1.5: {}
|
||||||
@@ -6508,6 +6660,28 @@ snapshots:
|
|||||||
'@babel/types': 7.29.0
|
'@babel/types': 7.29.0
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
maplibre-gl@5.24.0:
|
||||||
|
dependencies:
|
||||||
|
'@mapbox/jsonlint-lines-primitives': 2.0.2
|
||||||
|
'@mapbox/point-geometry': 1.1.0
|
||||||
|
'@mapbox/tiny-sdf': 2.2.0
|
||||||
|
'@mapbox/unitbezier': 0.0.1
|
||||||
|
'@mapbox/vector-tile': 2.0.4
|
||||||
|
'@mapbox/whoots-js': 3.1.0
|
||||||
|
'@maplibre/geojson-vt': 6.1.0
|
||||||
|
'@maplibre/maplibre-gl-style-spec': 24.8.5
|
||||||
|
'@maplibre/mlt': 1.1.9
|
||||||
|
'@maplibre/vt-pbf': 4.3.0
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
earcut: 3.0.2
|
||||||
|
gl-matrix: 3.4.4
|
||||||
|
kdbush: 4.0.2
|
||||||
|
murmurhash-js: 1.0.0
|
||||||
|
pbf: 4.0.1
|
||||||
|
potpack: 2.1.0
|
||||||
|
quickselect: 3.0.0
|
||||||
|
tinyqueue: 3.0.0
|
||||||
|
|
||||||
markdown-table@3.0.4: {}
|
markdown-table@3.0.4: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
@@ -6897,6 +7071,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
|
|
||||||
|
murmurhash-js@1.0.0: {}
|
||||||
|
|
||||||
mute-stream@3.0.0: {}
|
mute-stream@3.0.0: {}
|
||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
@@ -7052,6 +7228,10 @@ snapshots:
|
|||||||
|
|
||||||
path-to-regexp@8.4.2: {}
|
path-to-regexp@8.4.2: {}
|
||||||
|
|
||||||
|
pbf@4.0.1:
|
||||||
|
dependencies:
|
||||||
|
resolve-protobuf-schema: 2.1.0
|
||||||
|
|
||||||
piccolore@0.1.3: {}
|
piccolore@0.1.3: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
@@ -7073,6 +7253,8 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
potpack@2.1.0: {}
|
||||||
|
|
||||||
powershell-utils@0.1.0: {}
|
powershell-utils@0.1.0: {}
|
||||||
|
|
||||||
pretty-ms@9.3.0:
|
pretty-ms@9.3.0:
|
||||||
@@ -7088,6 +7270,8 @@ snapshots:
|
|||||||
|
|
||||||
property-information@7.1.0: {}
|
property-information@7.1.0: {}
|
||||||
|
|
||||||
|
protocol-buffers-schema@3.6.1: {}
|
||||||
|
|
||||||
proxy-addr@2.0.7:
|
proxy-addr@2.0.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
forwarded: 0.2.0
|
forwarded: 0.2.0
|
||||||
@@ -7099,6 +7283,8 @@ snapshots:
|
|||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
|
quickselect@3.0.0: {}
|
||||||
|
|
||||||
radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -7305,6 +7491,10 @@ snapshots:
|
|||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
|
|
||||||
|
resolve-protobuf-schema@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
protocol-buffers-schema: 3.6.1
|
||||||
|
|
||||||
restore-cursor@5.1.0:
|
restore-cursor@5.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
onetime: 7.0.0
|
onetime: 7.0.0
|
||||||
@@ -7602,6 +7792,10 @@ snapshots:
|
|||||||
|
|
||||||
strip-final-newline@4.0.0: {}
|
strip-final-newline@4.0.0: {}
|
||||||
|
|
||||||
|
supercluster@8.0.1:
|
||||||
|
dependencies:
|
||||||
|
kdbush: 4.0.2
|
||||||
|
|
||||||
svgo@4.0.1:
|
svgo@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
commander: 11.1.0
|
commander: 11.1.0
|
||||||
@@ -7633,6 +7827,8 @@ snapshots:
|
|||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
tinyqueue@3.0.0: {}
|
||||||
|
|
||||||
tldts-core@7.0.28: {}
|
tldts-core@7.0.28: {}
|
||||||
|
|
||||||
tldts@7.0.28:
|
tldts@7.0.28:
|
||||||
@@ -7794,6 +7990,8 @@ snapshots:
|
|||||||
|
|
||||||
validate-npm-package-name@7.0.2: {}
|
validate-npm-package-name@7.0.2: {}
|
||||||
|
|
||||||
|
vanilla-cookieconsent@3.1.0: {}
|
||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|
||||||
vfile-location@5.0.3:
|
vfile-location@5.0.3:
|
||||||
|
|||||||
7
pnpm-workspace.yaml
Normal file
7
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
packages:
|
||||||
|
- "."
|
||||||
|
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
msw: true
|
||||||
|
sharp: true
|
||||||
11
solo.yml
Normal file
11
solo.yml
Normal 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: {}
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface Footer27Props {
|
interface Footer27Props {
|
||||||
@@ -12,137 +5,33 @@ interface Footer27Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Footer27 = ({ className }: 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 (
|
return (
|
||||||
<section className={cn("py-32", className)}>
|
<footer className={cn("px-5 pb-10 sm:px-8 lg:px-12", className)}>
|
||||||
<div className="container">
|
<div className="border-t border-border/80 pt-6">
|
||||||
<footer>
|
<div className="flex flex-col gap-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<motion.div
|
<p>© 2026 Matthias Meister Webdesign</p>
|
||||||
variants={containerVariants}
|
<p className="text-xs text-muted-foreground/90">
|
||||||
initial="hidden"
|
Diese Website misst Nutzung anonym und cookiefrei (Rybbit).
|
||||||
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>
|
</p>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div variants={itemVariants}>
|
|
||||||
<Button size="lg">Kostenloses Angebot anfordern</Button>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||||
<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
|
<a
|
||||||
href={link.href}
|
href="/impressum"
|
||||||
className="group flex items-center gap-2 py-2 text-foreground transition-colors hover:text-foreground/80"
|
className="underline underline-offset-4 transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
<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
|
Impressum
|
||||||
</a>
|
</a>
|
||||||
</span>
|
<a
|
||||||
<span className="text-muted-foreground">
|
href="/datenschutz"
|
||||||
<a href="/datenschutz" className="underline underline-offset-4 transition-colors hover:text-foreground">
|
className="underline underline-offset-4 transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
Datenschutz
|
Datenschutz
|
||||||
</a>
|
</a>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
190
src/components/landing-hero-section.tsx
Normal file
190
src/components/landing-hero-section.tsx
Normal 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>©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 };
|
||||||
17
src/components/landing-page-sections.tsx
Normal file
17
src/components/landing-page-sections.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { AboutSection } from "@/components/landing/about-section";
|
||||||
|
import { PackagesSection } from "@/components/landing/packages-section";
|
||||||
|
import { ProcessSection } from "@/components/landing/process-section";
|
||||||
|
import { ServicesSection } from "@/components/landing/services-section";
|
||||||
|
|
||||||
|
const LandingPageSections = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ServicesSection />
|
||||||
|
<AboutSection />
|
||||||
|
<ProcessSection />
|
||||||
|
<PackagesSection />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { LandingPageSections };
|
||||||
36
src/components/landing/about-section.tsx
Normal file
36
src/components/landing/about-section.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
const AboutSection = () => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="ueber"
|
||||||
|
className="grid border-b border-border lg:grid-cols-[0.36fr_0.64fr]"
|
||||||
|
>
|
||||||
|
<div className="border-b border-border bg-foreground px-5 py-12 text-background sm:px-8 lg:border-b-0 lg:border-r lg:px-12 lg:py-20">
|
||||||
|
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||||
|
Über mich (03)
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-6 max-w-[12ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||||
|
Ein Mensch. Kein Ticketsystem.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-center gap-8 px-5 py-12 sm:px-8 lg:px-12 lg:py-20">
|
||||||
|
<p className="max-w-xl text-[clamp(1.5rem,3vw,2rem)] font-black uppercase leading-[0.95] text-foreground">
|
||||||
|
Ich bin Matthias — Webdesigner aus Sachsen.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<p className="max-w-2xl text-lg leading-8 text-muted-foreground">
|
||||||
|
Ich baue Websites für Betriebe, die ihre Energie lieber in Kunden
|
||||||
|
stecken als in Technik. Kein Großraumbüro, kein Agentur-Overhead —
|
||||||
|
ein Ansprechpartner von der ersten Idee bis zum Go-live.
|
||||||
|
</p>
|
||||||
|
<p className="max-w-2xl text-lg leading-8 text-muted-foreground">
|
||||||
|
Meine Kunden sind Handwerker, Praxen, Salons und Dienstleister aus
|
||||||
|
der Region. Menschen, die eine Website wollen, die funktioniert —
|
||||||
|
und sich dann wieder um ihren Betrieb kümmern möchten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { AboutSection };
|
||||||
68
src/components/landing/contact-section.tsx
Normal file
68
src/components/landing/contact-section.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CornerDownRight, Mail, MapPin, Phone } from "lucide-react";
|
||||||
|
import { Map, MapControls, MapMarker, MarkerContent } from "@/components/ui/map";
|
||||||
|
|
||||||
|
/** Karl-Marx-Str. 22, 08451 Crimmitschau (OpenStreetMap) */
|
||||||
|
const OFFICE: [number, number] = [12.3829769, 50.8131218];
|
||||||
|
|
||||||
|
const ContactSection = () => {
|
||||||
|
return (
|
||||||
|
<section id="kontakt" className="grid min-h-[620px] lg:grid-cols-2">
|
||||||
|
<div className="flex flex-col justify-between px-5 py-14 sm:px-8 lg:px-12 lg:py-24">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||||
|
Kontakt (06)
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-8 max-w-[12ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl lg:text-8xl">
|
||||||
|
Erzählen Sie mir kurz von Ihrem Betrieb
|
||||||
|
</h2>
|
||||||
|
<p className="mt-8 max-w-2xl text-xl leading-8 text-muted-foreground">
|
||||||
|
Ein paar Sätze reichen: Was bieten Sie an, was soll die Website für
|
||||||
|
Sie tun, und wann soll sie online sein?
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="mailto:support@matthias-meister-webdesign.de"
|
||||||
|
className="mt-10 inline-flex items-center gap-3 bg-primary px-6 py-5 text-sm font-black uppercase tracking-[0.18em] text-primary-foreground transition hover:bg-foreground hover:text-background"
|
||||||
|
>
|
||||||
|
<CornerDownRight className="size-5" />
|
||||||
|
Kurze Nachricht schreiben
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 flex flex-col gap-5 border-t border-border pt-8 text-sm text-muted-foreground">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Mail className="size-4 shrink-0 text-primary" />
|
||||||
|
<span>support@matthias-meister-webdesign.de</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Phone className="size-4 shrink-0 text-primary" />
|
||||||
|
<span>Rückmeldung innerhalb von 24 Stunden</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<MapPin className="size-4 shrink-0 text-primary" />
|
||||||
|
<span>Karl-Marx-Str. 22, 08451 Crimmitschau</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="contact-map relative min-h-[400px] bg-primary lg:min-h-0">
|
||||||
|
<Map
|
||||||
|
center={OFFICE}
|
||||||
|
zoom={15}
|
||||||
|
theme="dark"
|
||||||
|
className="h-full w-full"
|
||||||
|
>
|
||||||
|
<MapControls position="bottom-right" showZoom />
|
||||||
|
<MapMarker longitude={OFFICE[0]} latitude={OFFICE[1]}>
|
||||||
|
<MarkerContent>
|
||||||
|
<div className="size-5 rounded-full border-[3px] border-primary-foreground bg-primary shadow-lg ring-4 ring-primary-foreground/50" />
|
||||||
|
</MarkerContent>
|
||||||
|
</MapMarker>
|
||||||
|
</Map>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ContactSection };
|
||||||
37
src/components/landing/deliverables-section.tsx
Normal file
37
src/components/landing/deliverables-section.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
const deliverables = [
|
||||||
|
"Strategie und Seitenstruktur",
|
||||||
|
"Individuelles Screen-Design",
|
||||||
|
"Moderne, schnelle Umsetzung",
|
||||||
|
"Kontaktformular und Datenschutz",
|
||||||
|
"Hosting, Wartung und Analytics",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DeliverablesSection = () => {
|
||||||
|
return (
|
||||||
|
<section className="grid border-b border-border lg:grid-cols-2">
|
||||||
|
<div className="bg-foreground px-5 py-14 text-background sm:px-8 lg:px-12 lg:py-24">
|
||||||
|
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||||
|
Ergebnis (03)
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-8 max-w-[10ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||||
|
Was am Ende steht
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid content-center gap-4 px-5 py-12 sm:px-8 lg:px-12">
|
||||||
|
{deliverables.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className="flex items-center gap-4 border-b border-border pb-4 text-lg font-semibold uppercase tracking-[0.08em]"
|
||||||
|
>
|
||||||
|
<Check className="size-5 text-primary" />
|
||||||
|
<span>{item}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { DeliverablesSection };
|
||||||
110
src/components/landing/packages-section.tsx
Normal file
110
src/components/landing/packages-section.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
const packages = [
|
||||||
|
{
|
||||||
|
name: "Basis",
|
||||||
|
price: "799 EUR",
|
||||||
|
detail: "Eine starke Seite für ein klares Angebot.",
|
||||||
|
highlighted: false,
|
||||||
|
features: [
|
||||||
|
"One-Page-Website",
|
||||||
|
"Mobil optimiert",
|
||||||
|
"Kontaktformular",
|
||||||
|
"DSGVO & Impressum",
|
||||||
|
"Hosting für 1 Jahr",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Profi",
|
||||||
|
price: "1.499 EUR",
|
||||||
|
detail: "Mehrere Seiten für Betriebe mit mehr zu zeigen.",
|
||||||
|
highlighted: true,
|
||||||
|
features: [
|
||||||
|
"Alles aus Basis",
|
||||||
|
"Bis zu 5 Unterseiten",
|
||||||
|
"Individuelles Design",
|
||||||
|
"SEO-Grundoptimierung",
|
||||||
|
"Google Maps Einbindung",
|
||||||
|
"Kontaktformular",
|
||||||
|
"DSGVO & Impressum",
|
||||||
|
"Hosting für 1 Jahr",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Maßarbeit",
|
||||||
|
price: "2.499 EUR+",
|
||||||
|
detail: "Ihr Betrieb, Ihre Regeln. Inhalte selbst ändern, Seiten ergänzen, wachsen.",
|
||||||
|
highlighted: false,
|
||||||
|
features: [
|
||||||
|
"Alles aus Profi",
|
||||||
|
"Unbegrenzte Seiten",
|
||||||
|
"Inhalte selbst pflegbar (CMS)",
|
||||||
|
"Individuelles Design & Struktur",
|
||||||
|
"SEO-Optimierung",
|
||||||
|
"Blog oder News-Bereich",
|
||||||
|
"Erweiterte Funktionen",
|
||||||
|
"DSGVO & Impressum",
|
||||||
|
"Hosting für 1 Jahr",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PackagesSection = () => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="pakete"
|
||||||
|
className="border-b border-border px-5 py-14 sm:px-8 lg:px-12 lg:py-24"
|
||||||
|
>
|
||||||
|
<div className="grid gap-8 lg:grid-cols-[0.45fr_0.55fr]">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||||
|
Pakete (05)
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-8 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||||
|
Festpreis. Punkt.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{packages.map((item) => (
|
||||||
|
<article
|
||||||
|
key={item.name}
|
||||||
|
className={`grid gap-6 border p-5 sm:grid-cols-[0.5fr_0.5fr] sm:p-6 ${item.highlighted ? "border-primary bg-primary/6" : "border-border"}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<p className="text-sm uppercase tracking-[0.24em] text-primary">
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
{item.highlighted ? (
|
||||||
|
<span className="bg-primary px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.2em] text-primary-foreground">
|
||||||
|
Beliebteste Wahl
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-4xl font-black uppercase">
|
||||||
|
{item.price}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-lg leading-7 text-muted-foreground">
|
||||||
|
{item.detail}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-col gap-2.5 self-center">
|
||||||
|
{item.features.map((feature) => (
|
||||||
|
<li
|
||||||
|
key={feature}
|
||||||
|
className="flex items-center gap-3 text-sm text-foreground/85"
|
||||||
|
>
|
||||||
|
<Check className="size-4 shrink-0 text-primary" />
|
||||||
|
<span>{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { PackagesSection };
|
||||||
61
src/components/landing/process-section.tsx
Normal file
61
src/components/landing/process-section.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
number: "01",
|
||||||
|
title: "Gespräch",
|
||||||
|
text: "15 Minuten telefonieren. Sie erzählen, ich höre zu.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "02",
|
||||||
|
title: "Konzept",
|
||||||
|
text: "Seitenstruktur und Design-Vorschlag innerhalb einer Woche.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "03",
|
||||||
|
title: "Umsetzung",
|
||||||
|
text: "Fertige Website in 2–4 Wochen. Feedback, Anpassung, fertig.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "04",
|
||||||
|
title: "Online",
|
||||||
|
text: "Ich schalte live, richte Hosting ein, kümmere mich um den Rest.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ProcessSection = () => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="ablauf"
|
||||||
|
className="border-b border-border px-5 py-14 sm:px-8 lg:px-12 lg:py-24"
|
||||||
|
>
|
||||||
|
<div className="mb-12">
|
||||||
|
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||||
|
Ablauf (04)
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-6 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||||
|
Vier Schritte. Fertig.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-0 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<article
|
||||||
|
key={step.number}
|
||||||
|
className={`relative flex flex-col gap-4 border-t border-border py-8 pr-6 lg:border-t-0 lg:border-l lg:py-0 lg:pl-8 lg:pr-10 ${i === 0 ? "lg:border-l-0 lg:pl-0" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="text-6xl font-black leading-none text-primary/25">
|
||||||
|
{step.number}
|
||||||
|
</span>
|
||||||
|
<h3 className="text-xl font-black uppercase leading-none">
|
||||||
|
{step.title}
|
||||||
|
</h3>
|
||||||
|
<p className="max-w-xs text-base leading-7 text-muted-foreground">
|
||||||
|
{step.text}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ProcessSection };
|
||||||
55
src/components/landing/services-section.tsx
Normal file
55
src/components/landing/services-section.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
const services = [
|
||||||
|
{
|
||||||
|
number: "01",
|
||||||
|
title: "Website",
|
||||||
|
text: "Vom Elektriker bis zur Physiotherapie — eine Seite, die in drei Sekunden zeigt, was Sie machen und wie man Sie erreicht.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "02",
|
||||||
|
title: "Struktur",
|
||||||
|
text: "Angebot, Leistungen, Ablauf und Kontakt — alles dort, wo Besucher es erwarten. Damit aus Klicks Anrufe werden.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "03",
|
||||||
|
title: "Technik",
|
||||||
|
text: "Lädt in unter zwei Sekunden, sieht auf jedem Handy gut aus und ist rechtssicher. Änderungen später? Ein Anruf genügt.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ServicesSection = () => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="leistungen"
|
||||||
|
className="grid border-b border-border lg:grid-cols-[0.36fr_0.64fr]"
|
||||||
|
>
|
||||||
|
<div className="border-b border-border px-5 py-12 sm:px-8 lg:border-b-0 lg:border-r lg:px-12 lg:py-20">
|
||||||
|
<p className="text-sm uppercase tracking-[0.3em] text-primary">
|
||||||
|
Leistungen (02)
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-6 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
|
||||||
|
Drei Schritte. Eine Website.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{services.map((service) => (
|
||||||
|
<article
|
||||||
|
key={service.number}
|
||||||
|
className="grid gap-8 px-5 py-10 sm:px-8 md:grid-cols-[7rem_0.4fr_1fr] lg:px-12"
|
||||||
|
>
|
||||||
|
<span className="text-5xl font-black text-primary">
|
||||||
|
{service.number}
|
||||||
|
</span>
|
||||||
|
<h3 className="text-3xl font-black uppercase leading-none">
|
||||||
|
{service.title}
|
||||||
|
</h3>
|
||||||
|
<p className="max-w-2xl text-lg leading-8 text-muted-foreground">
|
||||||
|
{service.text}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ServicesSection };
|
||||||
@@ -1,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 };
|
|
||||||
@@ -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-image:linear-gradient(transparent_25%,black_25%,black_75%,transparent_75%)] [background-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 };
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Accordion as AccordionPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function Accordion({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<AccordionPrimitive.Root
|
|
||||||
data-slot="accordion"
|
|
||||||
className={cn("flex w-full flex-col", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccordionItem({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
|
||||||
return (
|
|
||||||
<AccordionPrimitive.Item
|
|
||||||
data-slot="accordion-item"
|
|
||||||
className={cn("not-last:border-b", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccordionTrigger({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<AccordionPrimitive.Header className="flex">
|
|
||||||
<AccordionPrimitive.Trigger
|
|
||||||
data-slot="accordion-trigger"
|
|
||||||
className={cn(
|
|
||||||
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
|
||||||
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
|
||||||
</AccordionPrimitive.Trigger>
|
|
||||||
</AccordionPrimitive.Header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccordionContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<AccordionPrimitive.Content
|
|
||||||
data-slot="accordion-content"
|
|
||||||
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</AccordionPrimitive.Content>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
import { Slot } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const badgeVariants = cva(
|
|
||||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
|
||||||
secondary:
|
|
||||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
|
||||||
outline:
|
|
||||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
|
||||||
ghost:
|
|
||||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function Badge({
|
|
||||||
className,
|
|
||||||
variant = "default",
|
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span"> &
|
|
||||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
|
||||||
const Comp = asChild ? Slot.Root : "span"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="badge"
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(badgeVariants({ variant }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
import { Slot } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
|
||||||
outline:
|
|
||||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
|
||||||
secondary:
|
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
|
||||||
ghost:
|
|
||||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default:
|
|
||||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
||||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
||||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
||||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
||||||
icon: "size-8",
|
|
||||||
"icon-xs":
|
|
||||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
|
||||||
"icon-sm":
|
|
||||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
|
||||||
"icon-lg": "size-9",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function Button({
|
|
||||||
className,
|
|
||||||
variant = "default",
|
|
||||||
size = "default",
|
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"button"> &
|
|
||||||
VariantProps<typeof buttonVariants> & {
|
|
||||||
asChild?: boolean
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot.Root : "button"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="button"
|
|
||||||
data-variant={variant}
|
|
||||||
data-size={size}
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
import { useMemo } from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
|
|
||||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
|
||||||
return (
|
|
||||||
<fieldset
|
|
||||||
data-slot="field-set"
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldLegend({
|
|
||||||
className,
|
|
||||||
variant = "legend",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
|
||||||
return (
|
|
||||||
<legend
|
|
||||||
data-slot="field-legend"
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(
|
|
||||||
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="field-group"
|
|
||||||
className={cn(
|
|
||||||
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldVariants = cva(
|
|
||||||
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
orientation: {
|
|
||||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
|
||||||
horizontal:
|
|
||||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
|
||||||
responsive:
|
|
||||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
orientation: "vertical",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function Field({
|
|
||||||
className,
|
|
||||||
orientation = "vertical",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="group"
|
|
||||||
data-slot="field"
|
|
||||||
data-orientation={orientation}
|
|
||||||
className={cn(fieldVariants({ orientation }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="field-content"
|
|
||||||
className={cn(
|
|
||||||
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldLabel({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Label>) {
|
|
||||||
return (
|
|
||||||
<Label
|
|
||||||
data-slot="field-label"
|
|
||||||
className={cn(
|
|
||||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
|
||||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="field-label"
|
|
||||||
className={cn(
|
|
||||||
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
data-slot="field-description"
|
|
||||||
className={cn(
|
|
||||||
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
|
||||||
"last:mt-0 nth-last-2:-mt-1",
|
|
||||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldSeparator({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & {
|
|
||||||
children?: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="field-separator"
|
|
||||||
data-content={!!children}
|
|
||||||
className={cn(
|
|
||||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Separator className="absolute inset-0 top-1/2" />
|
|
||||||
{children && (
|
|
||||||
<span
|
|
||||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
|
||||||
data-slot="field-separator-content"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldError({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
errors,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & {
|
|
||||||
errors?: Array<{ message?: string } | undefined>
|
|
||||||
}) {
|
|
||||||
const content = useMemo(() => {
|
|
||||||
if (children) {
|
|
||||||
return children
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!errors?.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueErrors = [
|
|
||||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
|
||||||
]
|
|
||||||
|
|
||||||
if (uniqueErrors?.length == 1) {
|
|
||||||
return uniqueErrors[0]?.message
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
|
||||||
{uniqueErrors.map(
|
|
||||||
(error, index) =>
|
|
||||||
error?.message && <li key={index}>{error.message}</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}, [children, errors])
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
data-slot="field-error"
|
|
||||||
className={cn("text-sm font-normal text-destructive", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Field,
|
|
||||||
FieldLabel,
|
|
||||||
FieldDescription,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLegend,
|
|
||||||
FieldSeparator,
|
|
||||||
FieldSet,
|
|
||||||
FieldContent,
|
|
||||||
FieldTitle,
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { memo, useCallback, useEffect, useRef } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { animate } from "motion/react";
|
|
||||||
|
|
||||||
interface GlowingEffectProps {
|
|
||||||
blur?: number;
|
|
||||||
inactiveZone?: number;
|
|
||||||
proximity?: number;
|
|
||||||
spread?: number;
|
|
||||||
variant?: "default" | "white";
|
|
||||||
glow?: boolean;
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
movementDuration?: number;
|
|
||||||
borderWidth?: number;
|
|
||||||
}
|
|
||||||
const GlowingEffect = memo(
|
|
||||||
({
|
|
||||||
blur = 0,
|
|
||||||
inactiveZone = 0.7,
|
|
||||||
proximity = 0,
|
|
||||||
spread = 20,
|
|
||||||
variant = "default",
|
|
||||||
glow = false,
|
|
||||||
className,
|
|
||||||
movementDuration = 2,
|
|
||||||
borderWidth = 1,
|
|
||||||
disabled = true,
|
|
||||||
}: GlowingEffectProps) => {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const lastPosition = useRef({ x: 0, y: 0 });
|
|
||||||
const animationFrameRef = useRef<number>(0);
|
|
||||||
|
|
||||||
const handleMove = useCallback(
|
|
||||||
(e?: MouseEvent | { x: number; y: number }) => {
|
|
||||||
if (!containerRef.current) return;
|
|
||||||
|
|
||||||
if (animationFrameRef.current) {
|
|
||||||
cancelAnimationFrame(animationFrameRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
animationFrameRef.current = requestAnimationFrame(() => {
|
|
||||||
const element = containerRef.current;
|
|
||||||
if (!element) return;
|
|
||||||
|
|
||||||
const { left, top, width, height } = element.getBoundingClientRect();
|
|
||||||
const mouseX = e?.x ?? lastPosition.current.x;
|
|
||||||
const mouseY = e?.y ?? lastPosition.current.y;
|
|
||||||
|
|
||||||
if (e) {
|
|
||||||
lastPosition.current = { x: mouseX, y: mouseY };
|
|
||||||
}
|
|
||||||
|
|
||||||
const center = [left + width * 0.5, top + height * 0.5];
|
|
||||||
const distanceFromCenter = Math.hypot(
|
|
||||||
mouseX - center[0],
|
|
||||||
mouseY - center[1]
|
|
||||||
);
|
|
||||||
const inactiveRadius = 0.5 * Math.min(width, height) * inactiveZone;
|
|
||||||
|
|
||||||
if (distanceFromCenter < inactiveRadius) {
|
|
||||||
element.style.setProperty("--active", "0");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive =
|
|
||||||
mouseX > left - proximity &&
|
|
||||||
mouseX < left + width + proximity &&
|
|
||||||
mouseY > top - proximity &&
|
|
||||||
mouseY < top + height + proximity;
|
|
||||||
|
|
||||||
element.style.setProperty("--active", isActive ? "1" : "0");
|
|
||||||
|
|
||||||
if (!isActive) return;
|
|
||||||
|
|
||||||
const currentAngle =
|
|
||||||
parseFloat(element.style.getPropertyValue("--start")) || 0;
|
|
||||||
let targetAngle =
|
|
||||||
(180 * Math.atan2(mouseY - center[1], mouseX - center[0])) /
|
|
||||||
Math.PI +
|
|
||||||
90;
|
|
||||||
|
|
||||||
const angleDiff = ((targetAngle - currentAngle + 180) % 360) - 180;
|
|
||||||
const newAngle = currentAngle + angleDiff;
|
|
||||||
|
|
||||||
animate(currentAngle, newAngle, {
|
|
||||||
duration: movementDuration,
|
|
||||||
ease: [0.16, 1, 0.3, 1],
|
|
||||||
onUpdate: (value) => {
|
|
||||||
element.style.setProperty("--start", String(value));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[inactiveZone, proximity, movementDuration]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (disabled) return;
|
|
||||||
|
|
||||||
const handleScroll = () => handleMove();
|
|
||||||
const handlePointerMove = (e: PointerEvent) => handleMove(e);
|
|
||||||
|
|
||||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
||||||
document.body.addEventListener("pointermove", handlePointerMove, {
|
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (animationFrameRef.current) {
|
|
||||||
cancelAnimationFrame(animationFrameRef.current);
|
|
||||||
}
|
|
||||||
window.removeEventListener("scroll", handleScroll);
|
|
||||||
document.body.removeEventListener("pointermove", handlePointerMove);
|
|
||||||
};
|
|
||||||
}, [handleMove, disabled]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none absolute -inset-px hidden rounded-[inherit] border opacity-0 transition-opacity",
|
|
||||||
glow && "opacity-100",
|
|
||||||
variant === "white" && "border-white",
|
|
||||||
disabled && "!block"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--blur": `${blur}px`,
|
|
||||||
"--spread": spread,
|
|
||||||
"--start": "0",
|
|
||||||
"--active": "0",
|
|
||||||
"--glowingeffect-border-width": `${borderWidth}px`,
|
|
||||||
"--repeating-conic-gradient-times": "5",
|
|
||||||
"--gradient":
|
|
||||||
variant === "white"
|
|
||||||
? `repeating-conic-gradient(
|
|
||||||
from 236.84deg at 50% 50%,
|
|
||||||
var(--black),
|
|
||||||
var(--black) calc(25% / var(--repeating-conic-gradient-times))
|
|
||||||
)`
|
|
||||||
: `radial-gradient(circle, #dd7bbb 10%, #dd7bbb00 20%),
|
|
||||||
radial-gradient(circle at 40% 40%, #d79f1e 5%, #d79f1e00 15%),
|
|
||||||
radial-gradient(circle at 60% 60%, #5a922c 10%, #5a922c00 20%),
|
|
||||||
radial-gradient(circle at 40% 60%, #4c7894 10%, #4c789400 20%),
|
|
||||||
repeating-conic-gradient(
|
|
||||||
from 236.84deg at 50% 50%,
|
|
||||||
#dd7bbb 0%,
|
|
||||||
#d79f1e calc(25% / var(--repeating-conic-gradient-times)),
|
|
||||||
#5a922c calc(50% / var(--repeating-conic-gradient-times)),
|
|
||||||
#4c7894 calc(75% / var(--repeating-conic-gradient-times)),
|
|
||||||
#dd7bbb calc(100% / var(--repeating-conic-gradient-times))
|
|
||||||
)`,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none absolute inset-0 rounded-[inherit] opacity-100 transition-opacity",
|
|
||||||
glow && "opacity-100",
|
|
||||||
blur > 0 && "blur-[var(--blur)] ",
|
|
||||||
className,
|
|
||||||
disabled && "!hidden"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"glow",
|
|
||||||
"rounded-[inherit]",
|
|
||||||
'after:content-[""] after:rounded-[inherit] after:absolute after:inset-[calc(-1*var(--glowingeffect-border-width))]',
|
|
||||||
"after:[border:var(--glowingeffect-border-width)_solid_transparent]",
|
|
||||||
"after:[background:var(--gradient)] after:[background-attachment:fixed]",
|
|
||||||
"after:opacity-[var(--active)] after:transition-opacity after:duration-300",
|
|
||||||
"after:[mask-clip:padding-box,border-box]",
|
|
||||||
"after:[mask-composite:intersect]",
|
|
||||||
"after:[mask-image:linear-gradient(#0000,#0000),conic-gradient(from_calc((var(--start)-var(--spread))*1deg),#00000000_0deg,#fff,#00000000_calc(var(--spread)*2deg))]"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
GlowingEffect.displayName = "GlowingEffect";
|
|
||||||
|
|
||||||
export { GlowingEffect };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
data-slot="input"
|
|
||||||
className={cn(
|
|
||||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Input }
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Label as LabelPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Label({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<LabelPrimitive.Root
|
|
||||||
data-slot="label"
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Label }
|
|
||||||
1844
src/components/ui/map.tsx
Normal file
1844
src/components/ui/map.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Separator({
|
|
||||||
className,
|
|
||||||
orientation = "horizontal",
|
|
||||||
decorative = true,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<SeparatorPrimitive.Root
|
|
||||||
data-slot="separator"
|
|
||||||
decorative={decorative}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Separator }
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Tabs({
|
|
||||||
className,
|
|
||||||
orientation = "horizontal",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.Root
|
|
||||||
data-slot="tabs"
|
|
||||||
data-orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabsListVariants = cva(
|
|
||||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-muted",
|
|
||||||
line: "gap-1 bg-transparent",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function TabsList({
|
|
||||||
className,
|
|
||||||
variant = "default",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
|
||||||
VariantProps<typeof tabsListVariants>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.List
|
|
||||||
data-slot="tabs-list"
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(tabsListVariants({ variant }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabsTrigger({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.Trigger
|
|
||||||
data-slot="tabs-trigger"
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
|
||||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
|
||||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabsContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.Content
|
|
||||||
data-slot="tabs-content"
|
|
||||||
className={cn("flex-1 text-sm outline-none", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
|
||||||
596
src/components/ui/webcam-pixel-grid.tsx
Normal file
596
src/components/ui/webcam-pixel-grid.tsx
Normal 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;
|
||||||
38
src/lib/cookie-banner-info.ts
Normal file
38
src/lib/cookie-banner-info.ts
Normal 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
153
src/pages/datenschutz.astro
Normal 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
106
src/pages/impressum.astro
Normal 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>
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
---
|
---
|
||||||
import { About19 } from "@/components/about19";
|
import { LandingHeroSection } from "@/components/landing-hero-section";
|
||||||
import { Contact21 } from "@/components/contact21";
|
import { LandingPageSections } from "@/components/landing-page-sections";
|
||||||
import { Faq7 } from "@/components/faq7";
|
import { ContactSection } from "@/components/landing/contact-section";
|
||||||
import { Feature284 } from "@/components/feature284";
|
|
||||||
import { Footer27 } from "@/components/footer27";
|
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";
|
import "@/styles/global.css";
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,16 +20,15 @@ import "@/styles/global.css";
|
|||||||
defer></script>
|
defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="mx-auto w-full max-w-7xl">
|
<main class="min-h-screen overflow-hidden bg-background text-foreground">
|
||||||
<Hero235 client:load />
|
<LandingHeroSection client:media="(min-width: 1024px)" />
|
||||||
<CTASection client:load />
|
<LandingPageSections />
|
||||||
<Feature284 client:load />
|
<ContactSection client:visible />
|
||||||
<Stats11 client:load />
|
<Footer27 />
|
||||||
<Pricing4 client:load />
|
</main>
|
||||||
<Faq7 client:load />
|
<script>
|
||||||
<About19 client:load />
|
import { initCookieInfoBanner } from "@/lib/cookie-banner-info";
|
||||||
<Contact21 client:load />
|
initCookieInfoBanner();
|
||||||
<Footer27 client:load />
|
</script>
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -49,38 +49,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.115 0.012 22);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.965 0.013 76);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(0.16 0.014 22);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.965 0.013 76);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(0.16 0.014 22);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.965 0.013 76);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.61 0.235 27);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0.01 76);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.22 0.016 22);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.965 0.013 76);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.19 0.014 22);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.73 0.021 76);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.61 0.235 27);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.985 0.01 76);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.965 0.013 76 / 20%);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.965 0.013 76 / 20%);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.61 0.235 27);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.87 0 0);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.556 0 0);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.439 0 0);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.371 0 0);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.269 0 0);
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.16 0.014 22);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.965 0.013 76);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.61 0.235 27);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0.01 76);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.22 0.016 22);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.965 0.013 76);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.965 0.013 76 / 20%);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.61 0.235 27);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -123,8 +123,37 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
text-rendering: geometricPrecision;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
14
tests/cookie-banner-info.test.mjs
Normal file
14
tests/cookie-banner-info.test.mjs
Normal 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/);
|
||||||
|
});
|
||||||
88
tests/landing-content.test.mjs
Normal file
88
tests/landing-content.test.mjs
Normal 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));
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user