Compare commits

21 Commits
Author SHA1 Message Date
rootandClaude Opus 5.5 80925c2a43 Kaltakquise-Vorlage: gültige Telefonnummer 0176 23726355
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:20:06 +00:00
rootandClaude Opus 5.5 b46527e65d Markenname WEBklar mit normaler Laufweite (B und k berührten sich)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:20:06 +00:00
rootandClaude Opus 5.5 d78338a969 Footer-Anker von Unterseiten, 404 ohne Konsolenfehler, Datenschutz-Link im neuen Tab
ScrollToTop springt bei /#services nach dem Laden der Schriften zum Abschnitt
(vorher landete man oben auf der Startseite bzw. 220 px zu weit unten).
Dazu Quelltext-Test gegen 99,9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:20:06 +00:00
rootandClaude Opus 5.5 e6d603cd0c Kontaktkarte: CTA-Button mit 4,5:1 Kontrast (Weiß auf dunklerem Blau)
Weiß auf #0793CF kam nur auf 3,45:1 (hell und dunkel). Der Button auf der
Kontaktkarte nutzt jetzt hsl(198 93% 35%) = 4,76:1, beim Hovern
hsl(198 93% 30%) = 6,08:1. Der Test nimmt den Button nicht mehr aus,
sondern prüft Ruhe- und Hover-Farbe aller Buttons auf den Karten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:10:27 +00:00
rootandClaude Sonnet 5.5 003f9e67aa Tests: 404-Konsolenrauschen unterdrückt, Glow-Beschreibung präzisiert
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:56:02 +00:00
rootandClaude Sonnet 5.5 897d594dde Karten im hellen Modus lesbar: Kartenfarbe nach Theme
Die BorderGlow-Karten in Values, Kontaktabschnitt und Über uns hatten
dunkle Hintergründe fest verdrahtet, ihr Text nutzt aber Theme-Farben
(Kontrast 1,07:1 im hellen Modus). BorderGlow kennt jetzt
lightBackgroundColor und wählt die Kartenfarbe per CSS nach Theme
(--card-bg-dark / --card-bg-light, html:not(.dark)), ohne Flackern beim
Laden. Dunkel behält exakt die bisherigen Farben (hsl(0 0% 6%), #120F17,
hsl(0 0% 4%)); hell nutzt hsl(0 0% 96%) mit sichtbarem Rand. Der
Kantenschein wird im hellen Modus normal statt mit plus-lighter gemischt
und in Violett gezeichnet, sonst wäre er auf Weiß unsichtbar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:48:36 +00:00
rootandClaude Sonnet 5.5 194301c6af Glow-Überschriften: Fix A aus der Hover-Analyse
Der Glow der Überschriften (Leistungen, Ablauf) wacht jetzt etwa 180 px
vor der SVG-Box auf (Zeiger-Tracking am window, nur feine Zeiger), bildet
den Mauszeiger über getScreenCTM korrekt auf die viewBox ab, blendet
weich per Opacity ein und aus und klebt auf Touch-Geräten nicht mehr.
Screenreader hören das Wort einmal (role=img mit aria-label, Textknoten
aria-hidden). Der Diff aus /root/reports/webklar-relaunch-2026-10-01/
hover/fix/text-hover-effect.fixA.diff ließ sich unverändert anwenden.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:43:43 +00:00
rootandClaude Sonnet 5.5 0d12bbc06a Navigation: Theme-Schalter und Kontakt-Button bekommen wieder Mausklicks
Der Link-Container der Desktop-Navigation ist "absolute inset-0" (hält die
Links mittig), lag dadurch aber über .navbar-actions und schluckte alle
Mausklicks auf Theme-Schalter und Kontakt-Button ab 1024 px. Der Container
ist jetzt pointer-events-none, die Links pointer-events-auto; Position und
Optik bleiben identisch, die Hover-Pille der Links funktioniert weiter.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:43:29 +00:00
rootandClaude Sonnet 5.5 b2e17d96c2 Hintergrundbilder nur im Dark Mode
problem_blitz.jpg, loesung.jpg und backgroud_effect.webp sind dunkle
Bilder mit 30 % Deckkraft und wurden im hellen Modus zu harten grauen
Quadraten. Die Bild-Ebenen sind jetzt "hidden dark:block"; der dunkle
Modus bleibt unverändert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:43:16 +00:00
rootandClaude Sonnet 5.5 2e65829e15 Anker: scroll-margin-top von 6rem für section[id]
Sprünge zu #features, #services, #process und #contact landeten unter
dem fixen Header; die Sektionen halten jetzt 6rem Abstand nach oben.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:43:01 +00:00
rootandClaude Sonnet 5.5 77672b55d3 Seitentitel und Beschreibung für Abo-, AGB-, Impressum- und 404-Seite
usePageMeta setzt jetzt auch auf /wartungs-abo, /hosting-abo, /agb,
/impressum und der 404-Seite Titel (Wartungs-Abo, Hosting-Abo, AGB,
Impressum, Seite nicht gefunden; der Hook ergänzt " | WEBklar") und
Beschreibung. Die Abo-Beschreibungen nennen nur Preis, Mindestlaufzeit und
Leistungen, die auf der jeweiligen Seite stehen; Impressum nennt Winnweiler
wie die Seite selbst. Die 404-Seite behält die Standardbeschreibung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:42:43 +00:00
rootandClaude Sonnet 5.5 f0ae8c0be2 Kontaktformular: Datenschutzhinweis unter dem Senden-Button
"Wir verwenden Ihre Angaben nur, um Ihre Anfrage zu beantworten. Mehr dazu
in unserer Datenschutzerklärung." als kleiner, gedämpfter Text mit Link auf
https://project.webklar.com/datenschutz.html (gleiches Ziel wie im Footer).
Textfarben sind die Theme-Token (muted-foreground / foreground), in Hell
und Dunkel über 4,5:1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:42:27 +00:00
rootandClaude Sonnet 5.5 80099cbdb4 Schreibweise: WEBklar statt Webklar im sichtbaren Markennamen
Header (Desktop und Mobil) und die Kopfzeilen der Unterseiten (Kontakt,
Über uns, AGB, Impressum, Wartungs-Abo, Hosting-Abo) schrieben die Marke
"Webklar". Rechtstexte bleiben unverändert, nur der Marken-Schriftzug in
der Kopfzeile ändert sich.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:42:08 +00:00
rootandClaude Sonnet 5.5 8d3424967c 404-Seite auf Deutsch mit lesbaren Theme-Farben
Überschrift "Seite nicht gefunden", Hinweis "Diese Seite gibt es nicht
oder nicht mehr." und Link "Zur Startseite" statt der englischen Vorlage.
bg-background/text-foreground statt festem grauen Hintergrund, damit der
Text in Hell und Dunkel lesbar ist. Der HTTP-Status bleibt 200 (Serveränderung
außerhalb dieses Pakets).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:41:54 +00:00
rootandClaude Sonnet 5.5 900d3a9ab5 Unterseiten: gemeinsamer Footer über ein Layout
/kontakt, /ueber-uns, /agb, /impressum, /wartungs-abo, /hosting-abo und
die 404-Seite bekommen den vorhandenen Footer (Impressum, Datenschutz,
Kündigen/Widerrufen) über ein gemeinsames Layout in src/routes.tsx. Die
Seiten selbst bleiben unverändert, damit die bestehenden Abo- und
AGB-Tests nicht auf doppelte Footer-Links stoßen. Die Startseite behält
ihren eigenen Footer, /datenschutz leitet weiter und bleibt ohne.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:41:38 +00:00
rootandClaude Sonnet 5.5 c75befe8e5 Erfolgsversprechen entschärft, tote Sektionen gelöscht
"Planbare Neukunden" wird zur Karte "Kundenportal", das Umsatzversprechen
in der Lösungs-Überschrift entfällt, Punkt 04 heißt "Hosting & Betreuung"
statt "SEO & Betreuung" (Sichtbar bei Google), Schritt 4 im Ablauf und die
Über-uns-Karte verweisen auf das Wartungs-Abo statt auf offene Optimierung.
AgitationSection und DifferentiationSection waren nirgends eingebunden und
sind gelöscht.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:41:15 +00:00
rootandClaude Sonnet 5.5 f337102c7a Antwortzeit: "spätestens am nächsten Werktag" statt 24 Stunden
index.html (Meta-Beschreibung), Standard-Beschreibung im Meta-Hook,
Startseiten-Meta, Kontaktseite (Meta, Einleitung) und Erfolgsmeldung des
Formulars versprechen jetzt nur noch eine Antwort spätestens am nächsten
Werktag. Weitere 24-Stunden-Versprechen gibt es im Quelltext nicht.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:41:06 +00:00
rootandClaude Sonnet 5.5 b897ae834f Eine Telefonnummer überall: 0176 23726355
Footer, Kontaktabschnitt der Startseite und Kontaktseite zeigten noch
0170 4969375, Impressum und Abo-Seiten schon 0176 23726355. Jetzt überall
0176 23726355 mit tel:+4917623726355.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:40:17 +00:00
rootandClaude Sonnet 5.5 c246b9811f Projekte-Bereich ausgeblendet: erfundene Referenzen entfernt
ProjectShowcase (vier erfundene Referenzen mit Unsplash-Hotlinks) ist
gelöscht, aus der Startseite genommen und der Nav-Punkt "Projekte"
(#projects) fällt in Desktop- und Mobil-Menü weg. Kein Link auf
#projects bleibt übrig.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:40:05 +00:00
rootandClaude Sonnet 5.5 a27b1fe255 Hero: ehrliche Fakten statt Zahlenleiste
Die Leiste mit Projektzähler (/api/projects), "Am Markt"-Ticker,
99,9 % Systemverfügbarkeit und Flagge ist ersetzt durch drei belegbare
Fakten ohne Count-up: Falkenstein (Server in Deutschland), Live-Vorschau
(im Kundenportal) und ab 20 € (Hosting im Monat). Einspaltig auf dem
Handy, ab md drei Spalten. Der zweite Button "Projekte ansehen" ist
entfernt, ebenso Projekt-Query, FOUNDING_DATE und src/lib/projects.ts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:39:46 +00:00
rootandClaude Sonnet 5.5 79c3551934 Routen in eigene Datei ausgelagert
AppRoutes in src/routes.tsx, damit die Tests die echte Routenliste im
MemoryRouter rendern können. Keine Verhaltensänderung. setup.ts bekommt
Attrappen für IntersectionObserver und ResizeObserver (jsdom kennt sie nicht).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:35:48 +00:00
33 changed files with 1011 additions and 487 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
# WEBklar – Cold-Email-Paket (B2B KMU)
Ziel: **kurze Antwort** oder **15-min. Potenzialanalyse** (unverbindlich).
Link: https://webklar.com/kontakt · E-Mail: support@webklar.com · Tel. 0170 4969375
Link: https://webklar.com/kontakt · E-Mail: support@webklar.com · Tel. 0176 23726355
---
+1 -1
View File
@@ -28,7 +28,7 @@
<title>WEBklar – Webagentur für KMU | Digitalisierung &amp; Automatisierung</title>
<meta
name="description"
content="WEBklar digitalisiert KMU: Website, Prozesse und Systeme aus einer Hand. Kostenlose Potenzialanalyse – Antwort innerhalb von 24 Stunden."
content="WEBklar digitalisiert KMU: Website, Prozesse und Systeme aus einer Hand. Kostenlose Potenzialanalyse – Antwort spätestens am nächsten Werktag."
/>
<meta name="author" content="WEBklar" />
<link rel="canonical" href="https://webklar.com/" />
+34 -25
View File
@@ -3,26 +3,46 @@ import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect } from "react";
import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom";
import { BrowserRouter, useLocation } from "react-router-dom";
import { ThemeProvider } from "@/components/ThemeProvider";
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
import Index from "./pages/Index";
import ContactPage from "./pages/Contact";
import AGBPage from "./pages/AGB";
import ImpressumPage from "./pages/Impressum";
import AboutPage from "./pages/About";
import WartungsAboPage from "./pages/WartungsAbo";
import HostingAboPage from "./pages/HostingAbo";
import DatenschutzPage from "./pages/Datenschutz";
import NotFound from "./pages/NotFound";
import { AppRoutes } from "./routes";
const queryClient = new QueryClient();
/** Scrollt bei einem Seitenwechsel nach oben; Anker-Links (URL mit #) bleiben unberührt. */
const ScrollToTop = () => {
/**
* Scrollt bei einem Seitenwechsel nach oben. Mit Anker (URL mit #) springt es zum Abschnitt,
* sobald er gerendert ist, z. B. beim Footer-Link „/#services“ von einer Unterseite aus.
*/
export const ScrollToTop = () => {
const { pathname, hash } = useLocation();
useEffect(() => {
if (!hash) window.scrollTo(0, 0);
if (!hash) {
window.scrollTo(0, 0);
return;
}
const id = decodeURIComponent(hash.slice(1));
let versuche = 0;
let frame = 0;
let abgebrochen = false;
const springe = () => {
const ziel = document.getElementById(id);
if (!ziel) {
if (versuche++ < 60) frame = requestAnimationFrame(springe); // höchstens ca. 1 s warten
return;
}
// Erst springen, wenn die Schriften geladen sind: Vorher ist der Text darüber höher,
// und der Abschnitt landet nach dem Nachladen ein Stück zu weit oben.
const schriften = document.fonts?.ready ?? Promise.resolve();
schriften.then(() => {
if (!abgebrochen) ziel.scrollIntoView();
});
};
springe();
return () => {
abgebrochen = true;
cancelAnimationFrame(frame);
};
}, [pathname, hash]);
return null;
};
@@ -36,18 +56,7 @@ const App = () => (
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ScrollToTop />
<AnalyticsProvider>
<Routes>
<Route path="/" element={<Index />} />
<Route path="/kontakt" element={<ContactPage />} />
<Route path="/agb" element={<AGBPage />} />
<Route path="/impressum" element={<ImpressumPage />} />
<Route path="/ueber-uns" element={<AboutPage />} />
<Route path="/wartungs-abo" element={<WartungsAboPage />} />
<Route path="/hosting-abo" element={<HostingAboPage />} />
<Route path="/datenschutz" element={<DatenschutzPage />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
<AppRoutes />
</AnalyticsProvider>
</BrowserRouter>
</TooltipProvider>
-45
View File
@@ -1,45 +0,0 @@
const AgitationSection = () => {
return (
<section className="py-24 md:py-32 bg-secondary/30 relative overflow-hidden">
{/* Decorative element */}
<div className="absolute inset-0 opacity-5">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] rounded-full border border-foreground" />
</div>
<div className="container mx-auto px-6 relative z-10">
<div className="max-w-4xl mx-auto text-center">
<div className="label-tag mb-4">Die Realität</div>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-display font-medium text-foreground tracking-tight uppercase mb-12">
Mehr Arbeit darf nicht die einzige Antwort auf Wachstum sein.
</h2>
<div className="space-y-8 text-lg md:text-xl text-muted-foreground leading-relaxed">
<p>
Neue Kunden sollten Ihr Unternehmen nicht ins Chaos stürzen.
</p>
<p>
Wenn mehr Umsatz automatisch mehr Stress bedeutet, liegt das Problem nicht an Ihrem Markt – <span className="text-foreground font-medium">sondern an fehlenden Systemen.</span>
</p>
<div className="pt-8 border-t border-border">
<p className="text-2xl md:text-3xl text-foreground font-display font-medium uppercase tracking-tight">
Erfolgreiche Unternehmen skalieren Prozesse.
</p>
<p className="text-2xl md:text-3xl text-muted-foreground font-display font-medium uppercase tracking-tight mt-2">
Nicht Arbeitsstunden.
</p>
</div>
<p className="text-foreground font-medium pt-4">
Genau dort setzen wir an.
</p>
</div>
</div>
</div>
</section>
);
};
export default AgitationSection;
+22
View File
@@ -6,6 +6,8 @@
--border-radius: 28px;
--glow-padding: 40px;
--cone-spread: 25;
/* Kartenfarbe nach Theme: --card-bg-dark / --card-bg-light kommen aus BorderGlow.tsx */
--card-bg: var(--card-bg-dark, #000);
position: relative;
border-radius: var(--border-radius);
isolation: isolate;
@@ -25,6 +27,12 @@
rgba(0, 0, 0, 0.1) 0px 32px 64px;
}
/* Heller Modus: helle Kartenfarbe und ein sichtbarer Rand (weiß mit 8 % Deckkraft wäre auf Weiß unsichtbar) */
html:not(.dark) .border-glow-card {
--card-bg: var(--card-bg-light, var(--card-bg-dark, #000));
border-color: hsl(var(--border));
}
.border-glow-card::before,
.border-glow-card::after,
.border-glow-card > .edge-light {
@@ -147,6 +155,20 @@
0 0 50px 2px var(--glow-color-10, hsl(40deg 80% 80% / 10%));
}
/* Heller Modus: "plus-lighter" ginge auf einer hellen Karte im Weiß unter, und die blasse Glow-Farbe wäre kaum sichtbar.
Der Kantenschein wird deshalb normal gemischt und in einem kräftigeren Violett gezeichnet. */
html:not(.dark) .border-glow-card > .edge-light {
mix-blend-mode: normal;
}
html:not(.dark) .border-glow-card > .edge-light::before {
box-shadow:
inset 0 0 0 1px hsl(268deg 83% 62% / 70%),
inset 0 0 3px 0 hsl(268deg 83% 62% / 45%),
inset 0 0 12px 0 hsl(268deg 83% 62% / 30%),
inset 0 0 25px 2px hsl(268deg 83% 62% / 15%);
}
.border-glow-inner {
display: flex;
flex-direction: column;
+6 -1
View File
@@ -56,7 +56,10 @@ interface BorderGlowProps {
className?: string;
edgeSensitivity?: number;
glowColor?: string;
/** Kartenfarbe im dunklen Modus (und Standard, wenn lightBackgroundColor fehlt). */
backgroundColor?: string;
/** Kartenfarbe im hellen Modus; ohne Angabe gilt backgroundColor in beiden Themes. */
lightBackgroundColor?: string;
borderRadius?: number;
glowRadius?: number;
glowIntensity?: number;
@@ -72,6 +75,7 @@ const BorderGlow = ({
edgeSensitivity = 30,
glowColor = '40 80 80',
backgroundColor = '#120F17',
lightBackgroundColor,
borderRadius = 28,
glowRadius = 40,
glowIntensity = 1.0,
@@ -183,7 +187,8 @@ const BorderGlow = ({
onPointerMove={animated ? undefined : handlePointerMove}
className={`border-glow-card ${className}`}
style={{
'--card-bg': backgroundColor,
'--card-bg-dark': backgroundColor,
'--card-bg-light': lightBackgroundColor ?? backgroundColor,
'--edge-sensitivity': edgeSensitivity,
'--border-radius': `${borderRadius}px`,
'--glow-padding': `${glowRadius}px`,
+6 -3
View File
@@ -12,6 +12,7 @@ const Contact = () => {
edgeSensitivity={30}
glowColor="40 80 80"
backgroundColor="#120F17"
lightBackgroundColor="hsl(0 0% 96%)"
borderRadius={28}
glowRadius={40}
glowIntensity={1}
@@ -38,9 +39,11 @@ const Contact = () => {
data-analytics-cta="Potenzialanalyse starten"
data-analytics-location="contact_section"
>
{/* Etwas dunkleres Blau als der Standard-Button (3,45:1 -> 4,76:1 mit weißem Text, Hover 6,08:1);
die Farbe gilt in beiden Themes, weil der Button nicht von Theme-Farben abhängt. */}
<Button
size="lg"
className="btn-minimal rounded-full px-8 py-6 text-base font-medium group"
className="btn-minimal rounded-full px-8 py-6 text-base font-medium group bg-[hsl(198,93%,35%)] hover:bg-[hsl(198,93%,30%)]"
>
Potenzialanalyse starten
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
@@ -54,9 +57,9 @@ const Contact = () => {
<Mail className="w-5 h-5" />
<span>support@webklar.com</span>
</a>
<a href="tel:+491704969375" className="flex items-center gap-3 hover:text-foreground transition-colors group">
<a href="tel:+4917623726355" className="flex items-center gap-3 hover:text-foreground transition-colors group">
<Phone className="w-5 h-5" />
<span>0170 4969375</span>
<span>0176 23726355</span>
</a>
</div>
</div>
-74
View File
@@ -1,74 +0,0 @@
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { ArrowRight } from "lucide-react";
import CountUp from "@/components/CountUp";
const DifferentiationSection = () => {
return (
<section className="py-24 md:py-32 bg-secondary/20 relative">
<div className="container mx-auto px-6">
<div className="max-w-4xl mx-auto text-center">
<div className="label-tag mb-4">Der Unterschied</div>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-display font-medium text-foreground tracking-tight uppercase mb-12">
Warum Unternehmen zu uns wechseln.
</h2>
<div className="grid md:grid-cols-3 gap-8 mb-12">
<div className="p-6 border border-border rounded-lg bg-background">
<div className="text-4xl font-display font-medium text-foreground mb-2">
<CountUp from={0} to={1} duration={1} padMinLength={2} startWhen={true} />
</div>
<h3 className="text-lg font-display font-medium text-foreground uppercase tracking-tight mb-2">
Alles aus einer Hand
</h3>
<p className="text-muted-foreground text-sm">
Website, Automation und Schnittstellen aus einer Hand – ein Ansprechpartner statt fünf Agenturen.
</p>
</div>
<div className="p-6 border border-border rounded-lg bg-background">
<div className="text-4xl font-display font-medium text-foreground mb-2">
<CountUp from={0} to={2} duration={1} padMinLength={2} startWhen={true} />
</div>
<h3 className="text-lg font-display font-medium text-foreground uppercase tracking-tight mb-2">
Systeme statt Inseln
</h3>
<p className="text-muted-foreground text-sm">
CRM, Formulare und Website tauschen Daten – ohne manuelles Nachpflegen.
</p>
</div>
<div className="p-6 border border-border rounded-lg bg-background">
<div className="text-4xl font-display font-medium text-foreground mb-2">
<CountUp from={0} to={3} duration={1} padMinLength={2} startWhen={true} />
</div>
<h3 className="text-lg font-display font-medium text-foreground uppercase tracking-tight mb-2">
Langfristige Partnerschaft
</h3>
<p className="text-muted-foreground text-sm">
Nach dem Go-live bleiben wir dran: Optimierung, wenn Ihr Team und Umsatz wachsen.
</p>
</div>
</div>
<Link
to="/kontakt"
data-analytics-cta="Potenzialanalyse starten"
data-analytics-location="differentiation_section"
>
<Button
size="lg"
className="btn-minimal rounded-full px-8 py-6 text-base font-medium group"
>
Potenzialanalyse starten
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</div>
</div>
</section>
);
};
export default DifferentiationSection;
+1 -1
View File
@@ -32,7 +32,7 @@ const footerLinks: { title: string; links: FooterLink[] }[] = [
title: "Kontakt",
links: [
{ id: 5, title: "support@webklar.com", href: "mailto:support@webklar.com" },
{ id: 6, title: "0170 4969375", href: "tel:+491704969375" },
{ id: 6, title: "0176 23726355", href: "tel:+4917623726355" },
],
},
{
+4 -5
View File
@@ -20,7 +20,6 @@ const Header = () => {
const navItems = [
{ name: "Über uns", link: "/ueber-uns" },
{ name: "Leistungen", link: "#services" },
{ name: "Projekte", link: "#projects" },
{ name: "Ablauf", link: "#process" },
];
@@ -33,8 +32,8 @@ const Header = () => {
<NavBody>
<NavbarLogo href="#">
<Logo width={30} height={30} />
<span className="font-display text-lg font-medium tracking-tight">
Webklar
<span className="font-display text-lg font-medium tracking-normal">
WEBklar
</span>
</NavbarLogo>
<NavItems items={navItems} />
@@ -60,8 +59,8 @@ const Header = () => {
<MobileNavHeader>
<NavbarLogo href="#">
<Logo width={30} height={30} />
<span className="font-display text-lg font-medium tracking-tight">
Webklar
<span className="font-display text-lg font-medium tracking-normal">
WEBklar
</span>
</NavbarLogo>
<div className="flex items-center gap-2">
+14 -116
View File
@@ -1,25 +1,12 @@
import { useNavigate } from "react-router-dom";
import { ArrowRight } from "lucide-react";
import React, { useState, useEffect, useRef, lazy, Suspense } from "react";
import { lazy, Suspense } from "react";
import { useTheme } from "next-themes";
import { useQuery } from "@tanstack/react-query";
import CountUp from "@/components/CountUp";
import { trackCtaClick } from "@/lib/analytics";
import { fetchProjects } from "@/lib/projects";
// WebGL-Effekte als eigene Chunks laden, damit der Text nicht auf three.js/ogl warten muss
const Silk = lazy(() => import("@/components/Silk"));
const SPARKLE_SVG = (
<svg className="btn-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z"
/>
</svg>
);
function DemoButtonLetters({ text }: { text: string }) {
const chars = text.split("");
return (
@@ -33,49 +20,16 @@ function DemoButtonLetters({ text }: { text: string }) {
);
}
const FOUNDING_DATE = new Date("2026-01-25"); // Samstag, 25. Januar 2026
// Drei belegbare Fakten statt Zahlen: Wort-Werte, deshalb kleiner gesetzt als die frühere Zahlenleiste.
const FAKTEN = [
{ wert: "Falkenstein", label: "Server in Deutschland" },
{ wert: "Live-Vorschau", label: "im Kundenportal" },
{ wert: "ab 20 €", label: "Hosting im Monat" },
];
const Hero = () => {
const navigate = useNavigate();
const { resolvedTheme } = useTheme();
const [companyAge, setCompanyAge] = useState("");
const secondBtnRef = useRef<HTMLButtonElement>(null);
// Projektzähler kommt live aus Gitea – gleicher queryKey wie im Abschnitt
// „Projekte", react-query teilt sich den einen Request.
const { data: projectsSnapshot } = useQuery({
queryKey: ["projects"],
queryFn: ({ signal }) => fetchProjects(signal),
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
});
const projectCount = projectsSnapshot?.total ?? 10;
useEffect(() => {
const calculateAge = () => {
const now = new Date();
const diff = now.getTime() - FOUNDING_DATE.getTime();
const totalSeconds = Math.floor(diff / 1000);
const days = Math.floor(totalSeconds / (60 * 60 * 24));
const hours = Math.floor((totalSeconds % (60 * 60 * 24)) / (60 * 60));
const years = Math.floor(days / 365);
const remainingDays = days % 365;
if (years > 0) {
setCompanyAge(`${years}J ${remainingDays}T ${hours}h`);
} else {
setCompanyAge(`${days}T ${hours}h`);
}
};
calculateAge();
const interval = setInterval(calculateAge, 60 * 60 * 1000); // Update every hour (only days/hours shown)
return () => clearInterval(interval);
}, []);
// ── Silk-Hintergrund Farben ──
const isDark = resolvedTheme === "dark";
@@ -149,32 +103,6 @@ const Hero = () => {
</div>
</button>
</div>
<div className="btn-wrapper w-full sm:w-auto">
<button
ref={secondBtnRef}
type="button"
className="btn w-full sm:w-auto justify-center"
onClick={() => {
const el = document.getElementById("projects");
if (el) el.scrollIntoView({ behavior: "smooth" });
else navigate("/#projects");
}}
aria-label="Projekte ansehen"
>
{SPARKLE_SVG}
<div className="txt-wrapper">
<span className="txt-width-helper" aria-hidden="true">
<DemoButtonLetters text="Projekte ansehen" />
</span>
<div className="txt-1">
<DemoButtonLetters text="Projekte ansehen" />
</div>
<div className="txt-2">
<DemoButtonLetters text="Wird geladen..." />
</div>
</div>
</button>
</div>
</div>
{/* Trust Line */}
@@ -184,46 +112,16 @@ const Hero = () => {
</div>
</div>
{/* Bottom Stats Row */}
{/* Bottom Facts Row */}
<div className="container mx-auto px-6 mt-auto pb-12 pt-20 relative z-10">
<div className="divider mb-12" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-12">
<div className="animate-fade-in" style={{ animationDelay: '0.6s' }}>
<div className="stat-number text-4xl md:text-5xl text-foreground mb-2">
<CountUp
from={0}
to={projectCount}
direction="up"
duration={1.2}
className="count-up-text"
startWhen={true}
suffix="+"
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12">
{FAKTEN.map((fakt, i) => (
<div key={fakt.wert} className="animate-fade-in" style={{ animationDelay: `${0.6 + i * 0.1}s` }}>
<div className="stat-number text-3xl lg:text-4xl xl:text-5xl text-foreground mb-2">{fakt.wert}</div>
<div className="label-tag">{fakt.label}</div>
</div>
<div className="label-tag">Projekte</div>
</div>
<div className="animate-fade-in" style={{ animationDelay: '0.7s' }}>
<div className="stat-number text-4xl md:text-5xl text-foreground mb-2">{companyAge}</div>
<div className="label-tag">Am Markt</div>
</div>
<div className="animate-fade-in" style={{ animationDelay: '0.8s' }}>
<div className="stat-number text-4xl md:text-5xl text-foreground mb-2">
<CountUp
from={0}
to={99.9}
direction="up"
duration={1.5}
className="count-up-text"
startWhen={true}
suffix="%"
/>
</div>
<div className="label-tag">Systemverfügbarkeit</div>
</div>
<div className="animate-fade-in" style={{ animationDelay: '0.9s' }}>
<div className="stat-number text-4xl md:text-5xl text-foreground mb-2">🇩🇪</div>
<div className="label-tag">Serverstandort in Deutschland</div>
</div>
))}
</div>
</div>
</section>
+2 -2
View File
@@ -58,9 +58,9 @@ const ProblemSection = () => {
</Suspense>
</div>
)}
{/* Hintergrundbild: nur Blitze rechts, auf Handy maximale Breite */}
{/* Hintergrundbild: nur Blitze rechts, auf Handy maximale Breite; nur im Dark Mode (dunkles Bild wäre im hellen ein graues Quadrat) */}
<div
className="problem-section-bg absolute inset-0 bg-right bg-no-repeat opacity-[0.3] z-0"
className="problem-section-bg hidden dark:block absolute inset-0 bg-right bg-no-repeat opacity-[0.3] z-0"
style={{
backgroundImage: "url(/problem_blitz.jpg)",
}}
+1 -1
View File
@@ -25,7 +25,7 @@ const Process = () => {
{
number: 4,
title: "Launch & Betreuung",
description: "Go-live mit Tests und Übergabe. Danach Updates, wenn sich Ihr Unternehmen weiterentwickelt.",
description: "Go-live mit Tests und Übergabe. Pflege und Updates danach im Wartungs-Abo.",
},
];
-110
View File
@@ -1,110 +0,0 @@
import { ArrowUpRight } from "lucide-react";
import { useTheme } from "next-themes";
import BorderGlow from "@/components/BorderGlow";
import { TextHoverEffect } from "@/components/ui/text-hover-effect";
type Project = {
title: string;
description: string;
image: string;
url: string;
};
const projects: Project[] = [
{
title: "Email Sorter",
description: "Automatisierung · E-Mail-Workflows für Teams",
image: "/project%20pics/emailsorter.webp",
url: "https://emailsorter.webklar.com/",
},
{
title: "Neutral",
description: "Website · Markenauftritt & Custom Development",
image: "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&h=600&fit=crop",
url: "#",
},
{
title: "Verbatim Labs",
description: "Website · UI/UX & individuelle Entwicklung",
image: "https://images.unsplash.com/photo-1559028012-481c04fa702d?w=800&h=600&fit=crop",
url: "#",
},
{
title: "JMK Engineers",
description: "Website · Technische Präsentation & Lead-Generierung",
image: "https://images.unsplash.com/photo-1486312338219-ce68d2c6f44d?w=800&h=600&fit=crop",
url: "#",
},
{
title: "GOODZ Club",
description: "Website · Mehrsprachig & skalierbare Plattform",
image: "https://images.unsplash.com/photo-1542744094-3a31f272c490?w=800&h=600&fit=crop",
url: "#",
},
];
const ProjectShowcase = () => {
const { resolvedTheme } = useTheme();
const cardBg = resolvedTheme === "dark" ? "hsl(0 0% 6%)" : "hsl(0 0% 96%)";
return (
<section id="projects" className="pt-8 pb-24 md:pt-12 md:pb-32 bg-background relative">
{/* TextHoverEffect */}
<div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10">
<TextHoverEffect text="Projekte" />
</div>
<div className="container mx-auto px-6">
{/* Projects Grid */}
<div className="space-y-2">
{projects.map((project, index) => (
<BorderGlow
key={project.title}
edgeSensitivity={30}
glowColor="40 80 80"
backgroundColor={cardBg}
borderRadius={8}
glowRadius={30}
glowIntensity={0.8}
coneSpread={25}
colors={['#c084fc', '#f472b6', '#38bdf8']}
>
<a
href={project.url}
target={project.url.startsWith("http") ? "_blank" : undefined}
rel={project.url.startsWith("http") ? "noopener noreferrer" : undefined}
className="group block p-6 md:p-8"
style={{ animationDelay: `${index * 0.1}s` }}
>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex-1">
<h3 className="text-2xl md:text-3xl font-display font-medium text-foreground mb-2 group-hover:text-muted-foreground transition-colors uppercase tracking-tight">
{project.title}
</h3>
<p className="text-muted-foreground text-sm uppercase tracking-wider">
{project.description}
</p>
</div>
<div className="flex items-center gap-4">
<div className="w-24 h-16 md:w-32 md:h-20 rounded overflow-hidden opacity-0 group-hover:opacity-100 transition-opacity duration-500">
<img
src={project.image}
alt={project.title}
loading="lazy"
decoding="async"
className="w-full h-full object-cover"
/>
</div>
<ArrowUpRight className="w-6 h-6 text-muted-foreground group-hover:text-foreground transition-colors" />
</div>
</div>
</a>
</BorderGlow>
))}
</div>
</div>
</section>
);
};
export default ProjectShowcase;
+4 -4
View File
@@ -1,4 +1,4 @@
import { Lightbulb, Palette, Code, Search } from "lucide-react";
import { Lightbulb, Palette, Code, Server } from "lucide-react";
const Services = () => {
const services = [
@@ -21,9 +21,9 @@ const Services = () => {
number: "03",
},
{
icon: Search,
title: "SEO & Betreuung",
description: "Sichtbar bei Google, stabil im Betrieb – mit Updates, wenn Ihr Unternehmen wächst.",
icon: Server,
title: "Hosting & Betreuung",
description: "Hosting auf unserem Server in Deutschland, Pflege und Updates im Wartungs-Abo.",
number: "04",
},
];
+3 -3
View File
@@ -47,9 +47,9 @@ const SolutionSection = () => {
</Suspense>
</div>
)}
{/* Hintergrundbild mittig, auf Handy maximale Breite */}
{/* Hintergrundbild mittig, auf Handy maximale Breite; nur im Dark Mode (dunkles Bild wäre im hellen ein graues Quadrat) */}
<div
className="solution-section-bg absolute inset-0 bg-center bg-no-repeat opacity-[0.3] z-0"
className="solution-section-bg hidden dark:block absolute inset-0 bg-center bg-no-repeat opacity-[0.3] z-0"
style={{
backgroundImage: "url(/loesung.jpg)",
}}
@@ -121,7 +121,7 @@ const SolutionSection = () => {
<div className="space-y-6">
<div className="text-sm uppercase tracking-wider text-muted-foreground">Das Ergebnis</div>
<h3 className="text-2xl md:text-3xl font-display font-medium text-foreground uppercase tracking-tight">
Sie sehen, was läuft – und was Umsatz bringt. Mehr Zeit für Kunden statt für Tool-Pflege:
Sie sehen, was läuft. Mehr Zeit für Kunden statt für Tool-Pflege:
</h3>
<div className="text-4xl md:text-5xl font-display font-medium text-primary uppercase tracking-tight">
Kontrolle.
+7 -6
View File
@@ -1,4 +1,4 @@
import { Users, Cog, MessageSquare, Target, BarChart3, Layers } from "lucide-react";
import { Users, Cog, MessageSquare, MonitorSmartphone, BarChart3, Layers } from "lucide-react";
import BorderGlow from "@/components/BorderGlow";
import { TextHoverEffect } from "@/components/ui/text-hover-effect";
@@ -20,9 +20,9 @@ const Values = () => {
description: "Jeder Kanal landet im System – mit Status, Zuständigkeit und Historie.",
},
{
icon: Target,
title: "Planbare Neukunden",
description: "Website und Prozesse ziehen an einer Schnur – Sie sehen, was funktioniert.",
icon: MonitorSmartphone,
title: "Kundenportal",
description: "Fortschritt, Chat und Rechnungen an einem Ort, im Browser oder als App.",
},
{
icon: BarChart3,
@@ -42,9 +42,9 @@ const Values = () => {
<div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10">
<TextHoverEffect text="Leistungen" />
</div>
{/* Hintergrundbild: auf Handy maximale Breite */}
{/* Hintergrundbild: auf Handy maximale Breite; nur im Dark Mode (dunkles Bild wäre im hellen ein graues Quadrat) */}
<div
className="values-section-bg absolute inset-0 bg-right bg-no-repeat opacity-[0.3] pointer-events-none"
className="values-section-bg hidden dark:block absolute inset-0 bg-right bg-no-repeat opacity-[0.3] pointer-events-none"
style={{
backgroundImage: "url(/backgroud_effect.webp)",
}}
@@ -69,6 +69,7 @@ const Values = () => {
edgeSensitivity={30}
glowColor="40 80 80"
backgroundColor="hsl(0 0% 6%)"
lightBackgroundColor="hsl(0 0% 96%)"
borderRadius={8}
glowRadius={30}
glowIntensity={0.8}
+4 -2
View File
@@ -105,7 +105,9 @@ export const NavItems = ({
<motion.div
onMouseLeave={() => setHovered(null)}
className={cn(
"absolute inset-0 hidden flex-1 flex-row items-center justify-center space-x-2 text-sm font-medium text-zinc-600 transition duration-200 hover:text-zinc-800 lg:flex lg:space-x-2",
// absolute inset-0 hält die Links mittig, liegt aber über Theme-Schalter und Kontakt-Button:
// der Container nimmt deshalb keine Mausklicks an (pointer-events-none), nur die Links selbst.
"pointer-events-none absolute inset-0 hidden flex-1 flex-row items-center justify-center space-x-2 text-sm font-medium text-zinc-600 transition duration-200 hover:text-zinc-800 lg:flex lg:space-x-2",
className
)}
>
@@ -114,7 +116,7 @@ export const NavItems = ({
const commonProps = {
onMouseEnter: () => setHovered(idx),
onClick: onItemClick,
className: "relative px-4 py-2 text-neutral-900 dark:text-neutral-300",
className: "pointer-events-auto relative px-4 py-2 text-neutral-900 dark:text-neutral-300",
key: `link-${idx}`,
};
const inner = (
+65 -25
View File
@@ -1,8 +1,12 @@
"use client";
import { useRef, useState, useCallback, useId } from "react";
import { useRef, useState, useEffect, useId } from "react";
import { motion } from "motion/react";
// Wie weit (in CSS-px) um die SVG-Box herum der Mauszeiger den Glow schon weckt.
// Der Glow-Radius ist 80 viewBox-Einheiten (~300 px), dahinter ist er ohnehin schwarz.
const WAKE_DISTANCE_PX = 260;
export const TextHoverEffect = ({
text,
}: {
@@ -10,24 +14,64 @@ export const TextHoverEffect = ({
duration?: number;
}) => {
const svgRef = useRef<SVGSVGElement>(null);
const [hovered, setHovered] = useState(false);
const [active, setActive] = useState(false);
const [cursorPos, setCursorPos] = useState({ x: 150, y: 30 });
const id = useId();
const gradientId = `textGradient-${id}`;
const revealMaskId = `revealMask-${id}`;
const textMaskId = `textMask-${id}`;
const handleMouseMove = useCallback(
(e: React.MouseEvent<SVGSVGElement>) => {
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
// Map screen coords to viewBox coords (0 0 300 60)
const x = ((e.clientX - rect.left) / rect.width) * 300;
const y = ((e.clientY - rect.top) / rect.height) * 60;
setCursorPos({ x, y });
},
[]
);
useEffect(() => {
const svg = svgRef.current;
// Nur echte Maus: Touch hat kein Hover und wuerde den Glow "kleben" lassen.
if (!svg || !window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
let last: { x: number; y: number } | null = null;
let raf = 0;
let isActive = false;
const update = () => {
raf = 0;
if (!last) return;
const r = svg.getBoundingClientRect();
const dx = Math.max(r.left - last.x, 0, last.x - r.right);
const dy = Math.max(r.top - last.y, 0, last.y - r.bottom);
const near = Math.hypot(dx, dy) <= WAKE_DISTANCE_PX;
if (near !== isActive) {
isActive = near;
setActive(near);
}
const m = svg.getScreenCTM();
if (near && m) {
// echte Bildschirm -> viewBox-Abbildung (beruecksichtigt "meet"-Letterbox)
const p = new DOMPoint(last.x, last.y).matrixTransform(m.inverse());
setCursorPos({ x: p.x, y: p.y });
}
};
const schedule = () => {
if (!raf) raf = requestAnimationFrame(update);
};
const onMove = (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
last = { x: e.clientX, y: e.clientY };
schedule();
};
const onLeave = () => {
last = null;
isActive = false;
setActive(false);
};
window.addEventListener("pointermove", onMove, { passive: true });
window.addEventListener("scroll", schedule, { passive: true });
document.documentElement.addEventListener("mouseleave", onLeave);
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("scroll", schedule);
document.documentElement.removeEventListener("mouseleave", onLeave);
if (raf) cancelAnimationFrame(raf);
};
}, []);
return (
<svg
@@ -36,9 +80,8 @@ export const TextHoverEffect = ({
height="100%"
viewBox="0 0 300 60"
xmlns="http://www.w3.org/2000/svg"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseMove={handleMouseMove}
role="img"
aria-label={text}
className="select-none"
>
<defs>
@@ -62,13 +105,7 @@ export const TextHoverEffect = ({
</radialGradient>
<mask id={textMaskId}>
<rect
x="0"
y="0"
width="300"
height="60"
fill={hovered ? `url(#${revealMaskId})` : "black"}
/>
<rect x="0" y="0" width="300" height="60" fill={`url(#${revealMaskId})`} />
</mask>
</defs>
@@ -79,6 +116,7 @@ export const TextHoverEffect = ({
textAnchor="middle"
dominantBaseline="middle"
strokeWidth="0.3"
aria-hidden="true"
className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800"
style={{ opacity: 0.15, fontSize: "2rem" }}
>
@@ -92,6 +130,7 @@ export const TextHoverEffect = ({
textAnchor="middle"
dominantBaseline="middle"
strokeWidth="0.3"
aria-hidden="true"
className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800"
style={{ fontSize: "2rem" }}
initial={{ strokeDashoffset: 1000, strokeDasharray: 1000 }}
@@ -101,7 +140,7 @@ export const TextHoverEffect = ({
{text}
</motion.text>
{/* Colored gradient revealed on hover */}
{/* Colored gradient revealed near the pointer */}
<text
x="150"
y="30"
@@ -110,8 +149,9 @@ export const TextHoverEffect = ({
stroke={`url(#${gradientId})`}
strokeWidth="0.3"
mask={`url(#${textMaskId})`}
aria-hidden="true"
className="font-display font-bold fill-transparent"
style={{ fontSize: "2rem" }}
style={{ fontSize: "2rem", opacity: active ? 1 : 0, transition: "opacity 300ms ease-out" }}
>
{text}
</text>
+1 -1
View File
@@ -3,7 +3,7 @@ import { useEffect } from "react";
const SITE_NAME = "WEBklar";
const DEFAULT_TITLE = `${SITE_NAME} – Webagentur für KMU`;
const DEFAULT_DESCRIPTION =
"WEBklar digitalisiert KMU: Website, Prozesse und Systeme aus einer Hand. Kostenlose Potenzialanalyse – Antwort innerhalb von 24 Stunden.";
"WEBklar digitalisiert KMU: Website, Prozesse und Systeme aus einer Hand. Kostenlose Potenzialanalyse – Antwort spätestens am nächsten Werktag.";
function setMetaTag(
attribute: "name" | "property",
+5
View File
@@ -146,6 +146,11 @@
scroll-behavior: smooth;
}
/* Anker-Ziele (#features, #services, #process, #contact) nicht unter dem fixen Header landen lassen */
section[id] {
scroll-margin-top: 6rem;
}
body {
@apply bg-background text-foreground antialiased;
font-family: 'Inter', system-ui, sans-serif;
-28
View File
@@ -1,28 +0,0 @@
/**
* Live-Projektzahl für die Statistik-Zeile im Hero.
*
* Quelle: GET /api/projects (contact-api-Container) – zählt serverseitig die
* Repositories, die aktuell in Gitea (git.webklar.com) liegen, angereichert um
* die Hosting-Metadaten aus Appwrite. Der Gitea-Token bleibt dadurch auf dem
* Server. Die Antwort enthält mehr Felder als hier genutzt werden; für die
* Homepage ist nur `total` relevant.
*/
export type ProjectsSnapshot = {
/** Zeitpunkt, zu dem der Server-Snapshot erzeugt wurde (ISO) */
generatedAt: string;
/** Anzahl der Repositories, die aktuell in Gitea existieren */
total: number;
counts: Record<string, number>;
source: string;
};
const API_URL = import.meta.env.VITE_PROJECTS_API ?? "/api/projects";
export async function fetchProjects(signal?: AbortSignal): Promise<ProjectsSnapshot> {
const res = await fetch(API_URL, { signal, headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`Projektzahl konnte nicht geladen werden (HTTP ${res.status})`);
const data = (await res.json()) as ProjectsSnapshot;
if (typeof data?.total !== "number") throw new Error("Unerwartete Antwort der Projekt-API");
return data;
}
+5 -2
View File
@@ -1,12 +1,15 @@
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { ArrowLeft, FileText } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
const contractDivider = (
<div className="my-8 border-t border-border" aria-hidden />
);
const AGB = () => {
usePageMeta("AGB", "Allgemeine Geschäftsbedingungen der WEBklar GbR.");
return (
<div className="min-h-screen bg-background">
{/* Header */}
@@ -14,8 +17,8 @@ const AGB = () => {
<div className="container mx-auto px-6">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 group">
<span className="text-xl font-display font-medium text-foreground tracking-tight">
Webklar
<span className="text-xl font-display font-medium text-foreground tracking-normal">
WEBklar
</span>
</Link>
<Link to="/">
+7 -4
View File
@@ -18,8 +18,8 @@ const About = () => {
<div className="container mx-auto px-6">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 group">
<span className="text-xl font-display font-medium text-foreground tracking-tight">
Webklar
<span className="text-xl font-display font-medium text-foreground tracking-normal">
WEBklar
</span>
</Link>
<Link to="/">
@@ -105,6 +105,7 @@ const About = () => {
edgeSensitivity={30}
glowColor="40 80 80"
backgroundColor="hsl(0 0% 4%)"
lightBackgroundColor="hsl(0 0% 96%)"
borderRadius={8}
glowRadius={30}
glowIntensity={0.8}
@@ -128,6 +129,7 @@ const About = () => {
edgeSensitivity={30}
glowColor="40 80 80"
backgroundColor="hsl(0 0% 4%)"
lightBackgroundColor="hsl(0 0% 96%)"
borderRadius={8}
glowRadius={30}
glowIntensity={0.8}
@@ -151,6 +153,7 @@ const About = () => {
edgeSensitivity={30}
glowColor="40 80 80"
backgroundColor="hsl(0 0% 4%)"
lightBackgroundColor="hsl(0 0% 96%)"
borderRadius={8}
glowRadius={30}
glowIntensity={0.8}
@@ -162,10 +165,10 @@ const About = () => {
<CountUp from={0} to={3} duration={1} padMinLength={2} startWhen={true} />
</div>
<h3 className="text-lg font-display font-medium text-foreground uppercase tracking-tight mb-2">
Langfristige Partnerschaft
Betreuung im Abo
</h3>
<p className="text-muted-foreground text-sm">
Nach dem Go-live bleiben wir dran: Optimierung, wenn Ihr Team und Umsatz wachsen.
Nach dem Go-live kümmern wir uns im Wartungs-Abo um Updates und kleine Änderungen.
</p>
</div>
</BorderGlow>
+20 -7
View File
@@ -13,7 +13,7 @@ import { usePageMeta } from "@/hooks/use-page-meta";
const Contact = () => {
usePageMeta(
"Kontakt & Potenzialanalyse",
"Beschreiben Sie Ihr Projekt – WEBklar meldet sich innerhalb von 24 Stunden mit den nächsten Schritten. Unverbindlich und kostenlos."
"Beschreiben Sie Ihr Projekt – WEBklar meldet sich spätestens am nächsten Werktag mit den nächsten Schritten. Unverbindlich und kostenlos."
);
const { toast } = useToast();
@@ -42,7 +42,7 @@ const Contact = () => {
trackFormSubmitted("contact");
toast({
title: "Nachricht gesendet!",
description: "Wir melden uns innerhalb von 24 Stunden bei Ihnen.",
description: "Wir melden uns spätestens am nächsten Werktag bei Ihnen.",
});
setFormData({ name: "", email: "", company: "", message: "" });
} catch (err) {
@@ -64,8 +64,8 @@ const Contact = () => {
<div className="container mx-auto px-6">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 group">
<span className="text-xl font-display font-medium text-foreground tracking-tight">
Webklar
<span className="text-xl font-display font-medium text-foreground tracking-normal">
WEBklar
</span>
</Link>
<Link to="/">
@@ -95,7 +95,7 @@ const Contact = () => {
</h1>
<p className="text-muted-foreground text-lg">
Kurz Ihr Ziel beschreiben – wir prüfen, wo Website, Automatisierung
oder Vernetzung den größten Hebel haben. Antwort innerhalb von 24 Stunden,
oder Vernetzung den größten Hebel haben. Antwort spätestens am nächsten Werktag,
unverbindlich.
</p>
</div>
@@ -179,6 +179,19 @@ const Contact = () => {
</>
)}
</Button>
<p className="text-sm leading-relaxed text-muted-foreground">
Wir verwenden Ihre Angaben nur, um Ihre Anfrage zu beantworten. Mehr dazu in unserer{" "}
<a
href="https://project.webklar.com/datenschutz.html"
target="_blank"
rel="noopener noreferrer"
className="text-foreground underline underline-offset-4 hover:no-underline"
>
Datenschutzerklärung
</a>
.
</p>
</form>
{/* Contact Info */}
@@ -196,10 +209,10 @@ const Contact = () => {
<div>
<div className="label-tag mb-2">Telefon</div>
<a
href="tel:+491704969375"
href="tel:+4917623726355"
className="text-foreground hover:text-muted-foreground transition-colors"
>
0170 4969375
0176 23726355
</a>
</div>
</div>
+7 -1
View File
@@ -1,6 +1,7 @@
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Server } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
const PORTAL = "https://project.webklar.com";
const STEUER = "Endpreis, gemäß § 19 UStG ohne Umsatzsteuer";
@@ -28,13 +29,18 @@ const leistungen = [
];
const HostingAbo = () => {
usePageMeta(
"Hosting-Abo",
"Ihre statische Website auf unserem Server in Deutschland: 20,00 € pro Monat, 12 Monate Mindestlaufzeit. Inklusive Domain und E-Mail-Postfach."
);
return (
<div className="min-h-screen bg-background">
<header className="fixed top-0 left-0 right-0 z-50 glass-nav py-4">
<div className="container mx-auto px-6">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 group">
<span className="text-xl font-display font-medium text-foreground tracking-tight">Webklar</span>
<span className="text-xl font-display font-medium text-foreground tracking-normal">WEBklar</span>
</Link>
<Button asChild variant="ghost" className="text-muted-foreground hover:text-foreground">
<Link to="/">
+5 -2
View File
@@ -1,12 +1,15 @@
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Scale } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
const impressumDivider = (
<div className="my-8 border-t border-border" aria-hidden />
);
const Impressum = () => {
usePageMeta("Impressum", "Impressum der WEBklar GbR, Winnweiler.");
return (
<div className="min-h-screen bg-background">
{/* Header */}
@@ -14,8 +17,8 @@ const Impressum = () => {
<div className="container mx-auto px-6">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 group">
<span className="text-xl font-display font-medium text-foreground tracking-tight">
Webklar
<span className="text-xl font-display font-medium text-foreground tracking-normal">
WEBklar
</span>
</Link>
<Link to="/">
+1 -3
View File
@@ -5,7 +5,6 @@ import ProblemSection from "@/components/ProblemSection";
import SolutionSection from "@/components/SolutionSection";
import Values from "@/components/Values";
import Services from "@/components/Services";
import ProjectShowcase from "@/components/ProjectShowcase";
import Process from "@/components/Process";
import Contact from "@/components/Contact";
import Footer from "@/components/Footer";
@@ -14,7 +13,7 @@ import { usePageMeta } from "@/hooks/use-page-meta";
const Index = () => {
usePageMeta(
"WEBklar – Webagentur für KMU",
"WEBklar digitalisiert KMU: Website, Prozesse und Systeme aus einer Hand. Kostenlose Potenzialanalyse – Antwort innerhalb von 24 Stunden."
"WEBklar digitalisiert KMU: Website, Prozesse und Systeme aus einer Hand. Kostenlose Potenzialanalyse – Antwort spätestens am nächsten Werktag."
);
return (
@@ -26,7 +25,6 @@ const Index = () => {
<SolutionSection />
<Values />
<Services />
<ProjectShowcase />
<Process />
<Contact />
<Footer />
+9 -13
View File
@@ -1,21 +1,17 @@
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
import { Link } from "react-router-dom";
import { usePageMeta } from "@/hooks/use-page-meta";
const NotFound = () => {
const location = useLocation();
useEffect(() => {
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
}, [location.pathname]);
usePageMeta("Seite nicht gefunden");
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<div className="flex min-h-[70vh] items-center justify-center bg-background px-6 text-foreground">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">404</h1>
<p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
<a href="/" className="text-primary underline hover:text-primary/90">
Return to Home
</a>
<h1 className="mb-4 text-4xl font-bold">Seite nicht gefunden</h1>
<p className="mb-6 text-xl text-muted-foreground">Diese Seite gibt es nicht oder nicht mehr.</p>
<Link to="/" className="text-foreground underline underline-offset-4 hover:no-underline">
Zur Startseite
</Link>
</div>
</div>
);
+7 -1
View File
@@ -1,6 +1,7 @@
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { ArrowLeft, ShieldCheck } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
const PORTAL = "https://project.webklar.com";
const STEUER = "Endpreis, gemäß § 19 UStG ohne Umsatzsteuer";
@@ -28,13 +29,18 @@ const leistungen = [
];
const WartungsAbo = () => {
usePageMeta(
"Wartungs-Abo",
"Betrieb, Pflege und Betreuung Ihrer Website: 50,00 € pro Monat, 12 Monate Mindestlaufzeit. Mit Domain, Postfächern und 1 Stunde Arbeitszeit pro Monat."
);
return (
<div className="min-h-screen bg-background">
<header className="fixed top-0 left-0 right-0 z-50 glass-nav py-4">
<div className="container mx-auto px-6">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 group">
<span className="text-xl font-display font-medium text-foreground tracking-tight">Webklar</span>
<span className="text-xl font-display font-medium text-foreground tracking-normal">WEBklar</span>
</Link>
<Button asChild variant="ghost" className="text-muted-foreground hover:text-foreground">
<Link to="/">
+38
View File
@@ -0,0 +1,38 @@
import { Routes, Route, Outlet } from "react-router-dom";
import Footer from "@/components/Footer";
import Index from "./pages/Index";
import ContactPage from "./pages/Contact";
import AGBPage from "./pages/AGB";
import ImpressumPage from "./pages/Impressum";
import AboutPage from "./pages/About";
import WartungsAboPage from "./pages/WartungsAbo";
import HostingAboPage from "./pages/HostingAbo";
import DatenschutzPage from "./pages/Datenschutz";
import NotFound from "./pages/NotFound";
/** Gemeinsamer Rahmen aller Unterseiten: Seiteninhalt, darunter der Footer mit Impressum, Datenschutz und Kündigen. */
const SubpageLayout = () => (
<>
<Outlet />
<Footer />
</>
);
/** Alle Seiten der Website; App.tsx rendert sie im BrowserRouter, die Tests im MemoryRouter. */
export const AppRoutes = () => (
<Routes>
{/* Die Startseite bringt ihren Footer selbst mit; /datenschutz leitet nur weiter. */}
<Route path="/" element={<Index />} />
<Route path="/datenschutz" element={<DatenschutzPage />} />
<Route element={<SubpageLayout />}>
<Route path="/kontakt" element={<ContactPage />} />
<Route path="/agb" element={<AGBPage />} />
<Route path="/impressum" element={<ImpressumPage />} />
<Route path="/ueber-uns" element={<AboutPage />} />
<Route path="/wartungs-abo" element={<WartungsAboPage />} />
<Route path="/hosting-abo" element={<HostingAboPage />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
);
+15
View File
@@ -13,3 +13,18 @@ Object.defineProperty(window, "matchMedia", {
dispatchEvent: () => {},
}),
});
// jsdom kennt diese Browser-APIs nicht; Komponenten wie BorderGlow brauchen sie.
class BeobachterAttrappe {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
for (const name of ["IntersectionObserver", "ResizeObserver"]) {
if (!(name in window)) {
Object.defineProperty(window, name, { writable: true, configurable: true, value: BeobachterAttrappe });
}
}
+716
View File
@@ -0,0 +1,716 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, within, fireEvent, act, cleanup, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { AppRoutes } from "@/routes";
import { usePageMeta } from "@/hooks/use-page-meta";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import Hero from "@/components/Hero";
import ProblemSection from "@/components/ProblemSection";
import SolutionSection from "@/components/SolutionSection";
import Values from "@/components/Values";
import Services from "@/components/Services";
import Process from "@/components/Process";
import KontaktAbschnitt from "@/components/Contact";
import About from "@/pages/About";
import { TextHoverEffect } from "@/components/ui/text-hover-effect";
import { Toaster } from "@/components/ui/toaster";
import { ScrollToTop } from "@/App";
// WebGL, Canvas und Netzwerk laden wir in jsdom nicht: die Effekt-Komponenten und
// das Kontaktformular-Backend werden durch Attrappen ersetzt.
vi.mock("@/components/Silk", () => ({ default: () => null }));
vi.mock("@/components/PixelBlast", () => ({ default: () => null }));
vi.mock("@/components/LightRays", () => ({ default: () => null }));
vi.mock("@/components/ui/flickering-grid", () => ({ FlickeringGrid: () => null }));
vi.mock("@/lib/appwrite", () => ({ createContactDocument: vi.fn().mockResolvedValue(undefined) }));
// Dieselben Future-Flags wie der BrowserRouter in App.tsx, sonst warnt React Router in der Testausgabe.
const routerFuture = { v7_startTransition: true, v7_relativeSplatPath: true };
const DATENSCHUTZ = "https://project.webklar.com/datenschutz.html";
const TELEFON = "0176 23726355";
const TELEFON_HREF = "tel:+4917623726355";
/** Rendert die komplette Routenliste (mit gemeinsamem Footer) an einem Pfad. */
const seite = (pfad: string) =>
render(
<MemoryRouter initialEntries={[pfad]} future={routerFuture}>
<AppRoutes />
</MemoryRouter>,
);
/** Rendert eine einzelne Komponente im Router. */
const zeige = (ui: React.ReactElement) =>
render(
<MemoryRouter future={routerFuture}>
{ui}
</MemoryRouter>,
);
// ── Quelltext-Scan ─────────────────────────────────────────────────────────────
// vitest läuft im Projektverzeichnis
const WURZEL = `${process.cwd()}/`;
const dateien = (ordner: string): string[] =>
readdirSync(ordner).flatMap((name) => {
const pfad = join(ordner, name);
return statSync(pfad).isDirectory() ? dateien(pfad) : [pfad];
});
const QUELLEN = [
...dateien(join(WURZEL, "src")).filter((p) => /\.(tsx?|css)$/.test(p) && !p.includes("/src/test/")),
join(WURZEL, "index.html"),
].map((pfad) => ({ pfad: pfad.replace(WURZEL, ""), text: readFileSync(pfad, "utf8") }));
/** Pfade aller Quelldateien (ohne Tests), in denen das Muster vorkommt. */
const vorkommen = (muster: RegExp) => QUELLEN.filter(({ text }) => muster.test(text)).map(({ pfad }) => pfad);
const lies = (pfad: string) => readFileSync(join(WURZEL, pfad), "utf8");
const gibtEs = (pfad: string) => existsSync(join(WURZEL, pfad));
const meta = (name: string) => document.querySelector(`meta[name="${name}"]`)?.getAttribute("content");
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("B1 Hero: ehrliche Fakten statt Zahlenleiste", () => {
const FAKTEN = [
["Falkenstein", "Server in Deutschland"],
["Live-Vorschau", "im Kundenportal"],
["ab 20 €", "Hosting im Monat"],
];
it("zeigt genau drei Fakten mit Wert und Beschriftung", () => {
zeige(<Hero />);
for (const [wert, beschriftung] of FAKTEN) {
expect(screen.getByText(wert)).toBeInTheDocument();
expect(screen.getByText(beschriftung)).toBeInTheDocument();
}
const raster = screen.getByText("Falkenstein").closest(".grid");
expect(raster?.children).toHaveLength(3);
expect(raster).toHaveClass("grid-cols-1", "md:grid-cols-3");
});
it("die alte Zahlenleiste ist weg: kein Projektzähler, kein »Am Markt«, keine 99,9 %, keine Flagge", () => {
const { container } = zeige(<Hero />);
expect(screen.queryByText("Am Markt")).toBeNull();
expect(screen.queryByText("Projekte")).toBeNull();
expect(screen.queryByText("Systemverfügbarkeit")).toBeNull();
expect(container).not.toHaveTextContent(/99[,.]9|🇩🇪|Serverstandort/);
});
it("hat nur noch den Potenzialanalyse-Button, keinen »Projekte ansehen«", () => {
zeige(<Hero />);
expect(screen.getAllByRole("button").map((b) => b.getAttribute("aria-label"))).toEqual([
"Kostenlose Potenzialanalyse sichern",
]);
});
it("lädt nichts vom Netz und braucht keinen QueryClient", () => {
const abruf = vi.fn();
vi.stubGlobal("fetch", abruf);
zeige(<Hero />);
expect(abruf).not.toHaveBeenCalled();
expect(gibtEs("src/lib/projects.ts")).toBe(false);
expect(vorkommen(/api\/projects|fetchProjects|FOUNDING_DATE/)).toEqual([]);
});
});
describe("B2 Projekte-Bereich ausgeblendet", () => {
it("ProjectShowcase ist gelöscht; nichts verweist auf #projects oder Unsplash", () => {
expect(gibtEs("src/components/ProjectShowcase.tsx")).toBe(false);
expect(vorkommen(/#projects|id="projects"|images\.unsplash\.com|ProjectShowcase/)).toEqual([]);
});
it("die Startseite hat keinen Projekte-Abschnitt und keinen Nav-Link dorthin", () => {
const { container } = seite("/");
expect(container.querySelector("#projects")).toBeNull();
expect(container.querySelector('a[href$="#projects"]')).toBeNull();
expect(screen.queryByRole("link", { name: "Projekte" })).toBeNull();
expect(screen.queryByText("Verbatim Labs")).toBeNull();
// die übrigen Nav-Punkte bleiben
for (const name of ["Über uns", "Leistungen", "Ablauf"]) {
expect(screen.getAllByRole("link", { name }).length).toBeGreaterThan(0);
}
});
});
describe("B3 Eine Telefonnummer überall", () => {
it("die alte Nummer 0170 4969375 kommt in keiner Quelldatei mehr vor", () => {
expect(vorkommen(/0170\s?4969375|491704969375/)).toEqual([]);
});
it("Kontaktseite zeigt 0176 23726355 mit passendem tel:-Link", () => {
seite("/kontakt");
const links = screen.getAllByRole("link", { name: TELEFON });
expect(links.length).toBeGreaterThan(0);
for (const link of links) expect(link).toHaveAttribute("href", TELEFON_HREF);
});
it("der Footer zeigt dieselbe Nummer", () => {
zeige(<Footer />);
expect(screen.getByRole("link", { name: TELEFON })).toHaveAttribute("href", TELEFON_HREF);
});
it("der Kontaktabschnitt der Startseite zeigt dieselbe Nummer", () => {
zeige(<KontaktAbschnitt />);
expect(screen.getByRole("link", { name: TELEFON })).toHaveAttribute("href", TELEFON_HREF);
});
});
describe("B4 Antwortzeit: spätestens am nächsten Werktag", () => {
const STARTSEITE =
"WEBklar digitalisiert KMU: Website, Prozesse und Systeme aus einer Hand. Kostenlose Potenzialanalyse – Antwort spätestens am nächsten Werktag.";
const KONTAKT =
"Beschreiben Sie Ihr Projekt – WEBklar meldet sich spätestens am nächsten Werktag mit den nächsten Schritten. Unverbindlich und kostenlos.";
it("kein 24-Stunden-Versprechen mehr in Quelltext und index.html (Tailwind-Klassen wie mt-24 h-48 zählen nicht)", () => {
expect(vorkommen(/(?<![\w-])24[\s-]*(Stunden|Std\b|h(?![\w-]))/i)).toEqual([]);
});
it("index.html: Beschreibung mit der neuen Antwortzeit", () => {
expect(lies("index.html")).toContain(`content="${STARTSEITE}"`);
});
it("Standard-Beschreibung des Meta-Hooks und der Startseite", () => {
const Probe = () => {
usePageMeta("Probe");
return null;
};
render(<Probe />);
expect(meta("description")).toBe(STARTSEITE);
cleanup();
seite("/");
expect(meta("description")).toBe(STARTSEITE);
});
it("Kontaktseite: Meta-Beschreibung und Einleitung", () => {
seite("/kontakt");
expect(meta("description")).toBe(KONTAKT);
expect(screen.getByText(/den größten Hebel haben\./)).toHaveTextContent(
"den größten Hebel haben. Antwort spätestens am nächsten Werktag, unverbindlich.",
);
});
it("Kontaktformular: Erfolgsmeldung mit der neuen Antwortzeit", async () => {
render(
<MemoryRouter initialEntries={["/kontakt"]} future={routerFuture}>
<AppRoutes />
<Toaster />
</MemoryRouter>,
);
fireEvent.change(screen.getByLabelText(/^Name/), { target: { value: "Erika Muster" } });
fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "erika@example.de" } });
fireEvent.change(screen.getByLabelText(/^Nachricht/), { target: { value: "Wir brauchen eine neue Website." } });
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/ }));
expect(await screen.findByText("Wir melden uns spätestens am nächsten Werktag bei Ihnen.")).toBeInTheDocument();
});
});
describe("B5 Erfolgsversprechen entschärft", () => {
it("die alten Versprechen kommen nirgends mehr vor", () => {
expect(vorkommen(/Planbare Neukunden|Sichtbar bei Google|Umsatz bringt/)).toEqual([]);
});
it("die toten Dateien AgitationSection und DifferentiationSection sind gelöscht", () => {
expect(gibtEs("src/components/AgitationSection.tsx")).toBe(false);
expect(gibtEs("src/components/DifferentiationSection.tsx")).toBe(false);
});
it("Values: Karte »Kundenportal« statt »Planbare Neukunden«", () => {
zeige(<Values />);
expect(screen.getByRole("heading", { level: 3, name: "Kundenportal" })).toBeInTheDocument();
expect(screen.getByText("Fortschritt, Chat und Rechnungen an einem Ort, im Browser oder als App.")).toBeInTheDocument();
expect(screen.getAllByRole("heading", { level: 3 })).toHaveLength(6);
});
it("SolutionSection: Ergebnis-Überschrift ohne Umsatzversprechen", () => {
zeige(<SolutionSection />);
expect(
screen.getByRole("heading", { level: 3, name: "Sie sehen, was läuft. Mehr Zeit für Kunden statt für Tool-Pflege:" }),
).toBeInTheDocument();
});
it("Services: Punkt 04 heißt »Hosting & Betreuung«", () => {
zeige(<Services />);
expect(screen.getByRole("heading", { level: 3, name: "Hosting & Betreuung" })).toBeInTheDocument();
expect(
screen.getByText("Hosting auf unserem Server in Deutschland, Pflege und Updates im Wartungs-Abo."),
).toBeInTheDocument();
expect(screen.queryByText(/SEO/)).toBeNull();
});
it("Process: Schritt 4 verweist auf das Wartungs-Abo", () => {
zeige(<Process />);
expect(screen.getByRole("heading", { level: 3, name: "Launch & Betreuung" })).toBeInTheDocument();
expect(
screen.getByText("Go-live mit Tests und Übergabe. Pflege und Updates danach im Wartungs-Abo."),
).toBeInTheDocument();
});
it("Über uns: Karte »Betreuung im Abo« statt »Langfristige Partnerschaft«", () => {
zeige(<About />);
expect(screen.getByRole("heading", { level: 3, name: "Betreuung im Abo" })).toBeInTheDocument();
expect(
screen.getByText("Nach dem Go-live kümmern wir uns im Wartungs-Abo um Updates und kleine Änderungen."),
).toBeInTheDocument();
expect(screen.queryByText("Langfristige Partnerschaft")).toBeNull();
});
});
describe("C1 Footer auf allen Unterseiten", () => {
const unterseiten = ["/kontakt", "/ueber-uns", "/agb", "/impressum", "/wartungs-abo", "/hosting-abo", "/gibt-es-nicht"];
it.each(unterseiten)("%s zeigt genau einen Footer mit Impressum und Datenschutz", (pfad) => {
seite(pfad);
const footer = screen.getByRole("contentinfo");
expect(within(footer).getByRole("link", { name: "Impressum" })).toHaveAttribute("href", "/impressum");
expect(within(footer).getByRole("link", { name: "Datenschutz" })).toHaveAttribute("href", DATENSCHUTZ);
});
it("die Startseite hat weiterhin genau einen Footer", () => {
seite("/");
expect(screen.getAllByRole("contentinfo")).toHaveLength(1);
});
it("der Footer steht hinter dem Seiteninhalt", () => {
seite("/impressum");
const inhalt = screen.getByRole("main");
const footer = screen.getByRole("contentinfo");
expect(inhalt.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});
// ── Kontrast-Hilfen (WCAG 2.x), gelesen werden die Farb-Token aus src/index.css ──
type RGB = [number, number, number];
const rgbAusHsl = (h: number, s: number, l: number): RGB => {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return [f(0) * 255, f(8) * 255, f(4) * 255];
};
const leuchtdichte = (rgb: RGB) => {
const [r, g, b] = rgb.map((v) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const kontrast = (a: RGB, b: RGB) => {
const [hell, dunkel] = [leuchtdichte(a), leuchtdichte(b)].sort((x, y) => y - x);
return (hell + 0.05) / (dunkel + 0.05);
};
/** "#120F17" oder "hsl(0 0% 6%)" als RGB. */
const farbe = (wert: string): RGB => {
const hex = wert.trim().match(/^#([0-9a-f]{6})$/i);
if (hex) return [0, 2, 4].map((i) => parseInt(hex[1].slice(i, i + 2), 16)) as RGB;
const hsl = wert.trim().match(/^hsl\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%\s*\)$/);
if (hsl) return rgbAusHsl(Number(hsl[1]), Number(hsl[2]), Number(hsl[3]));
throw new Error(`Unbekanntes Farbformat: ${wert}`);
};
type Thema = "hell" | "dunkel";
/** Farb-Token ("--foreground: 0 0% 9%") eines Themes aus index.css. */
const tokenFarbe = (thema: Thema, name: string): RGB => {
const css = lies("src/index.css");
const block = css.match(thema === "dunkel" ? /\.dark\s*\{([^}]*)\}/ : /:root\s*\{([^}]*)\}/)?.[1] ?? "";
const wert = block.match(new RegExp(`--${name}:\\s*([\\d.]+)\\s+([\\d.]+)%\\s+([\\d.]+)%`));
if (!wert) throw new Error(`Token --${name} fehlt im Theme ${thema}`);
return rgbAusHsl(Number(wert[1]), Number(wert[2]), Number(wert[3]));
};
const THEMEN: Thema[] = ["hell", "dunkel"];
describe("C5 404-Seite", () => {
it("zeigt Überschrift, Erklärung und Link zur Startseite auf Deutsch", () => {
seite("/gibt-es-nicht");
expect(screen.getByRole("heading", { level: 1, name: "Seite nicht gefunden" })).toBeInTheDocument();
expect(screen.getByText("Diese Seite gibt es nicht oder nicht mehr.")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Zur Startseite" })).toHaveAttribute("href", "/");
expect(screen.queryByText(/Oops|Return to Home|Page not found/)).toBeNull();
});
it("nutzt die Theme-Farben statt eines festen grauen Hintergrunds", () => {
const { container } = seite("/gibt-es-nicht");
expect(container.querySelector(".bg-background.text-foreground")).not.toBeNull();
expect(container.querySelector(".bg-muted")).toBeNull();
expect(screen.getByRole("link", { name: "Zur Startseite" })).toHaveClass("text-foreground");
});
it("Textfarben der Seite erreichen auf dem Hintergrund in beiden Themes mindestens 4,5:1", () => {
for (const thema of THEMEN) {
for (const name of ["foreground", "muted-foreground"]) {
expect(kontrast(tokenFarbe(thema, name), tokenFarbe(thema, "background"))).toBeGreaterThanOrEqual(4.5);
}
}
});
});
describe("C4 Schreibweise WEBklar", () => {
it("kein sichtbares »Webklar« mehr im Quelltext (URLs, Mailadressen und Kleinschreibung ausgenommen)", () => {
expect(vorkommen(/(?<![\w./@:-])Webklar(?![\w@.-])/)).toEqual([]);
});
it.each(["/", "/kontakt", "/ueber-uns", "/agb", "/impressum", "/wartungs-abo", "/hosting-abo", "/gibt-es-nicht"])(
"%s: die Marke steht als WEBklar da",
(pfad) => {
seite(pfad);
expect(screen.queryAllByText(/Webklar/)).toHaveLength(0);
expect(screen.getAllByText("WEBklar").length).toBeGreaterThan(0);
},
);
});
describe("C2 Datenschutzhinweis am Kontaktformular", () => {
const HINWEIS =
"Wir verwenden Ihre Angaben nur, um Ihre Anfrage zu beantworten. Mehr dazu in unserer Datenschutzerklärung.";
it("steht unter dem Senden-Button und verlinkt die Datenschutzerklärung", () => {
seite("/kontakt");
const senden = screen.getByRole("button", { name: /Nachricht senden/ });
const formular = senden.closest("form") as HTMLFormElement;
const hinweis = within(formular).getByText((_, el) => el?.tagName === "P" && el.textContent === HINWEIS);
expect(senden.compareDocumentPosition(hinweis) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(within(hinweis).getByRole("link", { name: "Datenschutzerklärung" })).toHaveAttribute("href", DATENSCHUTZ);
});
it("ist kleiner, gedämpfter Text mit gut lesbarem Link (Farb-Token siehe C5)", () => {
seite("/kontakt");
const hinweis = screen.getByRole("link", { name: "Datenschutzerklärung" }).closest("p") as HTMLElement;
expect(hinweis).toHaveClass("text-muted-foreground");
expect(hinweis.className).toMatch(/text-(xs|sm)/);
expect(screen.getByRole("link", { name: "Datenschutzerklärung" })).toHaveClass("text-foreground", "underline");
});
});
describe("C3 Seitentitel und Beschreibung der Unterseiten", () => {
// Beschreibungen nur aus Fakten, die auf der jeweiligen Seite stehen (Preis, Mindestlaufzeit, Leistungen).
const FAELLE: { pfad: string; titel: string; beschreibung: string; auf_der_seite: string[] }[] = [
{
pfad: "/wartungs-abo",
titel: "Wartungs-Abo | WEBklar",
beschreibung:
"Betrieb, Pflege und Betreuung Ihrer Website: 50,00 € pro Monat, 12 Monate Mindestlaufzeit. Mit Domain, Postfächern und 1 Stunde Arbeitszeit pro Monat.",
auf_der_seite: [
"Betrieb, Pflege und Betreuung Ihrer Website",
"50,00 € pro Monat",
"12 Monate Mindestlaufzeit",
"1 Domain inklusive",
"Postfächer",
"1 Stunde Arbeitszeit pro Monat",
],
},
{
pfad: "/hosting-abo",
titel: "Hosting-Abo | WEBklar",
beschreibung:
"Ihre statische Website auf unserem Server in Deutschland: 20,00 € pro Monat, 12 Monate Mindestlaufzeit. Inklusive Domain und E-Mail-Postfach.",
auf_der_seite: [
"Ihre statische Website auf unserem Server in Deutschland: 20,00 € pro Monat, 12 Monate Mindestlaufzeit.",
"1 Domain inklusive",
"1 E-Mail-Postfach",
],
},
{
pfad: "/agb",
titel: "AGB | WEBklar",
beschreibung: "Allgemeine Geschäftsbedingungen der WEBklar GbR.",
auf_der_seite: [],
},
{
pfad: "/impressum",
titel: "Impressum | WEBklar",
beschreibung: "Impressum der WEBklar GbR, Winnweiler.",
auf_der_seite: ["WEBklar GbR", "67722 Winnweiler"],
},
];
it.each(FAELLE)("$pfad setzt Titel und Beschreibung", ({ pfad, titel, beschreibung }) => {
seite(pfad);
expect(document.title).toBe(titel);
expect(meta("description")).toBe(beschreibung);
expect(beschreibung.length).toBeLessThanOrEqual(160);
});
it.each(FAELLE)("$pfad: jede Aussage der Beschreibung steht auch auf der Seite", ({ pfad, auf_der_seite }) => {
seite(pfad);
const text = (document.body.textContent ?? "").replace(/\s+/g, " ");
for (const fakt of auf_der_seite) expect(text).toContain(fakt);
});
it("die 404-Seite setzt den Titel »Seite nicht gefunden«", () => {
seite("/gibt-es-nicht");
expect(document.title).toBe("Seite nicht gefunden | WEBklar");
});
});
describe("A5 Anker landen nicht unter dem fixen Header", () => {
it("section[id] hat ein scroll-margin-top von etwa 6rem", () => {
expect(lies("src/index.css")).toMatch(/section\[id\]\s*\{[^}]*scroll-margin-top:\s*6rem/);
});
});
describe("A2 Hintergrundbilder nur im dunklen Modus", () => {
const BILDER: { name: string; ui: React.ReactElement; datei: string }[] = [
{ name: "ProblemSection", ui: <ProblemSection />, datei: "problem_blitz.jpg" },
{ name: "SolutionSection", ui: <SolutionSection />, datei: "loesung.jpg" },
{ name: "Values", ui: <Values />, datei: "backgroud_effect.webp" },
];
it.each(BILDER)("$name: $datei ist im hellen Modus ausgeblendet und im dunklen sichtbar", ({ ui, datei }) => {
const { container } = zeige(ui);
const bild = container.querySelector<HTMLElement>(`[style*="${datei}"]`);
expect(bild).not.toBeNull();
expect(bild).toHaveClass("hidden", "dark:block");
});
});
describe("A3 Navigation: Theme-Schalter, Kontakt und Logo bekommen echte Mausklicks", () => {
it("der absolut gelegte Link-Container fängt keine Mausklicks ab, die Links selbst schon", () => {
zeige(<Header />);
const link = screen.getAllByRole("link", { name: "Leistungen" })[0];
const container = link.parentElement as HTMLElement;
// bleibt mittig über die ganze Leiste gelegt (Optik unverändert) …
expect(container).toHaveClass("absolute", "inset-0", "justify-center");
// … lässt Klicks aber zum Theme-Schalter, zum Kontakt-Button und zum Logo durch
expect(container).toHaveClass("pointer-events-none");
for (const name of ["Über uns", "Leistungen", "Ablauf"]) {
expect(screen.getAllByRole("link", { name })[0]).toHaveClass("pointer-events-auto");
}
});
});
describe("A4 Glow-Überschrift (Fix A)", () => {
const BOX = { left: 100, top: 400, right: 1300, bottom: 624, width: 1200, height: 224, x: 100, y: 400 };
/** Browser-Umgebung für die Überschrift: Maus-Gerät, feste Box, manuell abgespielte Animation-Frames. */
const umgebung = (feinerZeiger = true) => {
const frames: FrameRequestCallback[] = [];
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => frames.push(cb));
vi.spyOn(window, "matchMedia").mockImplementation(
(query) =>
({
matches: feinerZeiger,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList,
);
vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({ ...BOX, toJSON: () => ({}) });
// jsdom hat weder getScreenCTM noch DOMPoint: Abbildung Bildschirm -> viewBox (300 x 60) mit Faktor 4
Object.defineProperty(SVGSVGElement.prototype, "getScreenCTM", {
configurable: true,
value: () => ({ inverse: () => ({}) }),
});
vi.stubGlobal(
"DOMPoint",
class {
constructor(
public x: number,
public y: number,
) {}
matrixTransform() {
return { x: (this.x - BOX.left) / 4, y: (this.y - BOX.top) / 4 };
}
},
);
return () => act(() => frames.splice(0).forEach((cb) => cb(0)));
};
afterEach(() => {
delete (SVGSVGElement.prototype as { getScreenCTM?: unknown }).getScreenCTM;
});
/** jsdom kennt keine PointerEvents: MouseEvent mit pointerType reicht dem Fix. */
const zeiger = (x: number, y: number, pointerType = "mouse") => {
const e = new MouseEvent("pointermove", { clientX: x, clientY: y, bubbles: true });
Object.defineProperty(e, "pointerType", { value: pointerType });
window.dispatchEvent(e);
};
const glow = () => document.querySelector("svg text[mask]") as SVGTextElement;
const mitte = () => document.querySelector("radialGradient") as SVGRadialGradientElement;
it("wird als ein Bild vorgelesen, die Textknoten sind für Screenreader versteckt", () => {
umgebung();
render(<TextHoverEffect text="Leistungen" />);
expect(screen.getByRole("img", { name: "Leistungen" })).toBeInTheDocument();
const texte = [...document.querySelectorAll("svg text")];
expect(texte).toHaveLength(3);
for (const t of texte) expect(t).toHaveAttribute("aria-hidden", "true");
});
it("weckt den Glow spätestens 180 px vor der Box (Aufwachradius 260 px) und bildet den Zeiger korrekt auf die viewBox ab", () => {
const abspielen = umgebung();
render(<TextHoverEffect text="Leistungen" />);
expect(glow().style.opacity).toBe("0");
expect(glow().style.transition).toContain("opacity");
act(() => zeiger(700, BOX.top - 180));
abspielen();
expect(glow().style.opacity).toBe("1");
expect(mitte().getAttribute("cx")).toBe("150");
expect(mitte().getAttribute("cy")).toBe("-45");
act(() => zeiger(700, BOX.top - 400));
abspielen();
expect(glow().style.opacity).toBe("0");
});
it("Touch lässt den Glow nicht kleben, und Verlassen der Seite schaltet ihn aus", () => {
const abspielen = umgebung();
render(<TextHoverEffect text="Leistungen" />);
act(() => zeiger(700, BOX.top + 100, "touch"));
abspielen();
expect(glow().style.opacity).toBe("0");
act(() => zeiger(700, BOX.top + 100));
abspielen();
expect(glow().style.opacity).toBe("1");
act(() => {
document.documentElement.dispatchEvent(new MouseEvent("mouseleave"));
});
expect(glow().style.opacity).toBe("0");
});
it("auf Geräten ohne feinen Zeiger (Touch) werden gar keine Zeiger-Listener angehängt", () => {
const abspielen = umgebung(false);
const hinzu = vi.spyOn(window, "addEventListener");
render(<TextHoverEffect text="Leistungen" />);
expect(hinzu.mock.calls.filter(([typ]) => typ === "pointermove")).toHaveLength(0);
act(() => zeiger(700, BOX.top + 100));
abspielen();
expect(glow().style.opacity).toBe("0");
});
it("räumt seine Listener beim Entfernen wieder auf", () => {
umgebung();
const weg = vi.spyOn(window, "removeEventListener");
const { unmount } = render(<TextHoverEffect text="Leistungen" />);
unmount();
expect(weg.mock.calls.map(([typ]) => typ)).toEqual(expect.arrayContaining(["pointermove", "scroll"]));
});
});
describe("A1 Karten (BorderGlow) im hellen Modus lesbar", () => {
// Dunkel bleibt exakt wie vorher; hell bekommt eine helle Kartenfarbe.
const KARTEN: { name: string; ui: React.ReactElement; anzahl: number; dunkel: string }[] = [
{ name: "Values (Was Sie bekommen)", ui: <Values />, anzahl: 6, dunkel: "hsl(0 0% 6%)" },
{ name: "Kontaktabschnitt (Der nächste Schritt)", ui: <KontaktAbschnitt />, anzahl: 1, dunkel: "#120F17" },
{ name: "Über uns (Der Unterschied)", ui: <About />, anzahl: 3, dunkel: "hsl(0 0% 4%)" },
];
it.each(KARTEN)("$name: dunkle Kartenfarbe unverändert, helle Variante vorhanden", ({ ui, anzahl, dunkel }) => {
const { container } = zeige(ui);
const karten = [...container.querySelectorAll<HTMLElement>(".border-glow-card")];
expect(karten).toHaveLength(anzahl);
for (const karte of karten) {
expect(karte.style.getPropertyValue("--card-bg-dark")).toBe(dunkel);
const hell = karte.style.getPropertyValue("--card-bg-light");
expect(hell).not.toBe("");
expect(hell).not.toBe(dunkel);
expect(leuchtdichte(farbe(hell))).toBeGreaterThan(0.85);
}
});
it.each(KARTEN)("$name: Text und Icons erreichen in beiden Themes mindestens 4,5:1", ({ ui }) => {
const { container } = zeige(ui);
const karte = container.querySelector<HTMLElement>(".border-glow-card") as HTMLElement;
const hintergrund = { hell: farbe(karte.style.getPropertyValue("--card-bg-light")), dunkel: farbe(karte.style.getPropertyValue("--card-bg-dark")) };
for (const thema of THEMEN) {
// text-foreground (Titel, Hover-Zustand der Icons) und text-muted-foreground (Fließtext, Icons).
// Der blaue CTA-Button im Kontaktabschnitt hat eigene, themeunabhängige Farben und wird im nächsten Test geprüft.
for (const name of ["foreground", "muted-foreground"]) {
expect(kontrast(tokenFarbe(thema, name), hintergrund[thema === "hell" ? "hell" : "dunkel"])).toBeGreaterThanOrEqual(4.5);
}
}
});
it.each(KARTEN)("$name: Buttons auf den Karten (Text weiß auf eigener Farbe) erreichen in Ruhe und beim Hovern mindestens 4,5:1", ({ ui, name }) => {
const { container } = zeige(ui);
const buttons = [...container.querySelectorAll<HTMLElement>(".border-glow-card button")];
if (name.startsWith("Kontaktabschnitt")) expect(buttons).toHaveLength(1);
const arbitraerFarbe = (klassen: string[], praefix: string) =>
klassen.filter((k) => k.startsWith(`${praefix}bg-[`)).map((k) => k.slice(praefix.length + 4, -1));
for (const button of buttons) {
const klassen = [...button.classList];
// Weiß auf der eigenen Farbe; die Farbe ist themeunabhängig, gilt also in hell und dunkel gleich.
expect(klassen).toContain("text-white");
const ruhe = arbitraerFarbe(klassen, "");
const hover = arbitraerFarbe(klassen, "hover:");
// twMerge muss die Standardfarbe des Buttons ersetzen, nicht ergänzen
expect(ruhe).toHaveLength(1);
expect(hover).toHaveLength(1);
for (const hintergrundFarbe of [ruhe[0], hover[0]]) {
expect(kontrast([255, 255, 255], farbe(hintergrundFarbe))).toBeGreaterThanOrEqual(4.5);
}
}
});
it("BorderGlow wählt die Kartenfarbe per CSS nach Theme und zeigt im hellen Modus einen sichtbaren Rand", () => {
const css = lies("src/components/BorderGlow.css");
expect(css).toMatch(/--card-bg:\s*var\(--card-bg-dark/);
expect(css).toMatch(/html:not\(\.dark\)\s+\.border-glow-card\s*\{[^}]*--card-bg:\s*var\(--card-bg-light/);
expect(css).toMatch(/html:not\(\.dark\)\s+\.border-glow-card\s*\{[^}]*border-color:/);
});
it("der Kantenschein der Karten wird im hellen Modus normal statt mit plus-lighter gemischt (sonst unsichtbar auf Weiß)", () => {
const css = lies("src/components/BorderGlow.css");
expect(css).toMatch(/html:not\(\.dark\)\s+\.border-glow-card\s*>\s*\.edge-light\s*\{[^}]*mix-blend-mode:\s*normal/);
// dunkel bleibt wie vorher
expect(css).toMatch(/\.border-glow-card\s*>\s*\.edge-light\s*\{[^}]*mix-blend-mode:\s*plus-lighter/);
});
});
describe("Nacharbeit nach der Prüfung", () => {
it("99,9 steht nirgends mehr im Quelltext", () => {
expect(vorkommen(/99[,.]9/)).toEqual([]);
});
it("ScrollToTop springt bei einem Anker zum Abschnitt (Footer-Link »Leistungen« von einer Unterseite)", async () => {
const springen = vi.fn();
Element.prototype.scrollIntoView = springen;
render(
<MemoryRouter initialEntries={["/#services"]} future={routerFuture}>
<ScrollToTop />
<section id="services" />
</MemoryRouter>,
);
await waitFor(() => expect(springen).toHaveBeenCalledTimes(1));
expect(springen.mock.instances[0]).toBe(document.getElementById("services"));
});
it("ScrollToTop scrollt ohne Anker wie bisher nach oben", () => {
const nachOben = vi.fn();
vi.stubGlobal("scrollTo", nachOben);
render(
<MemoryRouter initialEntries={["/kontakt"]} future={routerFuture}>
<ScrollToTop />
</MemoryRouter>,
);
expect(nachOben).toHaveBeenCalledWith(0, 0);
});
it("die 404-Seite schreibt nichts in die Konsole", () => {
const fehler = vi.spyOn(console, "error");
seite("/gibt-es-nicht");
expect(fehler).not.toHaveBeenCalled();
});
it("der Datenschutz-Link am Formular öffnet einen neuen Tab, damit eine getippte Nachricht nicht verloren geht", () => {
seite("/kontakt");
const link = screen.getByRole("link", { name: "Datenschutzerklärung" });
expect(link).toHaveAttribute("target", "_blank");
expect(link.getAttribute("rel")).toMatch(/noopener/);
});
it("der Markenname steht nicht mehr mit enger Laufweite da (B und k berührten sich)", () => {
expect(vorkommen(/tracking-tight[^"]*">\s*WEBklar\s*</)).toEqual([]);
});
});