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
rootandClaude Opus 5.5 72f278b478 AGB § 9: Zeiterfassung in 15-Minuten-Schritten und Unterstützung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:27:33 +00:00
rootandClaude Opus 5.5 034892c899 AGB § 11 Haftung: wirksame Regel nach dem Muster der Abo-Bedingungen
Die alte Fassung schloss die Haftung für Umsatzausfälle, Datenverlust,
Systemausfälle und externe Dienste aus, haftete nur bei Vorsatz und
grober Fahrlässigkeit und deckelte auf den Auftragswert. Gegenüber
Verbrauchern unwirksam (§ 309 Nr. 7 a/b BGB), bei wesentlichen
Vertragspflichten auch gegenüber Unternehmern (§ 307 BGB).

Neu, wie § 13 der Abo-Bedingungen (v4-Entwurf): unbeschränkt bei
Vorsatz, grober Fahrlässigkeit, Leben/Körper/Gesundheit, nach dem
Produkthaftungsgesetz und bei Garantie; bei leichter Fahrlässigkeit
nur für wesentliche Vertragspflichten und nur für den vertragstypischen,
vorhersehbaren Schaden; Datenverlust außer in den Fällen von Absatz 1
nur in Höhe des Wiederherstellungsaufwands aus den Kopien, die der
Kunde aufbewahrt; gilt auch zugunsten von Mitarbeitern und Beauftragten.

