Auth fertig erstellt. Aktuell läuft review durch KI
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user