Auth fertig erstellt. Aktuell läuft review durch KI

This commit is contained in:
2026-08-29 14:07:44 +02:00
commit 625d026f59
70 changed files with 17032 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
import { Link, useNavigate, useRouter } from '@tanstack/react-router'
import { LogOut, UserCog } from 'lucide-react'
import { useState, type ReactNode } from 'react'
import { Button } from '#/components/ui/button.tsx'
import type { Session } from '#/lib/auth.ts'
import { authClient } from '#/lib/auth-client.ts'
type AppShellProps = {
session: Session
nav?: ReactNode
children: ReactNode
}
export function AppShell({ session, nav, children }: AppShellProps) {
const router = useRouter()
const navigate = useNavigate()
const [stopping, setStopping] = useState(false)
const impersonating = Boolean(session.session.impersonatedBy)
async function signOut() {
await authClient.signOut()
await router.invalidate()
await navigate({ to: '/login' })
}
async function stopImpersonation() {
setStopping(true)
await authClient.admin.stopImpersonating()
await router.invalidate()
setStopping(false)
await navigate({ to: '/admin' })
}
return (
<div className="flex min-h-screen flex-col bg-background">
{impersonating ? (
<div className="border-b border-amber-500/40 bg-amber-500/10 text-amber-900 dark:text-amber-200">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-2 md:px-6">
<p className="flex items-center gap-2 text-sm">
<UserCog className="size-4" />
Du bist als <strong>{session.user.name}</strong> ({session.user.email}) eingeloggt.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={stopImpersonation}
disabled={stopping}
>
{stopping ? 'Beende ...' : 'Impersonation beenden'}
</Button>
</div>
</div>
) : null}
<header className="border-b border-border bg-card">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3 md:px-6">
<Link
to={session.user.role === 'admin' ? '/admin' : '/dashboard'}
className="flex items-center gap-2 no-underline"
>
<img
alt="Logo Webkulisse"
src="/img/logo.png"
className="h-8 w-8 rounded-md"
/>
<span className="font-heading text-base font-bold">webkulisse</span>
</Link>
{nav ? <nav className="flex items-center gap-1">{nav}</nav> : null}
<div className="flex items-center gap-3">
<div className="hidden text-right sm:block">
<div className="text-sm font-medium leading-tight">
{session.user.name}
</div>
<div className="text-xs text-muted-foreground leading-tight">
{session.user.email}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={signOut}
>
<LogOut />
Abmelden
</Button>
</div>
</div>
</header>
<main className="flex-1">
<div className="mx-auto max-w-6xl px-4 py-8 md:px-6">{children}</div>
</main>
</div>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { lazy, Suspense, useState, type ReactNode } from "react";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog.tsx";
// Cal.com-Bundle erst laden, wenn das Modal wirklich geöffnet wird -
// spart auf der Landingpage ~150 KB JS, die 99 % der Besucher nie brauchen.
const BookingEmbed = lazy(() => import("@/components/booking-embed.tsx"));
type BookingDialogProps = {
// Der Trigger (in der Regel ein <Button>). Kommt via asChild an Radix
// durch, muss also ein einzelnes, fokussierbares Element sein.
children: ReactNode;
};
export function BookingDialog({ children }: BookingDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="h-[85vh] w-[95vw] max-w-4xl gap-0 overflow-hidden p-0 sm:h-[80vh]">
<DialogTitle className="sr-only">Erstgespräch buchen</DialogTitle>
<div className="h-full w-full overflow-hidden rounded-2xl">
{open && (
<Suspense
fallback={
<div className="flex h-full w-full items-center justify-center text-sm text-(--sea-ink-soft)">
Kalender wird geladen ...
</div>
}
>
<BookingEmbed />
</Suspense>
)}
</div>
</DialogContent>
</Dialog>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect } from "react";
import Cal, { getCalApi } from "@calcom/embed-react";
import { CAL_CONFIG } from "@/lib/config.ts";
// Rein den cal.com-Embed - keine Umgebung, kein Wrapper.
// Wird sowohl vom Modal (booking-dialog.tsx) als auch von der Route
// /termin genutzt, damit Konfiguration und Verhalten identisch bleiben.
export default function BookingEmbed() {
useEffect(() => {
(async () => {
const cal = await getCalApi({
namespace: CAL_CONFIG.namespace,
embedJsUrl: CAL_CONFIG.embedJsUrl,
});
cal("ui", {
hideEventTypeDetails: false,
layout: "month_view",
});
})();
}, []);
return (
<Cal
namespace={CAL_CONFIG.namespace}
calLink={CAL_CONFIG.calLink}
style={{ width: "100%", height: "100%", overflow: "scroll" }}
config={{
layout: "month_view",
useSlotsViewOnSmallScreen: "true",
}}
calOrigin={CAL_CONFIG.origin}
embedJsUrl={CAL_CONFIG.embedJsUrl}
/>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { Gauge, Lock, Palette } from "lucide-react";
const features = [
{
icon: Gauge,
title: "Ladezeit unter einer Sekunde",
text: "Unsere Seiten sind auf dem Smartphone in unter einer Sekunde da. Der Besucher bleibt bei Ihnen, statt zum nächsten Google-Treffer weiterzuziehen.",
},
{
icon: Lock,
title: "Ohne WordPress, ohne Update-Pflicht",
text: "Kein Content-Management-System, keine Plugins, keine Datenbank. Nichts, was gehackt werden könnte oder das Sie regelmäßig aktualisieren müssen.",
},
{
icon: Palette,
title: "Design nach Maß",
text: "Kein Baukasten-Look. Ihre Seite bekommt einen Auftritt, den Besucher sofort mit Ihrem Betrieb verbinden - nicht mit tausend anderen.",
},
];
export const Features = () => {
return (
<section id="warum-wir" className="relative py-16 md:py-24">
<div className="mx-auto max-w-6xl px-4 md:px-6">
<figure className="relative overflow-hidden rounded-3xl border border-[var(--line)]">
<img
alt="Aufgeräumter Arbeitsplatz mit Notizen zur Projektplanung"
className="h-64 w-full object-cover sm:h-80 md:h-96"
src="/img/laptop-planning.webp"
width="5184"
height="3456"
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/40 to-black/10" />
<figcaption className="absolute inset-0 flex flex-col items-center justify-center px-6 text-center">
<span className="island-kicker !text-white/80">Unser Anspruch</span>
<h2 className="mt-3 max-w-3xl font-heading text-3xl font-bold leading-tight text-white sm:text-4xl md:text-5xl">
Echtes Handwerk statt Baukasten-Kompromisse
</h2>
</figcaption>
</figure>
<p className="mx-auto mt-10 max-w-2xl text-center text-[var(--sea-ink-soft)] sm:text-lg">
Viele Agenturen setzen auf schwerfällige Standard-Systeme. Wir schreiben den
Code für Ihre Webseite selbst. Der Unterschied für Sie:
</p>
<ul className="mt-10 grid gap-5 md:grid-cols-3">
{features.map(({ icon: Icon, title, text }) => (
<li
key={title}
className="feature-card rounded-2xl border border-[var(--line)] p-6"
>
<span className="mb-4 inline-flex h-11 w-11 items-center justify-center rounded-xl bg-[var(--lagoon)]/15 text-[var(--lagoon-deep)]">
<Icon className="h-5 w-5" aria-hidden />
</span>
<h3 className="font-heading text-lg font-semibold text-[var(--sea-ink)]">
{title}
</h3>
<p className="mt-2 text-sm text-[var(--sea-ink-soft)]">{text}</p>
</li>
))}
</ul>
</div>
</section>
);
};
+125
View File
@@ -0,0 +1,125 @@
import { ArrowRight, Mail, MapPin, Phone } from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import { BookingDialog } from "@/components/booking-dialog.tsx";
export const Footer = () => {
const year = new Date().getFullYear();
return (
<footer id="kontakt" className="site-footer mt-8">
<div className="mx-auto max-w-6xl px-4 py-16 md:px-6 md:py-24">
<section className="island-shell rounded-3xl p-8 sm:p-12">
<div className="grid gap-8 lg:grid-cols-[1.6fr_1fr] lg:items-center">
<div className="space-y-4">
<span className="island-kicker">Kontakt aufnehmen</span>
<h2 className="font-heading text-3xl font-bold leading-tight text-(--sea-ink) sm:text-4xl">
Sprechen wir über Ihr Projekt.
</h2>
<p className="max-w-xl text-(--sea-ink-soft) sm:text-lg">
Buchen Sie sich einen Termin direkt online - 30 Minuten,
kostenlos, keine Warteschleifen. Wenn Sie lieber telefonieren,
melde ich mich innerhalb eines Werktags zurück.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row lg:flex-col">
<BookingDialog>
<Button data-type="accent" size="lg" className="gap-2">
Erstgespräch buchen
<ArrowRight className="h-4 w-4" />
</Button>
</BookingDialog>
<Button variant="outline" size="lg" asChild>
<a href="tel:+4917622222222" className="no-underline">
+49 176 22222222
</a>
</Button>
</div>
</div>
</section>
<div
id="about"
className="mt-14 grid gap-10 border-t border-(--line) pt-10 md:grid-cols-3"
>
<div className="space-y-3">
<div className="flex items-center gap-2">
<img
alt="Placeholder Logo"
height="32"
width="32"
src="https://placehold.co/64"
className="h-8 w-8 rounded-md"
/>
<span className="font-heading text-lg font-bold text-(--sea-ink)">
webkulisse
</span>
</div>
<p className="text-sm text-(--sea-ink-soft)">
Handgemachte Webseiten für Handwerk, Praxis und Therapie schnell,
sicher und persönlich betreut.
</p>
</div>
<div>
<h3 className="font-heading text-sm font-semibold uppercase tracking-widest text-(--sea-ink-soft)">
Anbieter
</h3>
<address className="mt-3 text-sm not-italic text-(--sea-ink)">
Mathias Hurler Webdesign
<br />
Untermagerbein 30
<br />
86751 Mönchsdeggingen
</address>
</div>
<div>
<h3 className="font-heading text-sm font-semibold uppercase tracking-widest text-(--sea-ink-soft)">
So erreichen Sie uns
</h3>
<ul className="mt-3 space-y-2 text-sm">
<li>
<a
href="mailto:info@webkulisse.de"
className="inline-flex items-center gap-2 no-underline hover:underline"
>
<Mail className="h-4 w-4" aria-hidden />
info@webkulisse.de
</a>
</li>
<li>
<a
href="tel:+4917622222222"
className="inline-flex items-center gap-2 no-underline hover:underline"
>
<Phone className="h-4 w-4" aria-hidden />
+49 176 22222222
</a>
</li>
<li className="inline-flex items-center gap-2 text-(--sea-ink-soft)">
<MapPin className="h-4 w-4" aria-hidden />
Bayerisch-Schwaben
</li>
</ul>
</div>
</div>
<div className="mt-10 flex flex-col items-start justify-between gap-3 border-t border-(--line) pt-6 text-xs text-(--sea-ink-soft) sm:flex-row sm:items-center">
<p>© {year} webkulisse.de · Alle Rechte vorbehalten.</p>
<ul className="flex gap-4">
<li>
<a href="#" className="no-underline hover:underline">
Impressum
</a>
</li>
<li>
<a href="#" className="no-underline hover:underline">
Datenschutz
</a>
</li>
</ul>
</div>
</div>
</footer>
);
};
+138
View File
@@ -0,0 +1,138 @@
import { useEffect, useState } from "react";
import { Link } from "@tanstack/react-router";
import { Menu, Moon, Sun, X } from "lucide-react";
import { getTheme, setTheme, type Theme } from "@/lib/theme.ts";
import { Button } from "@/components/ui/button.tsx";
import { authClient } from "@/lib/auth-client.ts";
const navLinks = [
{ href: "#zielgruppen", label: "Für wen" },
{ href: "#warum-wir", label: "Warum wir" },
{ href: "#ablauf", label: "Ablauf" },
{ href: "#pricing", label: "Angebote" },
{ href: "#kontakt", label: "Kontakt" },
];
export default function Header() {
const [theme, setThemeState] = useState<Theme>("light");
const [mobileOpen, setMobileOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const { data: session } = authClient.useSession();
const loginTarget = session?.user
? session.user.role === "admin"
? "/admin"
: "/dashboard"
: "/login";
const loginLabel = session?.user ? "Zum Dashboard" : "Kundenlogin";
useEffect(() => {
setThemeState(getTheme());
const onScroll = () => setScrolled(window.scrollY > 8);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
function toggleTheme() {
const next: Theme = theme === "dark" ? "light" : "dark";
setTheme(next);
setThemeState(next);
}
return (
<header
className={[
"sticky top-0 z-40 w-full transition-all",
scrolled
? "border-b border-[var(--line)] backdrop-blur-md bg-[var(--header-bg)]"
: "border-b border-transparent",
].join(" ")}
>
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3 md:px-6">
<a href="#" aria-label="Startseite" className="flex items-center gap-2 no-underline">
<img
alt="Logo Webkulisse"
height="520"
width="690"
src="/img/logo.png"
className="h-10 w-10 rounded-lg"
/>
<span className="hidden font-heading text-lg font-bold tracking-tight sm:inline">
webkulisse
</span>
</a>
<nav aria-label="Hauptnavigation" className="hidden md:block">
<ul className="flex items-center gap-1">
{navLinks.map((link) => (
<li key={link.href}>
<a
href={link.href}
className="nav-link rounded-md px-3 py-2 text-sm font-medium no-underline"
>
{link.label}
</a>
</li>
))}
</ul>
</nav>
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggleTheme}
aria-label={theme === "dark" ? "Zu hellem Design wechseln" : "Zu dunklem Design wechseln"}
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-[var(--line)] bg-[var(--chip-bg)] text-[var(--sea-ink)] transition hover:bg-[var(--link-bg-hover)]"
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<Button asChild variant="default" className="hidden sm:inline-flex">
<Link to={loginTarget}>{loginLabel}</Link>
</Button>
<button
type="button"
onClick={() => setMobileOpen((v) => !v)}
aria-expanded={mobileOpen}
aria-controls="mobile-nav"
aria-label="Menü öffnen"
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-[var(--line)] bg-[var(--chip-bg)] text-[var(--sea-ink)] md:hidden"
>
{mobileOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
</button>
</div>
</div>
{mobileOpen && (
<nav
id="mobile-nav"
aria-label="Mobile Navigation"
className="md:hidden border-t border-[var(--line)] bg-[var(--header-bg)] backdrop-blur-md"
>
<ul className="mx-auto flex max-w-6xl flex-col gap-1 px-4 py-3">
{navLinks.map((link) => (
<li key={link.href}>
<a
href={link.href}
onClick={() => setMobileOpen(false)}
className="nav-link block rounded-md px-3 py-2 text-base font-medium no-underline"
>
{link.label}
</a>
</li>
))}
<li className="pt-2">
<Button asChild variant="default" className="w-full">
<Link to={loginTarget} onClick={() => setMobileOpen(false)}>
{loginLabel}
</Link>
</Button>
</li>
</ul>
</nav>
)}
</header>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { ArrowRight, HeartHandshake, ShieldCheck, Zap } from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import { BookingDialog } from "@/components/booking-dialog.tsx";
const trustPoints = [
{ icon: Zap, label: "Unter 1 Sekunde Ladezeit" },
{ icon: ShieldCheck, label: "Ohne Update-Pflicht" },
{ icon: HeartHandshake, label: "Persönlich betreut" },
];
export default function Hero() {
return (
<section className="mx-auto max-w-6xl px-4 pt-12 pb-16 md:px-6 md:pt-20 md:pb-24">
<div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-16">
<div className="space-y-6 rise-in">
<span className="island-kicker inline-flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--lagoon)]" aria-hidden />
Für Handwerk, Praxis und Therapie in Bayern
</span>
<h1 className="font-heading text-4xl font-bold leading-[1.05] tracking-tight text-[var(--sea-ink)] sm:text-5xl lg:text-6xl">
Eine Webseite, die{" "}
<span className="text-[var(--lagoon-deep)]">neue Kunden bringt</span> - nicht nur gut aussieht.
</h1>
<p className="max-w-xl text-lg text-[var(--sea-ink-soft)] sm:text-xl">
Damit neue Kunden Sie zuerst finden - nicht die Konkurrenz in der Nachbarschaft.
</p>
<div className="flex flex-wrap items-center gap-3">
<BookingDialog>
<Button data-type="accent" size="lg" className="gap-2">
Erstgespräch buchen
<ArrowRight className="h-4 w-4" />
</Button>
</BookingDialog>
<Button variant="ghost" size="lg" asChild>
<a href="#pricing" className="no-underline">Pakete ansehen</a>
</Button>
</div>
<p className="text-sm text-(--sea-ink-soft)">
30 Minuten, kostenlos - direkt online buchen, keine Warteschleifen.
</p>
<ul className="flex flex-wrap items-center gap-x-6 gap-y-3 pt-2 text-sm text-[var(--sea-ink-soft)]">
{trustPoints.map(({ icon: Icon, label }) => (
<li key={label} className="inline-flex items-center gap-2">
<Icon className="h-4 w-4 text-[var(--lagoon-deep)]" aria-hidden />
{label}
</li>
))}
</ul>
</div>
<figure className="island-shell relative overflow-hidden rounded-3xl">
<img
className="block h-full w-full object-cover"
src="https://placehold.co/400x600"
alt="Aufgeräumter Schreibtisch aus Holz mit Laptop"
width="400"
height="600"
loading="eager"
/>
</figure>
</div>
</section>
);
}
+146
View File
@@ -0,0 +1,146 @@
import { Check, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import { BookingDialog } from "@/components/booking-dialog.tsx";
type PricingPlan = {
id: number;
name: string;
description: string;
price: number;
features: string[];
priceNote: string;
highlighted?: boolean;
};
const offerlist: PricingPlan[] = [
{
id: 1,
name: "Das Kompakt-Paket",
description:
"Perfekt für die schnelle, moderne Präsentation. Ideal für Therapeuten, Berater und regionale Handwerker.",
price: 900,
features: [
"Alle wichtigen Infos auf einer Seite",
"Kontaktformular & Anfahrt",
"Für Smartphone optimiert",
"SSL-Verschlüsselung inklusive",
"SEO-Grundlagen (Meta-Tags, schnelle Ladezeit)",
],
priceNote: "Aufpreise z. B. für mehrsprachige Seiten oder Terminbuchung.",
},
{
id: 2,
name: "Das Klassik-Paket",
description:
"Für Praxen und Betriebe mit breitem Angebot. Maximale Übersicht für Ihre Besucher.",
price: 1700,
features: [
"Separate Leistungs- und Teamseiten",
"Bildergalerie & Referenzen",
"Erweiterte SEO-Optimierung",
"Rechtstexte-Assistenz",
"3 Monate kostenloser Support",
],
priceNote: "Aufpreise z. B. für zusätzliche Sprachen oder Shop-Anbindung.",
highlighted: true,
},
{
id: 3,
name: "Ihre neue Webapp",
description:
"Für digitale Lösungen mit echtem Mehrwert. Ideal für Unternehmen, die mehr als eine Website brauchen.",
price: 2500,
features: [
"Login- und Nutzerbereich",
"Individuelle Funktionen",
"Terminbuchung oder Kundenportal",
"SEO-Grundlagen (Meta-Tags, schnelle Ladezeit)",
"Skalierbare Infrastruktur",
],
priceNote: "Endpreis abhängig vom Funktionsumfang - Details im Erstgespräch.",
},
];
const priceFormatter = new Intl.NumberFormat("de-DE");
export const Pricing = () => {
return (
<section id="pricing" className="mx-auto max-w-6xl px-4 py-16 md:px-6 md:py-24">
<div className="mx-auto max-w-2xl text-center">
<span className="island-kicker">Preise</span>
<h2 className="mt-3 font-heading text-3xl font-bold leading-tight tracking-tight text-(--sea-ink) sm:text-4xl">
Feste Preise. Keine Überraschungen.
</h2>
<p className="mt-4 text-(--sea-ink-soft) sm:text-lg">
Wählen Sie das Paket, das zu Ihrem Betrieb passt. Nach dem Erstgespräch
bekommen Sie einen festen Preis - schriftlich, ohne Überraschungen.
</p>
</div>
<div className="mt-12 grid gap-6 lg:grid-cols-3 lg:items-stretch">
{offerlist.map((offer) => {
const isHighlighted = offer.highlighted;
return (
<article
key={offer.id}
className={[
"relative flex flex-col gap-5 rounded-3xl border p-6 sm:p-7",
isHighlighted
? "border-(--lagoon-deep) bg-(--surface-strong) shadow-[0_20px_50px_rgba(30,90,72,0.18)] lg:-translate-y-2"
: "feature-card border-(--line)",
].join(" ")}
>
{isHighlighted && (
<span className="absolute -top-3 left-1/2 inline-flex -translate-x-1/2 items-center gap-1 rounded-full bg-(--lagoon-deep) px-3 py-1 text-xs font-semibold text-white shadow">
<Sparkles className="h-3.5 w-3.5" aria-hidden />
Empfehlung
</span>
)}
<header className="space-y-2">
<h3 className="font-heading text-xl font-bold text-(--sea-ink)">
{offer.name}
</h3>
<p className="text-sm text-(--sea-ink-soft)">{offer.description}</p>
</header>
<div className="flex items-baseline gap-1">
<span className="text-sm text-(--sea-ink-soft)">ab</span>
<span className="font-heading text-4xl font-bold text-(--sea-ink)">
{priceFormatter.format(offer.price)}
</span>
</div>
<div className="-mt-3 space-y-1 text-xs text-(--sea-ink-soft)">
<p>zzgl. gesetzl. Umsatzsteuer</p>
<p>{offer.priceNote}</p>
</div>
<ul className="space-y-2.5 text-sm text-(--sea-ink)">
{offer.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check
className="mt-0.5 h-4 w-4 shrink-0 text-(--lagoon-deep)"
aria-hidden
/>
<span>{feature}</span>
</li>
))}
</ul>
<div className="mt-auto pt-2">
<BookingDialog>
<Button
variant={isHighlighted ? "default" : "outline"}
className="w-full"
>
Erstgespräch buchen
</Button>
</BookingDialog>
</div>
</article>
);
})}
</div>
</section>
);
};
+64
View File
@@ -0,0 +1,64 @@
import { FileText, MessageSquare, Rocket, Wrench } from "lucide-react";
const steps = [
{
icon: MessageSquare,
title: "Erstgespräch",
text: "30 Minuten, kostenlos, per Telefon oder vor Ort. Wir klären Ihre Ziele, Ihre Zielgruppe und den Umfang.",
},
{
icon: FileText,
title: "Angebot & Konzept",
text: "Sie bekommen einen festen Preis und einen Zeitplan. Erst nach Ihrer Freigabe geht es los.",
},
{
icon: Wrench,
title: "Umsetzung",
text: "Ich baue Ihre Seite. Zwischenstände zeige ich Ihnen regelmäßig, Anpassungen sind normaler Teil des Prozesses.",
},
{
icon: Rocket,
title: "Übergabe & Betreuung",
text: "Sie bekommen alle Zugänge, ein kurzes Handbuch und drei Monate kostenlosen Support nach dem Launch.",
},
];
export const Process = () => {
return (
<section id="ablauf" className="mx-auto max-w-6xl px-4 py-16 md:px-6 md:py-24">
<div className="mx-auto max-w-2xl text-center">
<span className="island-kicker">So arbeite ich</span>
<h2 className="mt-3 font-heading text-3xl font-bold leading-tight tracking-tight text-(--sea-ink) sm:text-4xl">
In vier Schritten zur neuen Webseite
</h2>
<p className="mt-4 text-(--sea-ink-soft) sm:text-lg">
Keine Überraschungen im Prozess. Sie wissen jederzeit, wo Ihr Projekt steht und
was als Nächstes passiert.
</p>
</div>
<ol className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{steps.map(({ icon: Icon, title, text }, index) => (
<li
key={title}
className="feature-card relative rounded-2xl border border-(--line) p-6"
>
<span
aria-hidden
className="absolute right-5 top-4 font-heading text-4xl font-bold text-(--sea-ink-soft)/20"
>
{String(index + 1).padStart(2, "0")}
</span>
<span className="mb-4 inline-flex h-11 w-11 items-center justify-center rounded-xl bg-(--lagoon)/15 text-(--lagoon-deep)">
<Icon className="h-5 w-5" aria-hidden />
</span>
<h3 className="font-heading text-lg font-semibold text-(--sea-ink)">
{title}
</h3>
<p className="mt-2 text-sm text-(--sea-ink-soft)">{text}</p>
</li>
))}
</ol>
</section>
);
};
+56
View File
@@ -0,0 +1,56 @@
import { Brain, HardHat, Stethoscope } from "lucide-react";
const audiences = [
{
icon: HardHat,
title: "Handwerksbetriebe",
text: "Anfragen aus der Region, ohne dass Sie ständig Marketing machen müssen. Referenzen, Leistungen und Kontaktdaten stehen dort, wo Kunden sie suchen.",
},
{
icon: Stethoscope,
title: "Praxen & Ärzte",
text: "Neue Patienten und weniger Rückfragen am Telefon. Sprechzeiten, Leistungsspektrum und Anfahrt sofort auf einen Blick - auch am Smartphone.",
},
{
icon: Brain,
title: "Therapeuten & Berater",
text: "Ein Auftritt, der Vertrauen weckt - bevor der Erstkontakt zustande kommt. Persönlich, ruhig, ohne Marketing-Lärm.",
},
];
export const Services = () => {
return (
<section
id="zielgruppen"
className="mx-auto max-w-6xl px-4 py-16 md:px-6 md:py-24"
>
<div className="mx-auto max-w-2xl text-center">
<span className="island-kicker">Für wen ist das?</span>
<h2 className="mt-3 font-heading text-3xl font-bold leading-tight tracking-tight text-(--sea-ink) sm:text-4xl">
Finden Sie sich wieder?
</h2>
<p className="mt-4 text-(--sea-ink-soft) sm:text-lg">
Meine Kunden verbindet eines: Sie sind Meister ihres Fachs -
und wollen sich nicht mit ihrer Webseite herumärgern.
</p>
</div>
<ul className="mt-12 grid gap-5 md:grid-cols-3">
{audiences.map(({ icon: Icon, title, text }) => (
<li
key={title}
className="feature-card rounded-2xl border border-(--line) p-6"
>
<span className="mb-4 inline-flex h-11 w-11 items-center justify-center rounded-xl bg-(--lagoon)/15 text-(--lagoon-deep)">
<Icon className="h-5 w-5" aria-hidden />
</span>
<h3 className="font-heading text-lg font-semibold text-(--sea-ink)">
{title}
</h3>
<p className="mt-2 text-sm text-(--sea-ink-soft)">{text}</p>
</li>
))}
</ul>
</section>
);
};
+55
View File
@@ -0,0 +1,55 @@
import { Sparkles } from "lucide-react";
const placeholders = [
{ name: "Beispiel: Handwerksbetrieb", type: "Firmen-Webseite" },
{ name: "Beispiel: Arztpraxis", type: "Praxis-Webseite" },
{ name: "Beispiel: Therapie", type: "Landingpage" },
];
export const Showcase = () => {
return (
<section
id="beispiele"
className="mx-auto max-w-6xl px-4 py-16 md:px-6 md:py-24"
>
<div className="mx-auto max-w-2xl text-center">
<span className="island-kicker">Beispielprojekte</span>
<h2 className="mt-3 font-heading text-3xl font-bold leading-tight tracking-tight text-(--sea-ink) sm:text-4xl">
So kann Ihr Ergebnis aussehen
</h2>
<p className="mt-4 text-(--sea-ink-soft) sm:text-lg">
Demo-Projekte für Handwerk, Praxis und Therapie - in Kürze hier zu sehen.
</p>
</div>
<ul className="mt-12 grid gap-5 md:grid-cols-3">
{placeholders.map((p) => (
<li
key={p.name}
className="feature-card relative overflow-hidden rounded-2xl border border-(--line)"
>
<div className="relative aspect-[4/3] bg-linear-to-br from-(--sand) via-(--foam) to-(--lagoon)/25">
<div className="absolute inset-0 flex items-center justify-center">
<span className="inline-flex items-center gap-1.5 rounded-full border border-(--line) bg-(--surface-strong) px-3 py-1 text-xs font-semibold text-(--sea-ink) shadow-sm backdrop-blur-sm">
<Sparkles
className="h-3.5 w-3.5 text-(--lagoon-deep)"
aria-hidden
/>
In Kürze
</span>
</div>
</div>
<div className="p-5">
<p className="text-xs uppercase tracking-widest text-(--sea-ink-soft)">
{p.type}
</p>
<p className="mt-1 font-heading text-base font-semibold text-(--sea-ink)">
{p.name}
</p>
</div>
</li>
))}
</ul>
</section>
);
};
+67
View File
@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "#/lib/utils.ts"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-md 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 hover:bg-primary/80",
outline:
"border-border bg-background shadow-xs 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-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] 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-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-9",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
"icon-lg": "size-10",
},
},
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 }
+116
View File
@@ -0,0 +1,116 @@
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { X } from "lucide-react";
import { cn } from "#/lib/utils.ts";
function Dialog(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger(
props: React.ComponentProps<typeof DialogPrimitive.Trigger>,
) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogClose(
props: React.ComponentProps<typeof DialogPrimitive.Close>,
) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogPortal(
props: React.ComponentProps<typeof DialogPrimitive.Portal>,
) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl border border-[var(--line)] bg-[var(--surface,var(--background))] p-6 shadow-2xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
aria-label="Schließen"
className="absolute right-3 top-3 z-10 inline-flex h-8 w-8 items-center justify-center rounded-md border border-[var(--line)] bg-[var(--chip-bg)] text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="h-4 w-4" />
<span className="sr-only">Schließen</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-lg font-semibold text-(--sea-ink)",
className,
)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-(--sea-ink-soft)", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};