src/test/agb-haftung.test.tsx hält die Kernsätze und die Reihenfolge
der Absätze fest. vitest.config.ts lädt @vitejs/plugin-react statt des
nicht installierten @vitejs/plugin-react-swc (vitest startete auf main
nicht); dieselbe Änderung wie auf feature/hosting-abo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:26:43 +00:00
rootandClaude Opus 5.5 3c9a879d4a Hosting-Abo: Preis 15 € -> 20 € pro Monat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:29:29 +00:00
36 changed files with 1141 additions and 501 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;
}
+16 -12
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="/">
@@ -192,6 +195,13 @@ const AGB = () => {
</h2>
<ol className="list-decimal list-inside space-y-2 text-muted-foreground leading-relaxed [&>li]:pl-2">
<li>Die Vergütung ergibt sich aus dem Angebot.</li>
<li>
Wird nach Aufwand abgerechnet, erfasst WEBklar die Arbeitszeit je Arbeitsschritt in Schritten von
15 Minuten; angefangene 15 Minuten werden aufgerundet. Wirkt eine weitere Person unterstützend mit,
rechnet WEBklar für sie die Hälfte der Arbeitszeit des Arbeitsschritts an, aufgerundet auf volle
15 Minuten; bei mehreren unterstützenden Personen gilt das für jede. Jeder Arbeitsschritt und jede
Unterstützung ist im Kundenportal einsehbar.
</li>
<li>Projektleistungen sind nach Vereinbarung fällig.</li>
<li>Für die Abos gelten die Vertragsbedingungen für die WEBklar-Abos.</li>
</ol>
@@ -222,16 +232,10 @@ const AGB = () => {
11. Haftung
</h2>
<ol className="list-decimal list-inside space-y-2 text-muted-foreground leading-relaxed [&>li]:pl-2">
<li>WEBklar haftet nur bei Vorsatz und grober Fahrlässigkeit.</li>
<li>Keine Haftung für:
<ul className="list-disc list-inside mt-2 space-y-1 pl-2">
<li>Umsatzausfälle</li>
<li>Datenverlust</li>
<li>Systemausfälle</li>
<li>externe Dienste</li>
</ul>
</li>
<li>Die Haftung ist der Höhe nach auf den Auftragswert begrenzt.</li>
<li>WEBklar haftet unbeschränkt bei Vorsatz und grober Fahrlässigkeit, bei Verletzung von Leben, Körper oder Gesundheit, nach dem Produkthaftungsgesetz und soweit WEBklar eine Garantie übernommen hat.</li>
<li>Bei leicht fahrlässiger Verletzung einer wesentlichen Vertragspflicht haftet WEBklar nur für den vertragstypischen, vorhersehbaren Schaden. Wesentlich sind Pflichten, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags überhaupt erst ermöglicht und auf deren Einhaltung der Kunde regelmäßig vertrauen darf. Im Übrigen ist die Haftung für leichte Fahrlässigkeit ausgeschlossen.</li>
<li>Der Kunde bewahrt Kopien der Inhalte und Daten auf, die er WEBklar übergibt oder selbst einpflegt. Für den Verlust solcher Inhalte und Daten haftet WEBklar außer in den Fällen von Absatz 1 nur in Höhe des Aufwands, der für ihre Wiederherstellung nötig gewesen wäre, wenn der Kunde diese Kopien aufbewahrt hätte.</li>
<li>Diese Regeln gelten auch zugunsten der Mitarbeiter und Beauftragten von WEBklar.</li>
</ol>
</section>
{contractDivider}
+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>
+9 -3
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="/">
@@ -58,7 +64,7 @@ const HostingAbo = () => {
Hosting-Abo
</h1>
<p className="text-muted-foreground text-lg">
Ihre statische Website auf unserem Server in Deutschland: 15,00 € pro Monat, 12 Monate Mindestlaufzeit.
Ihre statische Website auf unserem Server in Deutschland: 20,00 € pro Monat, 12 Monate Mindestlaufzeit.
</p>
<p className="text-muted-foreground text-sm mt-1">Alle Preise sind Endpreise, gemäß § 19 UStG ohne Umsatzsteuer.</p>
</div>
@@ -103,7 +109,7 @@ const HostingAbo = () => {
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">Preis und Laufzeit</h2>
<ul className="text-muted-foreground leading-relaxed space-y-1 list-disc pl-5">
<li>15,00 € pro Monat, monatlich im Voraus. {STEUER}.</li>
<li>20,00 € pro Monat, monatlich im Voraus. {STEUER}.</li>
<li>Mindestlaufzeit 12 Monate, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach jederzeit zum Ende des Abrechnungsmonats. Bei Geschäftsaufgabe Sonderkündigung mit einem Monat Frist.</li>
<li>Zahlung per Kreditkarte oder SEPA-Lastschrift über unseren Zahlungsdienstleister Stripe.</li>
<li>Ein Stundenkontingent ist nicht enthalten. Änderungen an Ihrer Website und andere Aufträge führen wir nur nach Ihrer Freigabe aus: 100 € pro Stunde ({STEUER}), abgerechnet in 15-Minuten-Schritten. Vorher nennen wir Ihnen den geschätzten Aufwand.</li>
+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>
);
+8 -2
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="/">
@@ -106,7 +112,7 @@ const WartungsAbo = () => {
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">Hosting-Abo für statische Websites</h2>
<p className="text-muted-foreground leading-relaxed">
Sie haben eine statische Website und brauchen weder Pflege noch Arbeitszeit? Dann genügt vielleicht unser{" "}
<Link to="/hosting-abo" className={linkClass}>Hosting-Abo</Link> für 15,00 € pro Monat ({STEUER}). Vom
<Link to="/hosting-abo" className={linkClass}>Hosting-Abo</Link> für 20,00 € pro Monat ({STEUER}). Vom
Hosting- ins Wartungs-Abo wechseln Sie im Kundenportal, sobald das Hosting-Abo begonnen hat, solange es nicht
gekündigt ist und keine Zahlung offen ist; mit dem Wechsel beginnt eine neue Mindestlaufzeit von 12 Monaten.
Ein Wechsel vom Wartungs- ins Hosting-Abo ist auf Anfrage nach Ablauf der Mindestlaufzeit zum Ende eines
+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>
);
+1 -1
View File
@@ -25,7 +25,7 @@ describe("Abo-Seiten", () => {
<HostingAbo />
</MemoryRouter>,
);
expect(screen.getAllByText(/15,00 € pro Monat/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/20,00 € pro Monat/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/1 E-Mail-Postfach/).length).toBeGreaterThan(0);
expect(screen.getByRole("link", { name: "Verträge hier kündigen" })).toHaveAttribute(
"href",
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import AGB from "@/pages/AGB";
// Dieselben Future-Flags wie der BrowserRouter in App.tsx, sonst warnt React Router in der Testausgabe.
const routerFuture = { v7_startTransition: true, v7_relativeSplatPath: true };
/** Rendert die AGB und liefert § 11 mit seinen Absätzen in der angezeigten Reihenfolge. */
const haftung = () => {
render(
<MemoryRouter future={routerFuture}>
<AGB />
</MemoryRouter>,
);
const section = screen.getByRole("heading", { name: "11. Haftung" }).closest("section");
if (!section) throw new Error("Abschnitt 11. Haftung fehlt");
return { section, absaetze: within(section).getAllByRole("listitem") };
};
describe("AGB § 11 Haftung", () => {
it("hat genau vier Absätze, damit der Verweis auf Absatz 1 stimmt", () => {
expect(haftung().absaetze).toHaveLength(4);
});
it("Absatz 1: unbeschränkte Haftung für Vorsatz, grobe Fahrlässigkeit, Leben/Körper/Gesundheit, ProdHaftG, Garantie", () => {
// § 309 Nr. 7 a und b BGB, § 14 ProdHaftG, § 276 Abs. 1 BGB (Garantie).
expect(haftung().absaetze[0]).toHaveTextContent(
"WEBklar haftet unbeschränkt bei Vorsatz und grober Fahrlässigkeit, bei Verletzung von Leben, Körper oder Gesundheit, nach dem Produkthaftungsgesetz und soweit WEBklar eine Garantie übernommen hat.",
);
});
it("Absatz 2: leichte Fahrlässigkeit nur bei wesentlichen Vertragspflichten und nur für den vorhersehbaren Schaden", () => {
// § 307 Abs. 2 Nr. 2 BGB: Kardinalpflichten, mit der vom BGH geforderten Erklärung des Begriffs.
const absatz = haftung().absaetze[1];
expect(absatz).toHaveTextContent(
"Bei leicht fahrlässiger Verletzung einer wesentlichen Vertragspflicht haftet WEBklar nur für den vertragstypischen, vorhersehbaren Schaden.",
);
expect(absatz).toHaveTextContent(
"Wesentlich sind Pflichten, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags überhaupt erst ermöglicht und auf deren Einhaltung der Kunde regelmäßig vertrauen darf.",
);
expect(absatz).toHaveTextContent("Im Übrigen ist die Haftung für leichte Fahrlässigkeit ausgeschlossen.");
});
it("Absatz 3: Datenverlust nur in Höhe des Wiederherstellungsaufwands, nie in den Fällen von Absatz 1", () => {
const absatz = haftung().absaetze[2];
expect(absatz).toHaveTextContent(
"Der Kunde bewahrt Kopien der Inhalte und Daten auf, die er WEBklar übergibt oder selbst einpflegt.",
);
expect(absatz).toHaveTextContent(
"Für den Verlust solcher Inhalte und Daten haftet WEBklar außer in den Fällen von Absatz 1 nur in Höhe des Aufwands, der für ihre Wiederherstellung nötig gewesen wäre, wenn der Kunde diese Kopien aufbewahrt hätte.",
);
});
it("Absatz 4: gilt auch zugunsten der Mitarbeiter und Beauftragten", () => {
expect(haftung().absaetze[3]).toHaveTextContent(
"Diese Regeln gelten auch zugunsten der Mitarbeiter und Beauftragten von WEBklar.",
);
});
it("enthält die unwirksamen Ausschlüsse und die Höchstgrenze der alten Fassung nicht mehr", () => {
const { section } = haftung();
for (const alt of [
/nur bei Vorsatz/,
/keine Haftung/i,
/Umsatzausfälle/,
/Systemausfälle/,
/externe Dienste/,
/Auftragswert/,
]) {
expect(section).not.toHaveTextContent(alt);
}
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import AGB from "@/pages/AGB";
// Dieselben Future-Flags wie der BrowserRouter in App.tsx, sonst warnt React Router in der Testausgabe.
const routerFuture = { v7_startTransition: true, v7_relativeSplatPath: true };
/** Rendert die AGB und liefert die Absätze von § 9 in der angezeigten Reihenfolge. */
const verguetung = () => {
render(
<MemoryRouter future={routerFuture}>
<AGB />
</MemoryRouter>,
);
const section = screen.getByRole("heading", { name: "9. Vergütung und Zahlung" }).closest("section");
if (!section) throw new Error("Abschnitt 9. Vergütung und Zahlung fehlt");
return within(section).getAllByRole("listitem");
};
describe("AGB § 9 Vergütung und Zahlung", () => {
it("hat vier Absätze", () => {
expect(verguetung()).toHaveLength(4);
});
it("Absatz 1 bleibt: Vergütung aus dem Angebot", () => {
expect(verguetung()[0]).toHaveTextContent("Die Vergütung ergibt sich aus dem Angebot.");
});
it("Absatz 2: 15-Minuten-Schritte und Unterstützung bei Abrechnung nach Aufwand", () => {
expect(verguetung()[1]).toHaveTextContent(
"Wird nach Aufwand abgerechnet, erfasst WEBklar die Arbeitszeit je Arbeitsschritt in Schritten von 15 Minuten; angefangene 15 Minuten werden aufgerundet. Wirkt eine weitere Person unterstützend mit, rechnet WEBklar für sie die Hälfte der Arbeitszeit des Arbeitsschritts an, aufgerundet auf volle 15 Minuten; bei mehreren unterstützenden Personen gilt das für jede. Jeder Arbeitsschritt und jede Unterstützung ist im Kundenportal einsehbar.",
);
});
it("Absatz 3 und 4: Fälligkeit und Abos wie bisher", () => {
const absaetze = verguetung();
expect(absaetze[2]).toHaveTextContent("Projektleistungen sind nach Vereinbarung fällig.");
expect(absaetze[3]).toHaveTextContent("Für die Abos gelten die Vertragsbedingungen für die WEBklar-Abos.");
});
});
+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([]);
});
});