Compare commits

...

2 Commits

Author SHA1 Message Date
8a4ec60655 Update contact email and improve code formatting across multiple pages
- Change contact email from hallo@matthias-meister.com to support@matthias-meister-webdesign.de in the contact section, Datenschutz, and Impressum pages.
- Enhance code readability by adjusting formatting and indentation in the main and section elements.
- Ensure consistent styling and structure across the affected components.
2026-05-07 10:40:59 +02:00
e039fdf555 Remove deprecated components from the project
- Delete unused components including About19, Contact21, CTA, Faq7, Feature284, Hero235, Landing, Pricing4, Stats11, and Accordion.
- Clean up the codebase by removing unnecessary files to improve maintainability and reduce clutter.
- Ensure that the removal of these components does not affect the existing functionality of the application.
2026-05-07 08:25:55 +02:00
31 changed files with 500 additions and 1943 deletions

View File

@@ -0,0 +1,52 @@
---
id: TASK-5
title: Show the webcam toggle only when a webcam is available
status: In Progress
assignee: []
created_date: '2026-05-06 20:11'
updated_date: '2026-05-07 05:58'
labels:
- frontend
- fallback
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Detect whether the browser reports an available webcam and only render the hero live-raster switch for visitors who can actually use the camera effect.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The hero checks browser media devices for at least one video input without prompting for camera permission.
- [x] #2 The webcam switch and helper copy are hidden when no video input is reported or media-device enumeration is unavailable.
- [x] #3 The switch still starts and stops the existing webcam pixel grid for visitors with an available webcam.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect current hero toggle and WebcamPixelGrid error behavior
2. Detect webcam availability with browser media-device enumeration
3. Hide the helper copy and switch when no video input is available
4. Preserve the existing webcam start/stop flow when a camera exists
5. Verify with build and update acceptance criteria
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented a hero fallback raster that activates when WebcamPixelGrid reports unavailable or denied camera access.
Added an explicit mediaDevices/getUserMedia availability check before requesting camera access.
Verified with npm run build; Astro built 3 static pages successfully. Dev server is running at http://127.0.0.1:4322/ for manual testing.
Changed direction after feedback: removed the visual fallback and now hide the switch unless enumerateDevices reports a videoinput.
Added a devicechange listener so the switch can appear or disappear if camera hardware availability changes during the session.
Verified the revised behavior compiles with npm run build.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,71 @@
---
id: TASK-6
title: Modularisiere die Landingpage und entferne ungenutzte Komponenten
status: In Progress
assignee: []
created_date: '2026-05-07 06:21'
updated_date: '2026-05-07 06:23'
labels:
- refactor
- frontend
dependencies: []
modified_files:
- src/pages/index.astro
- tests/landing-content.test.mjs
- src/components/landing-page-sections.tsx
- src/components/landing/services-section.tsx
- src/components/landing/deliverables-section.tsx
- src/components/landing/packages-section.tsx
- src/components/landing/contact-section.tsx
- src/components/landing.tsx
- src/components/about19.tsx
- src/components/contact21.tsx
- src/components/cta.tsx
- src/components/faq7.tsx
- src/components/feature284.tsx
- src/components/hero235.tsx
- src/components/pricing4.tsx
- src/components/stats11.tsx
- src/components/ui/accordion.tsx
- src/components/ui/badge.tsx
- src/components/ui/button.tsx
- src/components/ui/field.tsx
- src/components/ui/glowing-effect.tsx
- src/components/ui/input.tsx
- src/components/ui/label.tsx
- src/components/ui/separator.tsx
- src/components/ui/tabs.tsx
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Die bestehende Landingpage soll in klarere, kleinere Komponenten aufgeteilt werden. Nicht mehr referenzierte Komponenten sollen entfernt werden, ohne vorhandene Nutzer- oder laufende Änderungen zurückzudrehen.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Die Hauptseite nutzt klar benannte modulare Komponenten statt einer monolithischen LandingRest-Komponente.
- [x] #2 Nicht benötigte Komponenten im Komponentenordner sind entfernt oder nicht mehr Teil der Codebasis.
- [x] #3 Build und vorhandene Tests laufen nach der Änderung ohne Fehler.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Bestehende Landingpage-Struktur und Import-Verwendung prüfen.
2. LandingRest in klar benannte Sektionen extrahieren und Index-Import aktualisieren.
3. Nicht referenzierte Template- und UI-Komponenten entfernen.
4. Tests an neue Struktur anpassen und Build/Test ausführen.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
LandingRest wurde in ServicesSection, DeliverablesSection, PackagesSection und ContactSection extrahiert; index.astro nutzt nun LandingPageSections.
Nicht referenzierte Template-Komponenten sowie deren ungenutzte UI-Hilfskomponenten wurden entfernt. Vorbestehende Änderungen an landing-hero-section.tsx und ui/webcam-pixel-grid.tsx blieben unangetastet.
Verifikation: node --test tests/*.mjs und pnpm build laufen erfolgreich.
<!-- SECTION:NOTES:END -->

View File

@@ -1,60 +0,0 @@
import { cn } from "@/lib/utils";
interface About19Props {
className?: string;
}
const About19 = ({ className }: About19Props) => {
return (
<section className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}>
<div className="mx-auto max-w-6xl">
<div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,0.88fr)] lg:items-start lg:gap-14">
<div className="overflow-hidden rounded-lg border border-border bg-card">
<img
src="/about.jpg"
alt="Matthias Meister bei der Webentwicklung"
className="h-[20rem] w-full object-cover sm:h-[28rem] lg:h-[34rem]"
/>
</div>
<div className="max-w-xl">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
Über die Zusammenarbeit
</p>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
Hallo, ich bin Matthias. Zurück in der Region und hier, um zu
bleiben.
</h2>
<div className="mt-8 space-y-5 text-base leading-8 text-muted-foreground">
<p>
Ich bin in der Region aufgewachsen, war durch die Bundeswehr
viele Jahre weg und bin jetzt zurück.
</p>
<p>
Seit über 15 Jahren beschäftige ich mich mit Webentwicklung und
Software. Einen Großteil davon intern für die Bundeswehr:
Projekte, die ich Ihnen leider nicht zeigen kann. Was ich Ihnen
zeigen kann: wie ich arbeite. Zuverlässig, präzise und ohne
unnötigen Schnickschnack.
</p>
<p>
Neben Websites für regionale Unternehmen entwickle ich eigene
Software und Apps. Wenn Ihre Anforderungen irgendwann über eine
einfache Website hinausgehen, bleibt der Ansprechpartner also
derselbe.
</p>
</div>
<p className="mt-8 rounded-lg border border-border bg-card p-5 text-base font-medium leading-7 text-foreground">
Mein Ziel: Unternehmen aus der Region mit einer Website
ausstatten, die funktioniert, gefunden wird und Ihnen keine
Kopfschmerzen macht.
</p>
</div>
</div>
</div>
</section>
);
};
export { About19 };

View File

@@ -1,219 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { CornerDownRight, LoaderIcon } from "lucide-react";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
const contactFormSchema = z.object({
name: z.string().min(1, "Bitte geben Sie Ihren Namen ein"),
email: z
.string()
.min(1, "Bitte geben Sie Ihre E-Mail ein")
.email("Bitte geben Sie eine gültige E-Mail ein"),
message: z.string().min(1, "Bitte beschreiben Sie kurz Ihr Anliegen"),
});
type ContactFormData = z.infer<typeof contactFormSchema>;
interface Contact21Props {
className?: string;
onSubmit?: (data: ContactFormData) => Promise<void>;
}
const Contact21 = ({ className, onSubmit }: Contact21Props) => {
const [isSubmitted, setIsSubmitted] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
const form = useForm<ContactFormData>({
resolver: zodResolver(contactFormSchema),
mode: "onSubmit",
reValidateMode: "onSubmit",
defaultValues: {
name: "",
email: "",
message: "",
},
});
const handleFormSubmit = async (data: ContactFormData) => {
try {
if (onSubmit) {
await onSubmit(data);
} else {
console.log("Form submitted:", data);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
setIsSubmitted(true);
setShowSuccess(true);
form.reset();
setTimeout(() => setShowSuccess(false), 4500);
setTimeout(() => setIsSubmitted(false), 5000);
} catch {
form.setError("root", {
message: "Beim Senden ist etwas schiefgelaufen. Bitte versuchen Sie es erneut.",
});
}
};
return (
<section
id="kontakt"
className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}
>
<div className="mx-auto max-w-6xl">
<div className="grid gap-10 rounded-lg border border-border bg-card p-5 sm:p-8 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)] lg:gap-14 lg:p-10">
<div className="flex w-full max-w-lg flex-col justify-between gap-10">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
Kontakt
</p>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
Erzählen Sie kurz, worum es geht.
</h2>
<p className="mt-5 text-base leading-7 text-muted-foreground">
Ich melde mich innerhalb von 24 Stunden mit einer ersten
Einschätzung und dem passenden nächsten Schritt.
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex size-12 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground">
MM
</div>
<div>
<h3 className="text-lg font-medium tracking-tight">
Matthias Meister
</h3>
<p className="text-sm text-foreground/40">
Freelance Webdesigner
</p>
</div>
</div>
</div>
<div className="w-full">
{isSubmitted && (
<div
className={cn(
"mb-4 rounded-lg border border-primary/20 bg-primary/10 p-4 text-center transition-opacity duration-500",
showSuccess ? "opacity-100" : "opacity-0",
)}
>
<p className="text-sm font-medium text-primary">
Vielen Dank! Ich melde mich in Kürze bei Ihnen.
</p>
</div>
)}
<form onSubmit={form.handleSubmit(handleFormSubmit)}>
<FieldGroup className="gap-0">
<Controller
control={form.control}
name="name"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name} className="sr-only">
Name
</FieldLabel>
<Input
{...field}
id={field.name}
aria-invalid={fieldState.invalid}
placeholder="Ihr Name*"
className="h-14 rounded-none border-0 border-b border-b-border bg-transparent! px-0 shadow-none placeholder:text-muted-foreground/65 focus-visible:border-b-primary focus-visible:ring-0 lg:text-base"
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name} className="sr-only">
E-Mail
</FieldLabel>
<Input
{...field}
id={field.name}
type="email"
aria-invalid={fieldState.invalid}
placeholder="Ihre E-Mail*"
className="h-14 rounded-none border-0 border-b border-b-border bg-transparent! px-0 shadow-none placeholder:text-muted-foreground/65 focus-visible:border-b-primary focus-visible:ring-0 lg:text-base"
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="message"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name} className="sr-only">
Nachricht
</FieldLabel>
<textarea
{...field}
id={field.name}
aria-invalid={fieldState.invalid}
placeholder="Nachricht: Worum geht es bei Ihrem Projekt?"
rows={4}
className="min-h-36 w-full rounded-none border-0 border-b border-b-border bg-transparent px-0 py-4 text-base text-foreground shadow-none outline-none placeholder:text-muted-foreground/65 focus-visible:border-b-primary focus-visible:ring-0 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive lg:text-base"
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
{form.formState.errors.root && (
<p className="text-sm text-destructive">
{form.formState.errors.root.message}
</p>
)}
<Button
className="mt-8 flex h-11 w-full items-center justify-center gap-2 rounded-md px-6 lg:w-fit lg:text-base"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<>
<LoaderIcon className="size-5 animate-spin" />
Wird gesendet...
</>
) : (
<>
<CornerDownRight className="size-5" />
Anfrage senden
</>
)}
</Button>
</FieldGroup>
</form>
</div>
</div>
</div>
</section>
);
};
export { Contact21 };

View File

@@ -1,68 +0,0 @@
import { cn } from "@/lib/utils";
const trustAnchors = [
{
title: "Direkter Kontakt",
description:
"Sie sprechen mit dem Menschen, der die Website auch plant und baut.",
note: "Keine Vertriebsrunde, keine unklaren Übergänge.",
},
{
title: "15+ Jahre Erfahrung",
description:
"Webentwicklung und Software mit Fokus auf robuste, wartbare Lösungen.",
note: "Praxis statt Buzzwords und Technik nur dort, wo sie wirklich hilft.",
},
{
title: "Hosting in Sachsen",
description:
"Deutsche Server, DSGVO-konform und passend für regionale Unternehmen.",
note: "Greifbar, nachvollziehbar und ohne unnötiges Zusatztheater.",
},
];
export default function CTASection() {
return (
<section className="px-4 pb-14 sm:px-6 lg:px-8 lg:pb-20">
<div className="mx-auto max-w-6xl border-y border-border/80 py-9 lg:grid lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.55fr)] lg:gap-14 lg:py-11">
<div className="max-w-md space-y-4 lg:pt-1">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
Vor dem Angebot
</p>
<h2 className="text-2xl font-semibold tracking-tight text-balance lg:text-3xl">
Erst verstehen, dann bauen.
</h2>
<p className="text-base leading-7 text-muted-foreground">
Die Zusammenarbeit ist bewusst direkt gehalten: ein Gespräch, eine
klare Empfehlung und ein Vorschlag, der zu Ihrem Betrieb passt.
</p>
</div>
<dl className="mt-8 grid gap-6 sm:grid-cols-3 lg:mt-0 lg:gap-0">
{trustAnchors.map((item, index) => (
<div
key={item.title}
className={cn(
"space-y-3",
index === 0
? ""
: "border-t border-border/70 pt-5 sm:border-t-0 sm:border-l sm:pl-6 sm:pt-0 lg:pl-8",
)}
>
<dt className="text-sm font-semibold text-foreground">
{item.title}
</dt>
<dd className="space-y-2">
<p className="text-base font-medium leading-7 text-balance text-foreground lg:text-lg">
{item.description}
</p>
<p className="text-sm leading-6 text-muted-foreground">
{item.note}
</p>
</dd>
</div>
))}
</dl>
</div>
</section>
);
}

View File

@@ -1,86 +0,0 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const faqs = [
{
question: "Wie lange dauert es bis meine Website fertig ist?",
answer:
"In der Regel ist Ihre Website innerhalb von zwei Wochen fertig — vom ersten Gespräch bis zum Go-Live. Nach der Entwicklung bekommen Sie einen Vorschau-Link, damit Sie alles in Ruhe prüfen können. Erst wenn Sie zufrieden sind, geht die Seite online.",
},
{
question: "Was passiert wenn ich das Hosting kündige?",
answer:
"Ihre Website und Ihre Domain gehören Ihnen — immer. Wenn Sie das Hosting kündigen, übertrage ich Ihnen alles ohne Wenn und Aber. Keine versteckten Abhängigkeiten, das ist vertraglich festgehalten.",
},
{
question: "Ich habe schon eine Domain — was passiert damit?",
answer:
"Kein Problem. Wir zeigen Ihre bestehende Domain einfach auf die neue Website um. Falls Sie möchten, kann ich die Domain auch zu mir umziehen — das macht die Verwaltung einfacher, ist aber kein Muss.",
},
{
question: "Brauche ich technisches Wissen?",
answer:
"Keins. Sie kümmern Sie um Ihr Geschäft, ich um alles Technische. Von der Domain über die E-Mails bis zu Updates — das liegt bei mir.",
},
{
question: "Kümmern Sie sich auch um Impressum und Datenschutz?",
answer:
"Ja, jede Website die ich baue kommt mit einem rechtssicheren Impressum und einer DSGVO-konformen Datenschutzerklärung — inklusive übersichtlichem Cookie-Hinweis, wo nötig, statt Chaos und unnötigen Tracking-Dialogen.",
},
];
interface Faq7Props {
className?: string;
}
const Faq7 = ({ className }: Faq7Props) => {
return (
<section className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}>
<div className="mx-auto max-w-6xl">
<div className="grid gap-10 border-t border-border/80 pt-10 md:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:gap-16">
<div className="flex max-w-md flex-col gap-6">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
Häufige Fragen
</p>
<h2 className="text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
Vor dem Start soll nichts schwammig bleiben.
</h2>
<p className="text-base leading-7 text-muted-foreground">
Falls noch etwas offen ist, schreiben Sie mir gern über das
<a
href="#kontakt"
className="mx-1 whitespace-nowrap underline underline-offset-4 transition-colors hover:text-foreground"
>
Kontaktformular
</a>
.
</p>
<Button asChild size="lg" variant="outline" className="w-fit rounded-md">
<a href="#kontakt">Frage stellen</a>
</Button>
</div>
<Accordion type="multiple" className="rounded-lg border border-border bg-card px-4">
{faqs.map((faq, index) => (
<AccordionItem key={index} value={`item-${index}`}>
<AccordionTrigger className="text-left text-base font-semibold">
{faq.question}
</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
{faq.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</div>
</section>
);
};
export { Faq7 };

View File

@@ -1,93 +0,0 @@
import {
Gauge,
Handshake,
MapPinned,
Search,
Smartphone,
} from "lucide-react";
import { cn } from "@/lib/utils";
const featureData = [
{
desc: "Die Startseite sagt schnell, für wen Sie arbeiten, was Sie anbieten und wie Interessenten Kontakt aufnehmen.",
title: "Klare Positionierung",
badgeTitle: "01",
icon: MapPinned,
},
{
desc: "Gestaltung, Texte und Struktur wirken seriös, ohne den Charakter eines regionalen Betriebs glattzubügeln.",
title: "Glaubwürdiger Auftritt",
badgeTitle: "02",
icon: Handshake,
},
{
desc: "Telefonnummer, Formular und zentrale Informationen bleiben auf Smartphone und Desktop leicht erreichbar.",
title: "Mobil sauber geführt",
badgeTitle: "03",
icon: Smartphone,
},
{
desc: "Technik, Bilder und Inhalte werden so umgesetzt, dass die Seite schnell lädt und stabil bleibt.",
title: "Schnell und robust",
badgeTitle: "04",
icon: Gauge,
},
{
desc: "Google findet die richtigen Inhalte: Leistungen, Region, Kontakt und die wichtigsten Suchbegriffe.",
title: "Für Suche vorbereitet",
badgeTitle: "05",
icon: Search,
},
];
interface Feature284Props {
className?: string;
}
const Feature284 = ({ className }: Feature284Props) => {
return (
<section className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}>
<div className="mx-auto max-w-6xl">
<div className="grid gap-8 border-t border-border/80 pt-10 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.4fr)] lg:gap-16">
<div className="max-w-md">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
Was die Seite leisten muss
</p>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
Professionell heißt hier: verständlich, erreichbar, belastbar.
</h2>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{featureData.map((feature, index) => (
<div
key={index}
className="group flex min-h-52 flex-col justify-between rounded-lg border border-border bg-card p-5 transition-colors hover:border-primary/40"
>
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{feature.badgeTitle}
</p>
<feature.icon
className="size-5 text-primary transition-transform group-hover:-translate-y-0.5"
aria-hidden
/>
</div>
<div className="mt-10 space-y-3">
<h3 className="text-xl font-semibold tracking-tight">
{feature.title}
</h3>
<p className="text-sm leading-6 text-muted-foreground">
{feature.desc}
</p>
</div>
</div>
))}
</div>
</div>
</div>
</section>
);
};
export { Feature284 };

View File

@@ -1,116 +0,0 @@
import { ArrowRight, Mail, MapPin, Phone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface Hero235Props {
className?: string;
}
const Hero235 = ({ className }: Hero235Props) => {
return (
<section className={cn("px-4 pt-5 sm:px-6 lg:px-8", className)}>
<div className="mx-auto max-w-6xl">
<header className="flex flex-col gap-4 border-b border-border/80 pb-4 sm:flex-row sm:items-center sm:justify-between">
<a
href="/"
className="text-sm font-semibold tracking-tight text-foreground"
>
Matthias Meister Webdesign
</a>
<nav
aria-label="Direkte Kontaktwege"
className="flex flex-wrap items-center gap-x-5 gap-y-2 text-sm text-muted-foreground"
>
<a
href="tel:037627984400"
className="inline-flex items-center gap-1.5 transition-colors hover:text-foreground"
>
<Phone className="size-3.5" aria-hidden />
03762 798 4400
</a>
<a
href="mailto:info@matthias-meister-webdesign.de"
className="inline-flex items-center gap-1.5 transition-colors hover:text-foreground"
>
<Mail className="size-3.5" aria-hidden />
E-Mail
</a>
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5" aria-hidden />
Crimmitschau
</span>
</nav>
</header>
<div className="grid gap-10 py-16 sm:py-20 lg:grid-cols-[minmax(0,1.05fr)_minmax(320px,0.95fr)] lg:items-end lg:py-24">
<div className="flex max-w-3xl flex-col gap-7">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
Webdesign für regionale KMU
</p>
<h1 className="max-w-[12ch] text-5xl font-semibold leading-[0.95] tracking-tight text-balance text-foreground sm:text-6xl lg:text-7xl">
Websites, die vor Ort Vertrauen schaffen.
</h1>
<p className="max-w-[62ch] text-lg leading-8 text-muted-foreground">
Für Handwerk, Praxen und kleine Betriebe: klar erklärt, schnell
gebaut und so strukturiert, dass Besucher ohne Umwege verstehen,
warum sie gerade Sie anfragen sollten.
</p>
<div className="flex flex-col gap-3 pt-1 sm:flex-row sm:items-center">
<Button asChild size="lg" className="h-11 rounded-md px-5">
<a href="#kontakt">
Projekt anfragen
<ArrowRight className="shrink-0" aria-hidden />
</a>
</Button>
<Button
asChild
size="lg"
variant="outline"
className="h-11 rounded-md px-5"
>
<a href="#preise">Pakete ansehen</a>
</Button>
</div>
<dl className="grid gap-4 border-t border-border/80 pt-6 sm:grid-cols-3">
{[
["24h", "Rückmeldung"],
["2 Wochen", "typischer Go-Live"],
["Sachsen", "Hosting & Betrieb"],
].map(([value, label]) => (
<div key={label}>
<dt className="text-2xl font-semibold tracking-tight text-foreground">
{value}
</dt>
<dd className="mt-1 text-sm text-muted-foreground">
{label}
</dd>
</div>
))}
</dl>
</div>
<div className="relative">
<figure className="overflow-hidden rounded-lg border border-border bg-card">
<img
src="/about.jpg"
alt="Arbeitsplatz von Matthias Meister beim Entwickeln einer Website"
className="h-[19rem] w-full object-cover sm:h-[24rem] lg:h-[31rem]"
/>
<figcaption className="grid gap-2 border-t border-border bg-card/95 p-5 sm:grid-cols-[1fr_auto] sm:items-center">
<span className="text-sm font-medium text-foreground">
Direkt mit dem Entwickler statt mit wechselnden Agenturrollen.
</span>
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Persönlich geplant
</span>
</figcaption>
</figure>
</div>
</div>
</div>
</section>
);
};
export { Hero235 };

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { ArrowUpRight } from "lucide-react"; import { ArrowUpRight } from "lucide-react";
import { useState } from "react"; import { useEffect, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { WebcamPixelGrid } from "@/components/ui/webcam-pixel-grid"; import { WebcamPixelGrid } from "@/components/ui/webcam-pixel-grid";
@@ -11,6 +11,47 @@ const PRIMARY_HERO_BG = "#B54440";
const LandingHeroSection = () => { const LandingHeroSection = () => {
const [liveRasterOn, setLiveRasterOn] = useState(false); const [liveRasterOn, setLiveRasterOn] = useState(false);
const [hasWebcam, setHasWebcam] = useState(false);
useEffect(() => {
const mediaDevices = navigator.mediaDevices;
if (!mediaDevices?.enumerateDevices) {
setHasWebcam(false);
return;
}
const updateWebcamAvailability = async () => {
try {
const devices = await mediaDevices.enumerateDevices();
const hasVideoInput = devices.some(
(device) => device.kind === "videoinput",
);
setHasWebcam(hasVideoInput);
if (!hasVideoInput) {
setLiveRasterOn(false);
}
} catch {
setHasWebcam(false);
setLiveRasterOn(false);
}
};
updateWebcamAvailability();
mediaDevices.addEventListener?.("devicechange", updateWebcamAvailability);
return () => {
mediaDevices.removeEventListener?.(
"devicechange",
updateWebcamAvailability,
);
};
}, []);
const handleToggleLiveRaster = () => {
setLiveRasterOn((isOn) => !isOn);
};
return ( return (
<section className="relative grid min-h-screen grid-cols-1 border-b border-border lg:grid-cols-[1.08fr_0.92fr]"> <section className="relative grid min-h-screen grid-cols-1 border-b border-border lg:grid-cols-[1.08fr_0.92fr]">
@@ -70,6 +111,7 @@ const LandingHeroSection = () => {
borderColor="#ffffff" borderColor="#ffffff"
borderOpacity={0} borderOpacity={0}
quietWebcamErrors quietWebcamErrors
onWebcamError={() => setLiveRasterOn(false)}
/> />
</div> </div>
) : null} ) : null}
@@ -80,6 +122,7 @@ const LandingHeroSection = () => {
<span>&copy;2026</span> <span>&copy;2026</span>
</div> </div>
{hasWebcam ? (
<div className="hidden shrink-0 flex-col items-end gap-2 pt-4 lg:flex"> <div className="hidden shrink-0 flex-col items-end gap-2 pt-4 lg:flex">
<p <p
className={cn( className={cn(
@@ -97,10 +140,10 @@ const LandingHeroSection = () => {
aria-checked={liveRasterOn} aria-checked={liveRasterOn}
aria-label={ aria-label={
liveRasterOn liveRasterOn
? "Live-Raster und Kamera beenden" ? "Live-Raster beenden"
: "Live-Raster mit Kamera starten" : "Live-Raster mit Kamera starten"
} }
onClick={() => setLiveRasterOn((v) => !v)} onClick={handleToggleLiveRaster}
className={cn( 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", "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 liveRasterOn
@@ -116,6 +159,7 @@ const LandingHeroSection = () => {
/> />
</button> </button>
</div> </div>
) : null}
<div className="relative mt-auto pt-16 pb-8 lg:pt-24 lg:pb-10"> <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="pointer-events-none absolute bottom-0 right-0 h-36 w-36 border border-primary-foreground/35 sm:right-2 lg:right-10" />

View File

@@ -0,0 +1,17 @@
import { ContactSection } from "@/components/landing/contact-section";
import { DeliverablesSection } from "@/components/landing/deliverables-section";
import { PackagesSection } from "@/components/landing/packages-section";
import { ServicesSection } from "@/components/landing/services-section";
const LandingPageSections = () => {
return (
<>
<ServicesSection />
<DeliverablesSection />
<PackagesSection />
<ContactSection />
</>
);
};
export { LandingPageSections };

View File

@@ -1,185 +0,0 @@
import {
Check,
CornerDownRight,
Mail,
MapPin,
Phone,
} from "lucide-react";
const services = [
{
number: "01",
title: "Website",
text: "Eine klare Startseite oder ein kompletter Auftritt, der sofort zeigt, warum man Ihnen vertrauen kann.",
},
{
number: "02",
title: "Struktur",
text: "Angebot, Referenzen, Ablauf und Kontakt werden so sortiert, dass Besucher nicht suchen müssen.",
},
{
number: "03",
title: "Technik",
text: "Schnell, mobil sauber, DSGVO-sauber und so gebaut, dass spätere Änderungen nicht zum Projekt werden.",
},
];
const deliverables = [
"Strategie und Seitenstruktur",
"Individuelles Screen-Design",
"Moderne, schnelle Umsetzung",
"Kontaktformular und Datenschutz",
"Hosting, Wartung und Analytics",
];
const packages = [
{
name: "Basis",
price: "799 EUR",
detail: "Eine starke Seite für ein klares Angebot.",
},
{
name: "Profi",
price: "1.499 EUR",
detail: "Mehrere Seiten für Betriebe mit mehr zu zeigen.",
},
{
name: "Maßarbeit",
price: "2.499 EUR+",
detail: "Eigene Struktur, selbst pflegbar, für besondere Anforderungen.",
},
];
const LandingRest = () => {
return (
<>
<section
id="leistungen"
className="grid border-b border-border lg:grid-cols-[0.36fr_0.64fr]"
>
<div className="border-b border-border px-5 py-12 sm:px-8 lg:border-b-0 lg:border-r lg:px-12 lg:py-20">
<p className="text-sm uppercase tracking-[0.3em] text-primary">
Leistungen (02)
</p>
<h2 className="mt-6 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
Drei Schritte. Eine Website.
</h2>
</div>
<div className="divide-y divide-border">
{services.map((service) => (
<article
key={service.number}
className="grid gap-8 px-5 py-10 sm:px-8 md:grid-cols-[7rem_0.4fr_1fr] lg:px-12"
>
<span className="text-5xl font-black text-primary">
{service.number}
</span>
<h3 className="text-3xl font-black uppercase leading-none">
{service.title}
</h3>
<p className="max-w-2xl text-lg leading-8 text-muted-foreground">
{service.text}
</p>
</article>
))}
</div>
</section>
<section className="grid border-b border-border lg:grid-cols-2">
<div className="bg-foreground px-5 py-14 text-background sm:px-8 lg:px-12 lg:py-24">
<p className="text-sm uppercase tracking-[0.3em] text-primary">
Ergebnis (03)
</p>
<h2 className="mt-8 max-w-[10ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
Was am Ende steht
</h2>
</div>
<div className="grid content-center gap-4 px-5 py-12 sm:px-8 lg:px-12">
{deliverables.map((item) => (
<div
key={item}
className="flex items-center gap-4 border-b border-border pb-4 text-lg font-semibold uppercase tracking-[0.08em]"
>
<Check className="size-5 text-primary" />
<span>{item}</span>
</div>
))}
</div>
</section>
<section id="pakete" className="border-b border-border px-5 py-14 sm:px-8 lg:px-12 lg:py-24">
<div className="grid gap-8 lg:grid-cols-[0.45fr_0.55fr]">
<div>
<p className="text-sm uppercase tracking-[0.3em] text-primary">
Pakete (04)
</p>
<h2 className="mt-8 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
Kosten ohne Nebel
</h2>
</div>
<div className="grid gap-4">
{packages.map((item) => (
<article
key={item.name}
className="grid gap-4 border border-border p-5 sm:grid-cols-[0.5fr_0.5fr] sm:p-6"
>
<div>
<p className="text-sm uppercase tracking-[0.24em] text-primary">
{item.name}
</p>
<p className="mt-4 text-4xl font-black uppercase">
{item.price}
</p>
</div>
<p className="self-end text-lg leading-7 text-muted-foreground">
{item.detail}
</p>
</article>
))}
</div>
</div>
</section>
<section
id="kontakt"
className="grid min-h-[620px] lg:grid-cols-[0.72fr_0.28fr]"
>
<div className="px-5 py-14 sm:px-8 lg:px-12 lg:py-24">
<p className="text-sm uppercase tracking-[0.3em] text-primary">
Kontakt (05)
</p>
<h2 className="mt-8 max-w-[12ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl lg:text-8xl">
Erzählen Sie mir kurz von Ihrem Betrieb
</h2>
<p className="mt-8 max-w-2xl text-xl leading-8 text-muted-foreground">
Ein paar Sätze reichen: Was bieten Sie an, was soll die Website
für Sie tun, und wann soll sie online sein?
</p>
<a
href="mailto:hallo@matthias-meister.com"
className="mt-10 inline-flex items-center gap-3 bg-primary px-6 py-5 text-sm font-black uppercase tracking-[0.18em] text-primary-foreground transition hover:bg-foreground hover:text-background"
>
<CornerDownRight className="size-5" />
Anfrage per Mail senden
</a>
</div>
<div className="flex flex-col justify-end gap-6 bg-primary px-5 py-10 text-primary-foreground sm:px-8 lg:px-10">
<div className="flex gap-3">
<Mail className="size-5" />
<span>hallo@matthias-meister.com</span>
</div>
<div className="flex gap-3">
<Phone className="size-5" />
<span>Rückmeldung innerhalb von 24 Stunden</span>
</div>
<div className="flex gap-3">
<MapPin className="size-5" />
<span>Betriebe aus der Region</span>
</div>
</div>
</section>
</>
);
};
export { LandingRest };

View File

@@ -0,0 +1,46 @@
import { CornerDownRight, Mail, MapPin, Phone } from "lucide-react";
const ContactSection = () => {
return (
<section
id="kontakt"
className="grid min-h-[620px] lg:grid-cols-[0.72fr_0.28fr]"
>
<div className="px-5 py-14 sm:px-8 lg:px-12 lg:py-24">
<p className="text-sm uppercase tracking-[0.3em] text-primary">
Kontakt (05)
</p>
<h2 className="mt-8 max-w-[12ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl lg:text-8xl">
Erzählen Sie mir kurz von Ihrem Betrieb
</h2>
<p className="mt-8 max-w-2xl text-xl leading-8 text-muted-foreground">
Ein paar Sätze reichen: Was bieten Sie an, was soll die Website für
Sie tun, und wann soll sie online sein?
</p>
<a
href="mailto: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" />
Anfrage per Mail senden
</a>
</div>
<div className="flex flex-col justify-end gap-6 bg-primary px-5 py-10 text-primary-foreground sm:px-8 lg:px-10">
<div className="flex gap-3">
<Mail className="size-5" />
<span>support@matthias-meister-webdesign.de</span>
</div>
<div className="flex gap-3">
<Phone className="size-5" />
<span>Rückmeldung innerhalb von 24 Stunden</span>
</div>
<div className="flex gap-3">
<MapPin className="size-5" />
<span>Betriebe aus der Region</span>
</div>
</div>
</section>
);
};
export { ContactSection };

View File

@@ -0,0 +1,37 @@
import { Check } from "lucide-react";
const deliverables = [
"Strategie und Seitenstruktur",
"Individuelles Screen-Design",
"Moderne, schnelle Umsetzung",
"Kontaktformular und Datenschutz",
"Hosting, Wartung und Analytics",
];
const DeliverablesSection = () => {
return (
<section className="grid border-b border-border lg:grid-cols-2">
<div className="bg-foreground px-5 py-14 text-background sm:px-8 lg:px-12 lg:py-24">
<p className="text-sm uppercase tracking-[0.3em] text-primary">
Ergebnis (03)
</p>
<h2 className="mt-8 max-w-[10ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
Was am Ende steht
</h2>
</div>
<div className="grid content-center gap-4 px-5 py-12 sm:px-8 lg:px-12">
{deliverables.map((item) => (
<div
key={item}
className="flex items-center gap-4 border-b border-border pb-4 text-lg font-semibold uppercase tracking-[0.08em]"
>
<Check className="size-5 text-primary" />
<span>{item}</span>
</div>
))}
</div>
</section>
);
};
export { DeliverablesSection };

View File

@@ -0,0 +1,59 @@
const packages = [
{
name: "Basis",
price: "799 EUR",
detail: "Eine starke Seite für ein klares Angebot.",
},
{
name: "Profi",
price: "1.499 EUR",
detail: "Mehrere Seiten für Betriebe mit mehr zu zeigen.",
},
{
name: "Maßarbeit",
price: "2.499 EUR+",
detail: "Eigene Struktur, selbst pflegbar, für besondere Anforderungen.",
},
];
const 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 (04)
</p>
<h2 className="mt-8 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
Kosten ohne Nebel
</h2>
</div>
<div className="grid gap-4">
{packages.map((item) => (
<article
key={item.name}
className="grid gap-4 border border-border p-5 sm:grid-cols-[0.5fr_0.5fr] sm:p-6"
>
<div>
<p className="text-sm uppercase tracking-[0.24em] text-primary">
{item.name}
</p>
<p className="mt-4 text-4xl font-black uppercase">
{item.price}
</p>
</div>
<p className="self-end text-lg leading-7 text-muted-foreground">
{item.detail}
</p>
</article>
))}
</div>
</div>
</section>
);
};
export { PackagesSection };

View File

@@ -0,0 +1,55 @@
const services = [
{
number: "01",
title: "Website",
text: "Eine klare Startseite oder ein kompletter Auftritt, der sofort zeigt, warum man Ihnen vertrauen kann.",
},
{
number: "02",
title: "Struktur",
text: "Angebot, Referenzen, Ablauf und Kontakt werden so sortiert, dass Besucher nicht suchen müssen.",
},
{
number: "03",
title: "Technik",
text: "Schnell, mobil sauber, DSGVO-sauber und so gebaut, dass spätere Änderungen nicht zum Projekt werden.",
},
];
const ServicesSection = () => {
return (
<section
id="leistungen"
className="grid border-b border-border lg:grid-cols-[0.36fr_0.64fr]"
>
<div className="border-b border-border px-5 py-12 sm:px-8 lg:border-b-0 lg:border-r lg:px-12 lg:py-20">
<p className="text-sm uppercase tracking-[0.3em] text-primary">
Leistungen (02)
</p>
<h2 className="mt-6 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl">
Drei Schritte. Eine Website.
</h2>
</div>
<div className="divide-y divide-border">
{services.map((service) => (
<article
key={service.number}
className="grid gap-8 px-5 py-10 sm:px-8 md:grid-cols-[7rem_0.4fr_1fr] lg:px-12"
>
<span className="text-5xl font-black text-primary">
{service.number}
</span>
<h3 className="text-3xl font-black uppercase leading-none">
{service.title}
</h3>
<p className="max-w-2xl text-lg leading-8 text-muted-foreground">
{service.text}
</p>
</article>
))}
</div>
</section>
);
};
export { ServicesSection };

View File

@@ -1,219 +0,0 @@
"use client";
import { Check } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
interface PricingPlan {
name: string;
price: string;
period: string;
description: string;
features: string[];
isPopular?: boolean;
}
interface Pricing4Props {
title?: string;
description?: string;
className?: string;
}
const developmentPlans: PricingPlan[] = [
{
name: "Basis",
price: "799 €",
period: "Einmalpreis",
description: "Für einen klaren Webauftritt mit den wichtigsten Inhalten.",
features: [
"Eine Seite mit fünf Sektionen",
"Kontaktformular",
"Impressum und Datenschutz",
"Mobilfreundlich und für Google vorbereitet",
"Cookiefreie Analytics ohne Banner",
],
},
{
name: "Profi",
price: "1.499 €",
period: "Einmalpreis",
description: "Für Betriebe, die mehrere Leistungen sauber erklären wollen.",
features: [
"Bis zu fünf Unterseiten",
"Google Maps Integration",
"SEO-Basis für lokale Auffindbarkeit",
"Optionaler Blog",
"Alles aus Basis inklusive",
],
isPopular: true,
},
{
name: "Maßarbeit",
price: "2.499 €",
period: "Einmalpreis",
description: "Für individuelle Anforderungen, CMS und spätere Erweiterungen.",
features: [
"Individuelles Design nach Ihren Anforderungen",
"CMS zur eigenen Inhaltspflege",
"Erweiterbare Struktur",
"Alles aus Profi inklusive",
],
},
];
const servicePlans: PricingPlan[] = [
{
name: "Hosting",
price: "19 €",
period: "pro Monat",
description: "Solide technische Basis für kleine Unternehmensseiten.",
features: [
"Hosting auf deutschen Servern in Sachsen",
"SSL, Domain und tägliche Backups",
"Monatlicher Einblick in Besucherzahlen",
],
},
{
name: "Wartung",
price: "39 €",
period: "pro Monat",
description: "Für Unternehmen, die Betrieb und Sicherheit abgeben möchten.",
features: [
"Alles aus Hosting inklusive",
"Regelmäßige Updates und Sicherheitschecks",
"1 Stunde Support pro Monat",
"Monitoring bei technischen Problemen",
],
isPopular: true,
},
{
name: "Full Service",
price: "69 €",
period: "pro Monat",
description: "Für laufende Änderungen ohne jedes Mal ein neues Projekt.",
features: [
"Alles aus Wartung inklusive",
"Kleinere Inhaltsänderungen bis 2 Stunden pro Monat",
"Häufigerer Einblick in Besucherzahlen",
],
},
];
const Pricing4 = ({
title = "Pakete mit klarer Kante.",
description =
"Die Preise sind bewusst nachvollziehbar gehalten. Im Gespräch klären wir, welches Paket passt und wo ein schlankerer Weg sinnvoller ist.",
className,
}: Pricing4Props) => {
const [activeTab, setActiveTab] = useState<"development" | "service">(
"development",
);
const plans = activeTab === "development" ? developmentPlans : servicePlans;
return (
<section
id="preise"
className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}
>
<div className="mx-auto max-w-6xl">
<div className="grid gap-8 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.4fr)] lg:items-end lg:gap-16">
<div className="max-w-xl">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
Preise
</p>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
{title}
</h2>
<p className="mt-5 text-base leading-7 text-muted-foreground">
{description}
</p>
</div>
<Tabs
value={activeTab}
onValueChange={(value: string) =>
setActiveTab(value as "development" | "service")
}
className="w-fit lg:justify-self-end"
aria-label="Paketart auswählen"
>
<TabsList className="grid h-11 w-full grid-cols-2 rounded-md border border-border bg-card p-1 sm:w-max">
<TabsTrigger
value="development"
className="h-full min-h-0 px-5 py-0 text-sm font-semibold data-active:bg-primary data-active:text-primary-foreground"
>
Entwicklung
</TabsTrigger>
<TabsTrigger
value="service"
className="h-full min-h-0 px-5 py-0 text-sm font-semibold data-active:bg-primary data-active:text-primary-foreground"
>
Hosting & Wartung
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="mt-10 grid gap-4 lg:grid-cols-3">
{plans.map((plan) => (
<article
key={plan.name}
className={cn(
"flex min-h-[32rem] flex-col rounded-lg border bg-card p-6",
plan.isPopular
? "border-primary shadow-[0_18px_60px_oklch(0.285_0.045_148/0.12)]"
: "border-border",
)}
>
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-xl font-semibold tracking-tight">
{plan.name}
</h3>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{plan.description}
</p>
</div>
{plan.isPopular && (
<Badge variant="outline" className="rounded-md">
Empfohlen
</Badge>
)}
</div>
<div className="mt-8">
<p className="text-4xl font-semibold tracking-tight">
{plan.price}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{plan.period}
</p>
</div>
<ul className="mt-8 flex-1 space-y-4 text-sm leading-6 text-muted-foreground">
{plan.features.map((feature) => (
<li key={feature} className="flex gap-3">
<Check
className="mt-0.5 size-4 shrink-0 text-primary"
aria-hidden="true"
/>
<span>{feature}</span>
</li>
))}
</ul>
<Button asChild className="mt-8 w-full rounded-md">
<a href="#kontakt">Kostenloses Angebot anfordern</a>
</Button>
</article>
))}
</div>
</div>
</section>
);
};
export { Pricing4 };

View File

@@ -1,48 +0,0 @@
import { cn } from "@/lib/utils";
interface Stats11Props {
className?: string;
}
const Stats11 = ({ className }: Stats11Props) => {
const stats = [
["SEO-ready", "Leistungsseiten, Region und Kontakt sauber strukturiert."],
["< 1 Sek.", "Auf schnelle Ladezeiten und klare Technik ausgelegt."],
["ab 799 €", "Transparente Einstiegspreise ohne Paketnebel."],
["2 Wochen", "Typischer Zeitraum vom Startgespräch bis zur Vorschau."],
];
return (
<section className={cn("px-4 py-20 sm:px-6 lg:px-8 lg:py-28", className)}>
<div className="mx-auto max-w-6xl">
<div className="rounded-lg border border-border bg-primary p-6 text-primary-foreground sm:p-8 lg:grid lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.45fr)] lg:gap-12 lg:p-10">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary-foreground/65">
Messbare Grundlagen
</p>
<h2 className="mt-4 max-w-xl text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
Gute Websites fühlen sich ruhig an, weil die Basis stimmt.
</h2>
</div>
<div className="mt-10 grid gap-4 sm:grid-cols-2 lg:mt-0">
{stats.map(([value, label]) => (
<div
key={value}
className="rounded-md border border-primary-foreground/15 bg-primary-foreground/[0.06] p-5"
>
<p className="text-3xl font-semibold tracking-tight">
{value}
</p>
<p className="mt-3 text-sm leading-6 text-primary-foreground/72">
{label}
</p>
</div>
))}
</div>
</div>
</div>
</section>
);
};
export { Stats11 };

View File

@@ -1,79 +0,0 @@
import * as React from "react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -1,49 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -1,67 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -1,236 +0,0 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

View File

@@ -1,190 +0,0 @@
"use client";
import { memo, useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { animate } from "motion/react";
interface GlowingEffectProps {
blur?: number;
inactiveZone?: number;
proximity?: number;
spread?: number;
variant?: "default" | "white";
glow?: boolean;
className?: string;
disabled?: boolean;
movementDuration?: number;
borderWidth?: number;
}
const GlowingEffect = memo(
({
blur = 0,
inactiveZone = 0.7,
proximity = 0,
spread = 20,
variant = "default",
glow = false,
className,
movementDuration = 2,
borderWidth = 1,
disabled = true,
}: GlowingEffectProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const lastPosition = useRef({ x: 0, y: 0 });
const animationFrameRef = useRef<number>(0);
const handleMove = useCallback(
(e?: MouseEvent | { x: number; y: number }) => {
if (!containerRef.current) return;
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
animationFrameRef.current = requestAnimationFrame(() => {
const element = containerRef.current;
if (!element) return;
const { left, top, width, height } = element.getBoundingClientRect();
const mouseX = e?.x ?? lastPosition.current.x;
const mouseY = e?.y ?? lastPosition.current.y;
if (e) {
lastPosition.current = { x: mouseX, y: mouseY };
}
const center = [left + width * 0.5, top + height * 0.5];
const distanceFromCenter = Math.hypot(
mouseX - center[0],
mouseY - center[1]
);
const inactiveRadius = 0.5 * Math.min(width, height) * inactiveZone;
if (distanceFromCenter < inactiveRadius) {
element.style.setProperty("--active", "0");
return;
}
const isActive =
mouseX > left - proximity &&
mouseX < left + width + proximity &&
mouseY > top - proximity &&
mouseY < top + height + proximity;
element.style.setProperty("--active", isActive ? "1" : "0");
if (!isActive) return;
const currentAngle =
parseFloat(element.style.getPropertyValue("--start")) || 0;
let targetAngle =
(180 * Math.atan2(mouseY - center[1], mouseX - center[0])) /
Math.PI +
90;
const angleDiff = ((targetAngle - currentAngle + 180) % 360) - 180;
const newAngle = currentAngle + angleDiff;
animate(currentAngle, newAngle, {
duration: movementDuration,
ease: [0.16, 1, 0.3, 1],
onUpdate: (value) => {
element.style.setProperty("--start", String(value));
},
});
});
},
[inactiveZone, proximity, movementDuration]
);
useEffect(() => {
if (disabled) return;
const handleScroll = () => handleMove();
const handlePointerMove = (e: PointerEvent) => handleMove(e);
window.addEventListener("scroll", handleScroll, { passive: true });
document.body.addEventListener("pointermove", handlePointerMove, {
passive: true,
});
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
window.removeEventListener("scroll", handleScroll);
document.body.removeEventListener("pointermove", handlePointerMove);
};
}, [handleMove, disabled]);
return (
<>
<div
className={cn(
"pointer-events-none absolute -inset-px hidden rounded-[inherit] border opacity-0 transition-opacity",
glow && "opacity-100",
variant === "white" && "border-white",
disabled && "!block"
)}
/>
<div
ref={containerRef}
style={
{
"--blur": `${blur}px`,
"--spread": spread,
"--start": "0",
"--active": "0",
"--glowingeffect-border-width": `${borderWidth}px`,
"--repeating-conic-gradient-times": "5",
"--gradient":
variant === "white"
? `repeating-conic-gradient(
from 236.84deg at 50% 50%,
var(--black),
var(--black) calc(25% / var(--repeating-conic-gradient-times))
)`
: `radial-gradient(circle, #dd7bbb 10%, #dd7bbb00 20%),
radial-gradient(circle at 40% 40%, #d79f1e 5%, #d79f1e00 15%),
radial-gradient(circle at 60% 60%, #5a922c 10%, #5a922c00 20%),
radial-gradient(circle at 40% 60%, #4c7894 10%, #4c789400 20%),
repeating-conic-gradient(
from 236.84deg at 50% 50%,
#dd7bbb 0%,
#d79f1e calc(25% / var(--repeating-conic-gradient-times)),
#5a922c calc(50% / var(--repeating-conic-gradient-times)),
#4c7894 calc(75% / var(--repeating-conic-gradient-times)),
#dd7bbb calc(100% / var(--repeating-conic-gradient-times))
)`,
} as React.CSSProperties
}
className={cn(
"pointer-events-none absolute inset-0 rounded-[inherit] opacity-100 transition-opacity",
glow && "opacity-100",
blur > 0 && "blur-[var(--blur)] ",
className,
disabled && "!hidden"
)}
>
<div
className={cn(
"glow",
"rounded-[inherit]",
'after:content-[""] after:rounded-[inherit] after:absolute after:inset-[calc(-1*var(--glowingeffect-border-width))]',
"after:[border:var(--glowingeffect-border-width)_solid_transparent]",
"after:[background:var(--gradient)] after:[background-attachment:fixed]",
"after:opacity-[var(--active)] after:transition-opacity after:duration-300",
"after:[mask-clip:padding-box,border-box]",
"after:[mask-composite:intersect]",
"after:[mask-image:linear-gradient(#0000,#0000),conic-gradient(from_calc((var(--start)-var(--spread))*1deg),#00000000_0deg,#fff,#00000000_calc(var(--spread)*2deg))]"
)}
/>
</div>
</>
);
}
);
GlowingEffect.displayName = "GlowingEffect";
export { GlowingEffect };

View File

@@ -1,19 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -1,22 +0,0 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -1,26 +0,0 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -1,90 +0,0 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

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

View File

@@ -16,7 +16,9 @@ import "@/styles/global.css";
defer></script> defer></script>
</head> </head>
<body> <body>
<main class="min-h-screen bg-background px-5 py-8 text-foreground sm:px-8 lg:px-12"> <main
class="min-h-screen bg-background px-5 py-8 text-foreground sm:px-8 lg:px-12"
>
<a <a
href="/" href="/"
class="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground transition hover:text-foreground" class="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground transition hover:text-foreground"
@@ -24,19 +26,25 @@ import "@/styles/global.css";
Matthias Meister Matthias Meister
</a> </a>
<section class="mx-auto grid max-w-5xl gap-12 py-16 lg:grid-cols-[0.42fr_0.58fr] lg:py-24"> <section
<div> 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"> <p class="text-sm uppercase tracking-[0.3em] text-primary">
Datenschutz Datenschutz
</p> </p>
<h1 class="mt-6 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl"> <h1
class="mt-6 max-w-[9ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl"
>
Ihre Daten Ihre Daten
</h1> </h1>
</div> </div>
<div class="space-y-10 text-lg leading-8 text-foreground/85"> <div class="space-y-10 text-lg leading-8 text-foreground/85">
<section> <section>
<h2 class="text-sm font-black uppercase tracking-[0.22em] text-primary"> <h2
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
>
Verantwortlicher Verantwortlicher
</h2> </h2>
<div class="mt-4 space-y-1 text-muted-foreground"> <div class="mt-4 space-y-1 text-muted-foreground">
@@ -48,27 +56,31 @@ import "@/styles/global.css";
E-Mail: E-Mail:
<a <a
class="underline underline-offset-4 transition hover:text-foreground" class="underline underline-offset-4 transition hover:text-foreground"
href="mailto:hallo@matthias-meister.com" href="mailto:support@matthias-meister-webdesign.de"
> >
hallo@matthias-meister.com support@matthias-meister-webdesign.de
</a> </a>
</p> </p>
</div> </div>
</section> </section>
<section> <section>
<h2 class="text-sm font-black uppercase tracking-[0.22em] text-primary"> <h2
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
>
Keine Cookies Keine Cookies
</h2> </h2>
<p class="mt-4 text-muted-foreground"> <p class="mt-4 text-muted-foreground">
Diese Website setzt keine Cookies. Es gibt keine Nutzerkonten, Diese Website setzt keine Cookies. Es gibt keine Nutzerkonten,
keinen Newsletter, keine Zahlungsabwicklung und keine eingebetteten keinen Newsletter, keine Zahlungsabwicklung und keine
Drittanbieter-Medien. eingebetteten Drittanbieter-Medien.
</p> </p>
</section> </section>
<section> <section>
<h2 class="text-sm font-black uppercase tracking-[0.22em] text-primary"> <h2
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
>
Kontakt per E-Mail Kontakt per E-Mail
</h2> </h2>
<p class="mt-4 text-muted-foreground"> <p class="mt-4 text-muted-foreground">
@@ -80,27 +92,31 @@ import "@/styles/global.css";
</section> </section>
<section> <section>
<h2 class="text-sm font-black uppercase tracking-[0.22em] text-primary"> <h2
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
>
Reichweitenmessung mit Rybbit Reichweitenmessung mit Rybbit
</h2> </h2>
<p class="mt-4 text-muted-foreground"> <p class="mt-4 text-muted-foreground">
Diese Website nutzt Rybbit Analytics über Diese Website nutzt Rybbit Analytics, um anonymisierte und
https://rybbit.matthias.lol/api/script.js, um anonymisierte und
aggregierte Statistiken zur Nutzung der Website zu erhalten. aggregierte Statistiken zur Nutzung der Website zu erhalten.
Rybbit arbeitet cookielos und verwendet nach Anbieterangaben keine Rybbit verwendet keine Cookies oder local storage für das
Cookies oder local storage für das Tracking. Tracking.
</p> </p>
<p class="mt-4 text-muted-foreground"> <p class="mt-4 text-muted-foreground">
Dabei können technische Besuchsdaten wie aufgerufene Seiten, Dabei können technische Besuchsdaten wie aufgerufene Seiten,
Referrer, Browser- und Geräteinformationen sowie aus der Referrer, Browser- und Geräteinformationen sowie aus der
IP-Adresse abgeleitete ungefähre Standortdaten verarbeitet werden. IP-Adresse abgeleitete ungefähre Standortdaten verarbeitet werden.
Die IP-Adresse wird dabei nur vorübergehend zur Verarbeitung Die IP-Adresse wird dabei nur vorübergehend zur Verarbeitung
genutzt und nicht als Klartext in der Analysedatenbank gespeichert. genutzt und nicht als Klartext in der Analysedatenbank
gespeichert.
</p> </p>
</section> </section>
<section> <section>
<h2 class="text-sm font-black uppercase tracking-[0.22em] text-primary"> <h2
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
>
Ihre Rechte Ihre Rechte
</h2> </h2>
<p class="mt-4 text-muted-foreground"> <p class="mt-4 text-muted-foreground">
@@ -113,15 +129,20 @@ import "@/styles/global.css";
</section> </section>
<p class="border-t border-border pt-8 text-sm text-muted-foreground"> <p class="border-t border-border pt-8 text-sm text-muted-foreground">
Hinweis: Dieser Text beschreibt die technische Umsetzung dieser Hinweis: Dieser Text wird fortlaufend aktualisiert.
Website und ersetzt keine anwaltliche Prüfung.
</p> </p>
<nav class="flex flex-wrap gap-5 text-sm text-muted-foreground"> <nav class="flex flex-wrap gap-5 text-sm text-muted-foreground">
<a class="underline underline-offset-4 transition hover:text-foreground" href="/"> <a
class="underline underline-offset-4 transition hover:text-foreground"
href="/"
>
Startseite Startseite
</a> </a>
<a class="underline underline-offset-4 transition hover:text-foreground" href="/impressum"> <a
class="underline underline-offset-4 transition hover:text-foreground"
href="/impressum"
>
Impressum Impressum
</a> </a>
</nav> </nav>

View File

@@ -16,7 +16,9 @@ import "@/styles/global.css";
defer></script> defer></script>
</head> </head>
<body> <body>
<main class="min-h-screen bg-background px-5 py-8 text-foreground sm:px-8 lg:px-12"> <main
class="min-h-screen bg-background px-5 py-8 text-foreground sm:px-8 lg:px-12"
>
<a <a
href="/" href="/"
class="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground transition hover:text-foreground" class="text-sm font-semibold uppercase tracking-[0.24em] text-muted-foreground transition hover:text-foreground"
@@ -24,19 +26,25 @@ import "@/styles/global.css";
Matthias Meister Matthias Meister
</a> </a>
<section class="mx-auto grid max-w-5xl gap-12 py-16 lg:grid-cols-[0.42fr_0.58fr] lg:py-24"> <section
class="mx-auto grid max-w-5xl gap-12 py-16 lg:grid-cols-[0.42fr_0.58fr] lg:py-24"
>
<div> <div>
<p class="text-sm uppercase tracking-[0.3em] text-primary"> <p class="text-sm uppercase tracking-[0.3em] text-primary">
Rechtliches Rechtliches
</p> </p>
<h1 class="mt-6 max-w-[8ch] text-5xl font-black uppercase leading-[0.86] sm:text-7xl"> <h1
class="mt-6 max-w-[8ch] text-5xl font-black uppercase leading-[0.86] sm:text-6xl"
>
Impressum Impressum
</h1> </h1>
</div> </div>
<div class="space-y-10 text-lg leading-8 text-foreground/85"> <div class="space-y-10 text-lg leading-8 text-foreground/85">
<section> <section>
<h2 class="text-sm font-black uppercase tracking-[0.22em] text-primary"> <h2
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
>
Angaben gemäß § 5 TMG Angaben gemäß § 5 TMG
</h2> </h2>
<div class="mt-4 space-y-1 text-muted-foreground"> <div class="mt-4 space-y-1 text-muted-foreground">
@@ -48,7 +56,9 @@ import "@/styles/global.css";
</section> </section>
<section> <section>
<h2 class="text-sm font-black uppercase tracking-[0.22em] text-primary"> <h2
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
>
Umsatzsteuer Umsatzsteuer
</h2> </h2>
<p class="mt-4 text-muted-foreground"> <p class="mt-4 text-muted-foreground">
@@ -57,25 +67,35 @@ import "@/styles/global.css";
</section> </section>
<section> <section>
<h2 class="text-sm font-black uppercase tracking-[0.22em] text-primary"> <h2
class="text-sm font-black uppercase tracking-[0.22em] text-primary"
>
Kontakt Kontakt
</h2> </h2>
<p class="mt-4 text-muted-foreground"> <p class="mt-4 text-muted-foreground">
E-Mail: E-Mail:
<a <a
class="underline underline-offset-4 transition hover:text-foreground" class="underline underline-offset-4 transition hover:text-foreground"
href="mailto:hallo@matthias-meister.com" href="mailto:support@matthias-meister-webdesign.de"
> >
hallo@matthias-meister.com support@matthias-meister-webdesign.de
</a> </a>
</p> </p>
</section> </section>
<nav class="flex flex-wrap gap-5 border-t border-border pt-8 text-sm text-muted-foreground"> <nav
<a class="underline underline-offset-4 transition hover:text-foreground" href="/"> 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 Startseite
</a> </a>
<a class="underline underline-offset-4 transition hover:text-foreground" href="/datenschutz"> <a
class="underline underline-offset-4 transition hover:text-foreground"
href="/datenschutz"
>
Datenschutz Datenschutz
</a> </a>
</nav> </nav>

View File

@@ -1,6 +1,6 @@
--- ---
import { LandingHeroSection } from "@/components/landing-hero-section"; import { LandingHeroSection } from "@/components/landing-hero-section";
import { LandingRest } from "@/components/landing"; import { LandingPageSections } from "@/components/landing-page-sections";
import { Footer27 } from "@/components/footer27"; import { Footer27 } from "@/components/footer27";
import "@/styles/global.css"; import "@/styles/global.css";
--- ---
@@ -21,7 +21,7 @@ import "@/styles/global.css";
<body> <body>
<main class="min-h-screen overflow-hidden bg-background text-foreground"> <main class="min-h-screen overflow-hidden bg-background text-foreground">
<LandingHeroSection client:media="(min-width: 1024px)" /> <LandingHeroSection client:media="(min-width: 1024px)" />
<LandingRest /> <LandingPageSections />
<Footer27 /> <Footer27 />
</main> </main>
<script> <script>

View File

@@ -3,8 +3,11 @@ import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
const sourcePaths = [ const sourcePaths = [
new URL("../src/components/landing.tsx", import.meta.url),
new URL("../src/components/landing-hero-section.tsx", import.meta.url), new URL("../src/components/landing-hero-section.tsx", import.meta.url),
new URL("../src/components/landing/services-section.tsx", import.meta.url),
new URL("../src/components/landing/deliverables-section.tsx", import.meta.url),
new URL("../src/components/landing/packages-section.tsx", import.meta.url),
new URL("../src/components/landing/contact-section.tsx", import.meta.url),
]; ];
const footerPath = new URL("../src/components/footer27.tsx", import.meta.url); const footerPath = new URL("../src/components/footer27.tsx", import.meta.url);