Revision vor Kundenübergabe: Plausibilität, SEO, A11y, OpenPanel

This commit is contained in:
2026-09-10 16:51:09 +02:00
parent 3d4f38bfac
commit 3bb0438215
21 changed files with 839 additions and 796 deletions
+109 -617
View File
File diff suppressed because it is too large Load Diff
+4 -6
View File
@@ -26,8 +26,7 @@
"@calcom/embed-react": "^1.5.3",
"@fontsource-variable/nunito-sans": "^5.3.0",
"@fontsource-variable/public-sans": "^5.3.0",
"@react-three/drei": "^10.7.8",
"@react-three/fiber": "^9.7.0",
"@openpanel/web": "^1.4.1",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "latest",
"@tanstack/react-router": "latest",
@@ -36,7 +35,6 @@
"better-auth": "^1.5.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.1",
"lucide-react": "^0.577.0",
"nitro": "3.0.260610-beta",
@@ -47,9 +45,8 @@
"shadcn": "^4.19.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.1.18",
"three": "^0.185.1",
"tw-animate-css": "^1.3.6",
"tsx": "^4.21.0"
"tsx": "^4.21.0",
"tw-animate-css": "^1.3.6"
},
"devDependencies": {
"@biomejs/biome": "2.4.5",
@@ -64,6 +61,7 @@
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv": "^17.3.1",
"drizzle-kit": "^0.31.10",
"typescript": "^6.0.2",
"vite": "^8.0.0"
},
+8
View File
@@ -0,0 +1,8 @@
User-agent: *
Allow: /
Disallow: /login
Disallow: /dashboard
Disallow: /admin
Disallow: /api/
Sitemap: https://webkulisse.de/sitemap.xml
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://webkulisse.de/</loc>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://webkulisse.de/termin</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://webkulisse.de/impressum</loc>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://webkulisse.de/datenschutz</loc>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
</urlset>
+10
View File
@@ -0,0 +1,10 @@
import { useEffect } from "react";
// OpenPanel wird ausschließlich im Browser initialisiert. Ein dynamischer Import
// verhindert, dass der Konstruktor bereits beim Server-Rendering läuft.
export function Analytics() {
useEffect(() => {
void import("@/openpanel.ts");
}, []);
return null;
}
+25 -10
View File
@@ -24,14 +24,25 @@ export const Footer = () => {
</div>
<div className="flex flex-col gap-3 sm:flex-row lg:flex-col">
<BookingDialog>
<Button data-type="accent" size="lg" className="gap-2">
<Button
data-type="accent"
size="lg"
className="gap-2"
data-track="cta_erstgespraech"
data-source="footer"
>
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
href="tel:+491718084830"
className="no-underline"
data-track="kontakt_telefon"
data-source="footer_cta"
>
+49 171 8084830
</a>
</Button>
</div>
@@ -45,10 +56,10 @@ export const Footer = () => {
<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"
alt=""
height="520"
width="690"
src="/img/logo.png"
className="h-8 w-8 rounded-md"
/>
<span className="font-heading text-lg font-bold text-(--sea-ink)">
@@ -66,7 +77,7 @@ export const Footer = () => {
Anbieter
</h3>
<address className="mt-3 text-sm not-italic text-(--sea-ink)">
Mathias Hurler Webdesign
Hurler Webdesign
<br />
Untermagerbein 30
<br />
@@ -83,6 +94,8 @@ export const Footer = () => {
<a
href="mailto:info@webkulisse.de"
className="inline-flex items-center gap-2 no-underline hover:underline"
data-track="kontakt_mail"
data-source="footer_list"
>
<Mail className="h-4 w-4" aria-hidden />
info@webkulisse.de
@@ -90,11 +103,13 @@ export const Footer = () => {
</li>
<li>
<a
href="tel:+4917622222222"
href="tel:+491718084830"
className="inline-flex items-center gap-2 no-underline hover:underline"
data-track="kontakt_telefon"
data-source="footer_list"
>
<Phone className="h-4 w-4" aria-hidden />
+49 176 22222222
+49 171 8084830
</a>
</li>
<li className="inline-flex items-center gap-2 text-(--sea-ink-soft)">
+38 -17
View File
@@ -6,11 +6,11 @@ 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" },
{ hash: "zielgruppen", label: "Für wen" },
{ hash: "warum-wir", label: "Warum wir" },
{ hash: "ablauf", label: "Ablauf" },
{ hash: "pricing", label: "Angebote" },
{ hash: "kontakt", label: "Kontakt" },
];
export default function Header() {
@@ -52,7 +52,7 @@ export default function Header() {
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3 md:px-6">
<Link to="/" aria-label="Startseite" className="flex items-center gap-2 no-underline">
<img
alt="Logo Webkulisse"
alt=""
height="520"
width="690"
src="/img/logo.png"
@@ -66,13 +66,17 @@ export default function Header() {
<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}
<li key={link.hash}>
<Link
to="/"
hash={link.hash}
className="nav-link rounded-md px-3 py-2 text-sm font-medium no-underline"
data-track="nav_anchor"
data-hash={link.hash}
data-source="header_desktop"
>
{link.label}
</a>
</Link>
</li>
))}
</ul>
@@ -89,7 +93,14 @@ export default function Header() {
</button>
<Button asChild variant="default" className="hidden sm:inline-flex">
<Link to={loginTarget}>{loginLabel}</Link>
<Link
to={loginTarget}
data-track="nav_kundenlogin"
data-source="header_desktop"
data-authenticated={session?.user ? "true" : "false"}
>
{loginLabel}
</Link>
</Button>
<button
@@ -97,7 +108,7 @@ export default function Header() {
onClick={() => setMobileOpen((v) => !v)}
aria-expanded={mobileOpen}
aria-controls="mobile-nav"
aria-label="Menü öffnen"
aria-label={mobileOpen ? "Menü schließen" : "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" />}
@@ -113,19 +124,29 @@ export default function Header() {
>
<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}
<li key={link.hash}>
<Link
to="/"
hash={link.hash}
onClick={() => setMobileOpen(false)}
className="nav-link block rounded-md px-3 py-2 text-base font-medium no-underline"
data-track="nav_anchor"
data-hash={link.hash}
data-source="header_mobile"
>
{link.label}
</a>
</Link>
</li>
))}
<li className="pt-2">
<Button asChild variant="default" className="w-full">
<Link to={loginTarget} onClick={() => setMobileOpen(false)}>
<Link
to={loginTarget}
onClick={() => setMobileOpen(false)}
data-track="nav_kundenlogin"
data-source="header_mobile"
data-authenticated={session?.user ? "true" : "false"}
>
{loginLabel}
</Link>
</Button>
+46 -46
View File
@@ -10,58 +10,58 @@ const trustPoints = [
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>
<section className="mx-auto max-w-4xl px-4 pt-12 pb-16 md:px-6 md:pt-20 md:pb-24">
<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>
<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>
<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>
<div className="flex flex-wrap items-center gap-3">
<BookingDialog>
<Button
data-type="accent"
size="lg"
className="gap-2"
data-track="cta_erstgespraech"
data-source="hero"
>
Erstgespräch buchen
<ArrowRight className="h-4 w-4" />
</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>
</BookingDialog>
<Button variant="ghost" size="lg" asChild>
<a
href="#pricing"
className="no-underline"
data-track="cta_pakete_ansehen"
data-source="hero"
>
Pakete ansehen
</a>
</Button>
</div>
<p className="text-sm text-(--sea-ink-soft)">
30 Minuten, kostenlos - direkt online buchen, keine Warteschleifen.
</p>
<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>
<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>
</section>
);
+279 -17
View File
@@ -1,3 +1,4 @@
import { useState } from "react";
import { Check, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import { BookingDialog } from "@/components/booking-dialog.tsx";
@@ -5,6 +6,7 @@ import { BookingDialog } from "@/components/booking-dialog.tsx";
type PricingPlan = {
id: number;
name: string;
slug: string;
description: string;
price: number;
features: string[];
@@ -12,16 +14,26 @@ type PricingPlan = {
highlighted?: boolean;
};
type HostingPlan = {
id: number;
name: string;
description: string;
priceMonthly: number;
priceYearly: number;
features: string[];
highlighted: boolean;
}
const offerlist: PricingPlan[] = [
{
id: 1,
name: "Das Kompakt-Paket",
slug: "kompakt",
description:
"Perfekt für die schnelle, moderne Präsentation. Ideal für Therapeuten, Berater und regionale Handwerker.",
price: 900,
price: 1800,
features: [
"Alle wichtigen Infos auf einer Seite",
"Kontaktformular & Anfahrt",
"Für Smartphone optimiert",
"SSL-Verschlüsselung inklusive",
"SEO-Grundlagen (Meta-Tags, schnelle Ladezeit)",
@@ -31,40 +43,60 @@ const offerlist: PricingPlan[] = [
{
id: 2,
name: "Das Klassik-Paket",
slug: "klassik",
description:
"Für Praxen und Betriebe mit breitem Angebot. Maximale Übersicht für Ihre Besucher.",
price: 1700,
price: 2500,
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,
}
];
const hostinglist: HostingPlan[] = [
{
id: 1,
name: "Basis",
description: "",
priceMonthly: 79,
priceYearly: 790,
features: [
"Premium-Hosting",
"SSL-Zertifikat",
"Uptime Monitoring",
"30 Minuten pro Monat für Textänderungen oder Anpassungen",
],
highlighted: false,
},
{
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,
id: 2,
name: "Standard",
description: "",
priceMonthly: 149,
priceYearly: 1490,
features: [
"Login- und Nutzerbereich",
"Individuelle Funktionen",
"Terminbuchung oder Kundenportal",
"SEO-Grundlagen (Meta-Tags, schnelle Ladezeit)",
"Skalierbare Infrastruktur",
"Alles aus Basis",
"1 Stunde pro Monat für inhaltliche Pflege, z. B. neue Texte, Bilder, Teammitglieder",
"Monatlicher Performance-Report zu Besucherzahlen und Analytics",
],
priceNote: "Endpreis abhängig vom Funktionsumfang - Details im Erstgespräch.",
},
highlighted: true,
}
];
const priceFormatter = new Intl.NumberFormat("de-DE");
type Billing = "monthly" | "yearly";
export const Pricing = () => {
const [billing, setBilling] = useState<Billing>("monthly");
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>
@@ -77,7 +109,7 @@ export const Pricing = () => {
</p>
</div>
<div className="mt-12 grid gap-6 lg:grid-cols-3 lg:items-stretch">
<div className="mx-auto mt-12 grid max-w-4xl gap-6 lg:grid-cols-2 lg:items-stretch">
{offerlist.map((offer) => {
const isHighlighted = offer.highlighted;
return (
@@ -132,6 +164,8 @@ export const Pricing = () => {
<Button
variant={isHighlighted ? "default" : "outline"}
className="w-full"
data-track="cta_erstgespraech"
data-source={`pricing_${offer.slug}`}
>
Erstgespräch buchen
</Button>
@@ -141,6 +175,234 @@ export const Pricing = () => {
);
})}
</div>
<div className="mt-24 border-t border-(--line) pt-16">
<div className="mx-auto max-w-2xl text-center">
<span className="island-kicker">Hosting & Betreuung</span>
<h3 className="mt-3 font-heading text-3xl font-bold leading-tight tracking-tight text-(--sea-ink) sm:text-4xl">
Rundum betreut - auch nach dem Launch.
</h3>
<p className="mt-4 text-(--sea-ink-soft) sm:text-lg">
Wir übernehmen Technik, Sicherheit und laufende Anpassungen -
damit Ihre Seite dauerhaft schnell, sicher und aktuell bleibt.
</p>
</div>
<div className="mt-8 flex justify-center">
<div
role="radiogroup"
aria-label="Abrechnungszeitraum"
className="inline-flex rounded-full border border-(--line) bg-white p-1 text-sm shadow-sm"
>
<button
type="button"
role="radio"
aria-checked={billing === "monthly"}
onClick={() => setBilling("monthly")}
data-track="pricing_billing_toggle"
data-mode="monthly"
className={[
"rounded-full px-4 py-1.5 font-medium transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--lagoon-deep)",
billing === "monthly"
? "bg-(--lagoon-deep) text-white shadow"
: "text-(--sea-ink-soft) hover:text-(--sea-ink)",
].join(" ")}
>
Monatlich
</button>
<button
type="button"
role="radio"
aria-checked={billing === "yearly"}
onClick={() => setBilling("yearly")}
data-track="pricing_billing_toggle"
data-mode="yearly"
className={[
"flex items-center gap-2 rounded-full px-4 py-1.5 font-medium transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--lagoon-deep)",
billing === "yearly"
? "bg-(--lagoon-deep) text-white shadow"
: "text-(--sea-ink-soft) hover:text-(--sea-ink)",
].join(" ")}
>
Jährlich
<span
className={[
"rounded-full px-2 py-0.5 text-xs font-semibold",
billing === "yearly"
? "bg-white/20 text-white"
: "bg-(--surface-strong) text-(--lagoon-deep)",
].join(" ")}
>
2 Monate frei
</span>
</button>
</div>
</div>
<div className="mx-auto mt-10 grid max-w-4xl gap-6 lg:grid-cols-2 lg:items-stretch">
{hostinglist.map((plan) => {
const isHighlighted = plan.highlighted;
const price =
billing === "monthly" ? plan.priceMonthly : plan.priceYearly;
const suffix = billing === "monthly" ? "/ Monat" : "/ Jahr";
return (
<article
key={plan.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">
<h4 className="font-heading text-xl font-bold text-(--sea-ink)">
{plan.name}
</h4>
{plan.description && (
<p className="text-sm text-(--sea-ink-soft)">
{plan.description}
</p>
)}
</header>
<div className="flex items-baseline gap-1">
<span className="font-heading text-4xl font-bold text-(--sea-ink)">
{priceFormatter.format(price)}
</span>
<span className="text-sm text-(--sea-ink-soft)">
{suffix}
</span>
</div>
<p className="-mt-3 text-xs text-(--sea-ink-soft)">
zzgl. gesetzl. Umsatzsteuer
</p>
<ul className="space-y-2.5 text-sm text-(--sea-ink)">
{plan.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>
</article>
);
})}
</div>
<p className="mx-auto mt-6 max-w-4xl text-center text-xs text-(--sea-ink-soft)">
Nicht genutzte Zeitkontingente verfallen zum Monatsende.
Kündbar zum Ende der jeweiligen Laufzeit.
</p>
</div>
</section>
<section
id="startup"
className="mx-auto max-w-6xl px-4 pb-16 md:px-6 md:pb-24"
>
<div className="island-shell relative overflow-hidden rounded-3xl p-6 sm:p-10 lg:p-12">
<div className="grid gap-10 lg:grid-cols-2 lg:items-center">
<div>
<span className="island-kicker">Für Startups</span>
<h2 className="mt-3 font-heading text-3xl font-bold leading-tight tracking-tight text-(--sea-ink) sm:text-4xl">
Ohne Vorabkosten starten.
</h2>
<p className="mt-4 text-(--sea-ink-soft) sm:text-lg">
Als Startup steckt jeder Euro ins Wachstum. Wir übernehmen
die Website und finanzieren die Erstellung mit - Sie zahlen
von Tag eins nur einen festen Monatspreis.
</p>
<ul className="mt-6 space-y-2.5 text-sm text-(--sea-ink)">
{[
"0 € Einmalkosten für die Erstellung",
"Klassik-Paket inkl. Hosting, SSL und laufender Pflege",
"1 Stunde inhaltliche Betreuung pro Monat inklusive",
"Mindestlaufzeit 24 Monate, danach monatlich kündbar",
].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>
<p className="mt-4 text-xs text-(--sea-ink-soft)">
Für Unternehmen mit Gründung innerhalb der letzten 3 Jahre.
Nachweis über Handelsregister- oder Gewerbeanmeldung.
</p>
</div>
<div className="rounded-2xl border border-(--lagoon-deep) bg-white p-6 shadow-[0_20px_50px_rgba(30,90,72,0.18)] sm:p-8">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-widest text-(--sea-ink-soft)">
Startup-Paket
</p>
<p className="mt-1 font-heading text-sm text-(--sea-ink-soft)">
Alles inklusive
</p>
</div>
<span className="inline-flex 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 />
Angebot
</span>
</div>
<div className="mt-6 flex items-baseline gap-1">
<span className="font-heading text-5xl font-bold text-(--sea-ink)">
300
</span>
<span className="text-sm text-(--sea-ink-soft)">/ Monat</span>
</div>
<p className="mt-1 text-xs text-(--sea-ink-soft)">
zzgl. gesetzl. Umsatzsteuer
</p>
<dl className="mt-5 grid grid-cols-2 gap-3 text-sm">
<div className="rounded-xl bg-(--surface-strong) px-3 py-2">
<dt className="text-xs text-(--sea-ink-soft)">
Einmalkosten
</dt>
<dd className="font-heading font-bold text-(--sea-ink)">
0
</dd>
</div>
<div className="rounded-xl bg-(--surface-strong) px-3 py-2">
<dt className="text-xs text-(--sea-ink-soft)">Laufzeit</dt>
<dd className="font-heading font-bold text-(--sea-ink)">
24 Monate
</dd>
</div>
</dl>
<BookingDialog>
<Button
variant="default"
className="mt-6 w-full"
data-track="cta_erstgespraech"
data-source="pricing_startup"
>
Erstgespräch buchen
</Button>
</BookingDialog>
</div>
</div>
</div>
</section>
</>
);
};
+21
View File
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
import Header from "@/components/header.tsx";
import { Footer } from "@/components/footer.tsx";
export function PublicShell({ children }: { children: ReactNode }) {
return (
<div className="relative flex min-h-screen flex-col">
<a
href="#main"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-(--lagoon-deep) focus:px-4 focus:py-2 focus:font-medium focus:text-white focus:shadow-lg"
>
Direkt zum Hauptinhalt springen
</a>
<Header />
<main id="main" tabIndex={-1} className="flex-1 outline-none">
{children}
</main>
<Footer />
</div>
);
}
+1
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type ReactNode, type CSSProperties } from 'react';
import './devtool.css';
interface CrosshairProps {
children: ReactNode;
+20
View File
@@ -0,0 +1,20 @@
import { OpenPanel } from '@openpanel/web';
// Do-Not-Track-Signal des Browsers respektieren. Wenn der Besucher DNT aktiv
// hat, wird das SDK deaktiviert keine Events, keine Screen Views. Damit die
// Zusage in der Datenschutzerklärung technisch abgedeckt ist.
const respectDnt =
typeof navigator !== 'undefined' &&
(navigator.doNotTrack === '1' ||
// Legacy-Fallbacks für ältere Browser-Implementierungen.
(navigator as unknown as { msDoNotTrack?: string }).msDoNotTrack === '1' ||
(window as unknown as { doNotTrack?: string }).doNotTrack === '1');
export const op = new OpenPanel({
apiUrl: 'https://data.hurler-webdesign.de',
clientId: '40700ec3-9beb-45f9-9a2a-c9321d307d1a',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
disabled: respectDnt,
});
+51 -18
View File
@@ -1,16 +1,38 @@
import { HeadContent, Scripts, createRootRouteWithContext } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import { lazy, Suspense } from 'react'
import type { Session } from '#/lib/auth.ts'
import { getSessionFn } from '#/lib/session.ts'
import { Analytics } from '#/components/analytics.tsx'
import appCss from '../styles.css?url'
export interface RouterAppContext {
session: Session | null
}
const themeItnitScript = `
const DevtoolsPanel = import.meta.env.DEV
? lazy(async () => {
const [{ TanStackDevtools }, { TanStackRouterDevtoolsPanel }] = await Promise.all([
import('@tanstack/react-devtools'),
import('@tanstack/react-router-devtools'),
])
return {
default: () => (
<TanStackDevtools
config={{ position: 'bottom-right' }}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
),
}
})
: null
const themeInitScript = `
(function() {
try {
var stored = localStorage.getItem('theme');
@@ -57,19 +79,31 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
content:
'Handgemachte Webseiten blitzschnell, sicher und individuell.',
},
{ property: 'og:image', content: '/img/logo.png' },
{ property: 'og:image', content: 'https://webkulisse.de/img/og-cover.png' },
{ property: 'og:image:width', content: '1200' },
{ property: 'og:image:height', content: '630' },
{ property: 'og:image:alt', content: 'webkulisse Webdesign für Handwerk, Praxis & Therapie' },
{ property: 'og:url', content: 'https://webkulisse.de/' },
// Twitter/X Card
{ name: 'twitter:card', content: 'summary' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: 'webkulisse' },
{
name: 'twitter:description',
content:
'Handgemachte Webseiten blitzschnell, sicher und individuell.',
},
{ name: 'twitter:image', content: '/img/logo.png' },
{ name: 'twitter:image', content: 'https://webkulisse.de/img/og-cover.png' },
],
links: [
{ rel: 'stylesheet', href: appCss },
// Variable-Font vorladen, damit erste Textausgabe nicht auf den System-Fallback rutscht.
{
rel: 'preload',
href: '/fonts/VendSans-VariableFont_wght.woff2',
as: 'font',
type: 'font/woff2',
crossOrigin: 'anonymous',
},
// Favicon wird in Browser-Tabs, Bookmarks und PWA-Icons genutzt.
{ rel: 'icon', type: 'image/png', href: '/img/logo.png' },
{ rel: 'apple-touch-icon', href: '/img/logo.png' },
@@ -78,7 +112,11 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
// z. B. im Blog: { rel: 'canonical', href: `${SITE_URL}/blog/${slug}` }.
{ rel: 'canonical', href: 'https://webkulisse.de/' },
],
scripts: [{ children: themeItnitScript }]
scripts: [
{ children: themeInitScript },
// OpenPanel-Loader (self-hosted). Async, damit der Haupt-Thread nicht blockiert wird.
{ src: 'https://hub.hurler-webdesign.de/op1.js', async: true, defer: true },
]
}),
shellComponent: RootDocument,
})
@@ -91,17 +129,12 @@ function RootDocument({ children }: { children: React.ReactNode }) {
</head>
<body>
{children}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<Analytics />
{DevtoolsPanel ? (
<Suspense fallback={null}>
<DevtoolsPanel />
</Suspense>
) : null}
<Scripts />
</body>
</html>
+3
View File
@@ -13,5 +13,8 @@ export const Route = createFileRoute('/_admin')({
}
return { session: context.session }
},
head: () => ({
meta: [{ name: 'robots', content: 'noindex,nofollow' }],
}),
component: () => <Outlet />,
})
+3
View File
@@ -10,5 +10,8 @@ export const Route = createFileRoute('/_authenticated')({
}
return { session: context.session }
},
head: () => ({
meta: [{ name: 'robots', content: 'noindex,nofollow' }],
}),
component: () => <Outlet />,
})
+100 -16
View File
@@ -1,6 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
import Header from "@/components/header.tsx";
import { Footer } from "@/components/footer.tsx";
import { PublicShell } from "@/components/public-shell.tsx";
export const Route = createFileRoute("/datenschutz")({
component: DatenschutzPage,
@@ -19,10 +18,8 @@ export const Route = createFileRoute("/datenschutz")({
function DatenschutzPage() {
return (
<div className="relative flex min-h-screen flex-col">
<Header />
<main className="flex-1">
<section className="mx-auto max-w-3xl px-4 pt-10 pb-6 md:px-6 md:pt-16">
<PublicShell>
<section className="mx-auto max-w-3xl px-4 pt-10 pb-6 md:px-6 md:pt-16">
<span className="island-kicker">Rechtliches</span>
<h1 className="mt-3 font-heading text-3xl font-bold leading-tight tracking-tight text-(--sea-ink) sm:text-4xl">
Datenschutzerklärung
@@ -323,16 +320,105 @@ function DatenschutzPage() {
</p>
</div>
{/*
* TODO Datenschutz — vor Launch juristisch prüfen lassen.
* Die konkreten Zusagen dieser Ziffer 10 basieren auf den
* offiziellen OpenPanel-Angaben (openpanel.dev/for/gdpr-compliant,
* Stand September 2026) und der Default-Konfiguration der
* self-hosted Instanz auf data.hurler-webdesign.de:
* • Cookieless by default (kein Consent-Banner nötig).
* • Session-Kennung = täglich rotierender Hash aus IP +
* User-Agent; rohe IP wird nicht gespeichert.
* • Geo-Daten aus dem Hash abgeleitet, nicht aus der IP.
* • DNT-Signal wird respektiert in src/openpanel.ts
* via `disabled: navigator.doNotTrack === '1'` gesetzt.
* • Retention aggregierter Statistiken: aktuell keine
* TTL auf ClickHouse konfiguriert Daten bleiben
* dauerhaft. Wenn eine Frist zugesagt werden soll,
* ClickHouse-TTL setzen und Text hier anpassen.
* Empfehlung: BayLDA-Muster / Anwalt vor Kundenübergabe.
*/}
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
10. Keine externen Analyse- oder Marketingdienste
10. Reichweitenmessung mit OpenPanel (selbst gehostet)
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Wir setzen bewusst keine Analyse-Tools wie Google
Analytics, Matomo, Meta-Pixel oder ähnliche Werkzeuge zur
Nachverfolgung Ihres Nutzungsverhaltens ein. Es werden
keine Nutzerprofile erstellt und keine Daten an
Werbepartner übermittelt.
Zur bedarfsgerechten Gestaltung und laufenden Verbesserung
unseres Onlineangebots erfassen wir anonymisierte
Zugriffsstatistiken mit <strong>OpenPanel</strong>, einer
quelloffenen Analytics-Software. OpenPanel läuft auf einer
von uns selbst betriebenen Instanz unter der Subdomain{" "}
<a
href="https://data.hurler-webdesign.de"
target="_blank"
rel="noreferrer noopener"
className="underline underline-offset-2"
>
data.hurler-webdesign.de
</a>
. Das begleitende Ladeskript wird von{" "}
<a
href="https://hub.hurler-webdesign.de/op1.js"
target="_blank"
rel="noreferrer noopener"
className="underline underline-offset-2"
>
hub.hurler-webdesign.de
</a>{" "}
ausgeliefert - ebenfalls unsere eigene Infrastruktur. Es
handelt sich also nicht um einen externen Dienstleister
und nicht um einen Drittanbieter im Rechtssinne - die
Daten verlassen unsere eigene Infrastruktur in der
Europäischen Union nicht. Eine Übermittlung in ein
Drittland (insbesondere in die USA) findet nicht statt.
</p>
<p className="leading-relaxed text-(--sea-ink-soft)">
Verarbeitet werden ausschließlich aggregierte, nicht
personenbezogene Daten:
</p>
<ul className="list-disc space-y-1 pl-6 leading-relaxed text-(--sea-ink-soft)">
<li>aufgerufene Seiten und Zeitpunkt des Aufrufs</li>
<li>Referrer (zuvor besuchte Seite, sofern übermittelt)</li>
<li>Herkunftsland (aus dem Sitzungs-Hash abgeleitet)</li>
<li>verwendete Browser- und Geräteklasse</li>
<li>Bildschirmauflösung und Spracheinstellung</li>
<li>ausgehende Klicks auf externe Links</li>
</ul>
<p className="leading-relaxed text-(--sea-ink-soft)">
Es werden <strong>keine Cookies</strong> zur
Wiedererkennung gesetzt, kein browserübergreifendes
Tracking durchgeführt und keine Nutzerprofile gebildet.
Zur Zusammenfassung mehrerer Aufrufe innerhalb eines
Besuchs bildet OpenPanel einen kurzlebigen Sitzungs-
schlüssel: einen Hashwert aus IP-Adresse und Browser-
Kennung, der sich <strong>täglich neu bildet</strong>.
Nach spätestens 24 Stunden lässt sich ein Besuch also
keiner bestimmten Kennung mehr zuordnen. Die
IP-Adresse selbst wird <strong>zu keinem Zeitpunkt
gespeichert</strong>; das Herkunftsland wird aus dem
Hashwert abgeleitet und die IP anschließend verworfen.
Eine Weitergabe an Werbepartner oder Dritte findet
nicht statt.
</p>
<p className="leading-relaxed text-(--sea-ink-soft)">
Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser
berechtigtes Interesse liegt in der statistischen
Auswertung des Nutzungsverhaltens zur Verbesserung
unseres Angebots. Da nach 24 Stunden kein Bezug zu
einer einzelnen Sitzung mehr hergestellt werden kann,
werden die verbleibenden aggregierten Kennzahlen
(Seitenaufrufe, Ereignisse, Herkunftsland) unbefristet
zu statistischen Zwecken vorgehalten. Sie sind
anonym und lassen keinen Rückschluss auf einzelne
Personen zu.
</p>
<p className="leading-relaxed text-(--sea-ink-soft)">
Wir respektieren das <strong>Do-Not-Track-Signal</strong>{" "}
Ihres Browsers: Ist DNT aktiviert, wird die
Reichweitenmessung für Ihren Besuch vollständig
deaktiviert - es werden keinerlei Ereignisse an
unsere Instanz übermittelt. Alternativ können Sie
uns per E-Mail (siehe Ziffer 1) kontaktieren.
</p>
</div>
@@ -350,13 +436,11 @@ function DatenschutzPage() {
</div>
<p className="pt-4 text-sm text-(--sea-ink-soft)">
Stand: August 2026
Stand: September 2026
</p>
</div>
</div>
</section>
</main>
<Footer />
</div>
</PublicShell>
);
}
+5 -20
View File
@@ -1,6 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
import Header from "@/components/header.tsx";
import { Footer } from "@/components/footer.tsx";
import { PublicShell } from "@/components/public-shell.tsx";
export const Route = createFileRoute("/impressum")({
component: ImpressumPage,
@@ -19,10 +18,8 @@ export const Route = createFileRoute("/impressum")({
function ImpressumPage() {
return (
<div className="relative flex min-h-screen flex-col">
<Header />
<main className="flex-1">
<section className="mx-auto max-w-3xl px-4 pt-10 pb-6 md:px-6 md:pt-16">
<PublicShell>
<section className="mx-auto max-w-3xl px-4 pt-10 pb-6 md:px-6 md:pt-16">
<span className="island-kicker">Rechtliches</span>
<h1 className="mt-3 font-heading text-3xl font-bold leading-tight tracking-tight text-(--sea-ink) sm:text-4xl">
Impressum
@@ -77,16 +74,6 @@ function ImpressumPage() {
</ul>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
Umsatzsteuer
</h2>
<p className="leading-relaxed text-(--sea-ink-soft)">
Kleinunternehmer im Sinne von § 19 UStG. Es wird daher keine
Umsatzsteuer ausgewiesen.
</p>
</div>
<div className="space-y-3">
<h2 className="font-heading text-xl font-semibold">
Redaktionell verantwortlich
@@ -212,13 +199,11 @@ function ImpressumPage() {
</div>
<p className="pt-4 text-sm text-(--sea-ink-soft)">
Stand: August 2026
Stand: September 2026
</p>
</div>
</div>
</section>
</main>
<Footer />
</div>
</PublicShell>
);
}
+49 -16
View File
@@ -1,28 +1,61 @@
import { createFileRoute } from '@tanstack/react-router'
import Header from '#/components/header.tsx'
import Hero from '#/components/hero.tsx'
import { Services } from '#/components/services.tsx'
import { Features } from '#/components/features.tsx'
import { Showcase } from '#/components/showcase.tsx'
// import { Showcase } from '#/components/showcase.tsx'
import { Process } from '#/components/process.tsx'
import { Pricing } from '#/components/pricing.tsx'
import { Footer } from '#/components/footer.tsx'
import { PublicShell } from '#/components/public-shell.tsx'
export const Route = createFileRoute('/')({ component: Home })
const structuredData = {
'@context': 'https://schema.org',
'@type': 'ProfessionalService',
name: 'Hurler Webdesign',
alternateName: 'webkulisse',
description:
'Handgemachte Webseiten für Handwerk, Praxis und Therapie in Bayern schnell, sicher und persönlich betreut.',
url: 'https://webkulisse.de/',
logo: 'https://webkulisse.de/img/logo.png',
image: 'https://webkulisse.de/img/og-cover.png',
telephone: '+49 171 8084830',
email: 'info@webkulisse.de',
priceRange: '€€',
address: {
'@type': 'PostalAddress',
streetAddress: 'Untermagerbein 30',
postalCode: '86751',
addressLocality: 'Mönchsdeggingen',
addressRegion: 'BY',
addressCountry: 'DE',
},
areaServed: [
{ '@type': 'AdministrativeArea', name: 'Bayern' },
{ '@type': 'AdministrativeArea', name: 'Bayerisch-Schwaben' },
],
founder: { '@type': 'Person', name: 'Mathias Hurler' },
}
export const Route = createFileRoute('/')({
component: Home,
head: () => ({
scripts: [
{
type: 'application/ld+json',
children: JSON.stringify(structuredData),
},
],
}),
})
function Home() {
return (
<div className="relative flex min-h-screen flex-col">
<Header />
<main className="flex-1">
<Hero />
<Services />
<Features />
<Showcase />
<Process />
<Pricing />
</main>
<Footer />
</div>
<PublicShell>
<Hero />
<Services />
<Features />
{/* <Showcase /> — Referenz-Sektion vorerst deaktiviert, wird mit echten Projekten reaktiviert */}
<Process />
<Pricing />
</PublicShell>
)
}
+6
View File
@@ -11,6 +11,12 @@ export const Route = createFileRoute('/login')({
})
}
},
head: () => ({
meta: [
{ title: 'Kundenlogin - webkulisse' },
{ name: 'robots', content: 'noindex,nofollow' },
],
}),
component: LoginPage,
})
+4 -9
View File
@@ -1,7 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import Header from "@/components/header.tsx";
import { Footer } from "@/components/footer.tsx";
import BookingEmbed from "@/components/booking-embed.tsx";
import { PublicShell } from "@/components/public-shell.tsx";
export const Route = createFileRoute("/termin")({
component: TerminPage,
@@ -20,10 +19,8 @@ export const Route = createFileRoute("/termin")({
function TerminPage() {
return (
<div className="relative flex min-h-screen flex-col">
<Header />
<main className="flex-1">
<section className="mx-auto max-w-6xl px-4 pt-10 pb-6 md:px-6 md:pt-16">
<PublicShell>
<section className="mx-auto max-w-6xl px-4 pt-10 pb-6 md:px-6 md:pt-16">
<div className="mx-auto max-w-2xl text-center">
<span className="island-kicker">Erstgespräch</span>
<h1 className="mt-3 font-heading text-3xl font-bold leading-tight tracking-tight text-(--sea-ink) sm:text-4xl">
@@ -43,8 +40,6 @@ function TerminPage() {
</div>
</div>
</section>
</main>
<Footer />
</div>
</PublicShell>
);
}
+34 -4
View File
@@ -1,16 +1,15 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/nunito-sans";
@import "@fontsource-variable/public-sans";
@import "dev-component/devtool.css";
@font-face {
font-family: Vend Sans;
src: url('/public/fonts/VendSans-VariableFont_wght.woff2');
font-weight: normal;
src: url('/fonts/VendSans-VariableFont_wght.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@plugin '@tailwindcss/typography';
@@ -333,6 +332,13 @@ a {
transform: scaleX(1);
}
.nav-link:focus-visible {
outline: 2px solid var(--lagoon-deep);
outline-offset: 4px;
border-radius: 6px;
color: var(--sea-ink);
}
.rise-in {
animation: rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@@ -348,6 +354,30 @@ a {
}
}
.hero-image-in {
animation: hero-image-in 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
animation-delay: 200ms;
}
@keyframes hero-image-in {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.rise-in,
.hero-image-in {
animation-duration: 0.01ms;
animation-delay: 0ms;
}
}
.site-footer {
border-top: 1px solid var(--line);
background: color-mix(in oklab, var(--header-bg) 84%, transparent 16%);