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.
This commit is contained in:
@@ -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 -->
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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,42 +122,44 @@ const LandingHeroSection = () => {
|
|||||||
<span>©2026</span>
|
<span>©2026</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden shrink-0 flex-col items-end gap-2 pt-4 lg:flex">
|
{hasWebcam ? (
|
||||||
<p
|
<div className="hidden shrink-0 flex-col items-end gap-2 pt-4 lg:flex">
|
||||||
className={cn(
|
<p
|
||||||
"max-w-[18ch] text-right text-[10px] leading-snug font-medium uppercase tracking-[0.22em] text-primary-foreground/85",
|
|
||||||
!liveRasterOn && "motion-safe:animate-pulse",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{liveRasterOn
|
|
||||||
? "Kamera aus? Schalter zurück, fertig."
|
|
||||||
: "Psst ... einmal wippen, dann lebt die Seite."}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={liveRasterOn}
|
|
||||||
aria-label={
|
|
||||||
liveRasterOn
|
|
||||||
? "Live-Raster und Kamera beenden"
|
|
||||||
: "Live-Raster mit Kamera starten"
|
|
||||||
}
|
|
||||||
onClick={() => setLiveRasterOn((v) => !v)}
|
|
||||||
className={cn(
|
|
||||||
"relative h-9 w-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(
|
className={cn(
|
||||||
"absolute top-1 left-1 size-7 rounded-full bg-primary-foreground shadow-sm transition-transform duration-300 ease-out",
|
"max-w-[18ch] text-right text-[10px] leading-snug font-medium uppercase tracking-[0.22em] text-primary-foreground/85",
|
||||||
liveRasterOn ? "translate-x-4" : "translate-x-0",
|
!liveRasterOn && "motion-safe:animate-pulse",
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
</button>
|
{liveRasterOn
|
||||||
</div>
|
? "Kamera aus? Schalter zurück, fertig."
|
||||||
|
: "Psst ... einmal wippen, dann lebt die Seite."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={liveRasterOn}
|
||||||
|
aria-label={
|
||||||
|
liveRasterOn
|
||||||
|
? "Live-Raster beenden"
|
||||||
|
: "Live-Raster mit Kamera starten"
|
||||||
|
}
|
||||||
|
onClick={handleToggleLiveRaster}
|
||||||
|
className={cn(
|
||||||
|
"relative h-9 w-13 shrink-0 rounded-full border transition-colors duration-300 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-primary-foreground",
|
||||||
|
liveRasterOn
|
||||||
|
? "border-primary-foreground/50 bg-primary-foreground/20"
|
||||||
|
: "border-primary-foreground/45 bg-primary-foreground/10 hover:border-primary-foreground/70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute top-1 left-1 size-7 rounded-full bg-primary-foreground shadow-sm transition-transform duration-300 ease-out",
|
||||||
|
liveRasterOn ? "translate-x-4" : "translate-x-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="relative 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" />
|
||||||
|
|||||||
17
src/components/landing-page-sections.tsx
Normal file
17
src/components/landing-page-sections.tsx
Normal 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 };
|
||||||
@@ -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 };
|
|
||||||
46
src/components/landing/contact-section.tsx
Normal file
46
src/components/landing/contact-section.tsx
Normal 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: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 { 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 };
|
||||||
59
src/components/landing/packages-section.tsx
Normal file
59
src/components/landing/packages-section.tsx
Normal 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 };
|
||||||
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: "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 };
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user