Compare commits
21
Commits
72f278b478
...
80925c2a43
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80925c2a43 | ||
|
|
b46527e65d | ||
|
|
d78338a969 | ||
|
|
e6d603cd0c | ||
|
|
003f9e67aa | ||
|
|
897d594dde | ||
|
|
194301c6af | ||
|
|
0d12bbc06a | ||
|
|
b2e17d96c2 | ||
|
|
2e65829e15 | ||
|
|
77672b55d3 | ||
|
|
f0ae8c0be2 | ||
|
|
80099cbdb4 | ||
|
|
8d3424967c | ||
|
|
900d3a9ab5 | ||
|
|
c75befe8e5 | ||
|
|
f337102c7a | ||
|
|
b897ae834f | ||
|
|
c246b9811f | ||
|
|
a27b1fe255 | ||
|
|
79c3551934 |
No files matched your search
@@ -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
@@ -28,7 +28,7 @@
|
||||
<title>WEBklar – Webagentur für KMU | Digitalisierung & 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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)",
|
||||
}}
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Live-Projektzahl für die Statistik-Zeile im Hero.
|
||||
*
|
||||
* Quelle: GET /api/projects (contact-api-Container) – zählt serverseitig die
|
||||
* Repositories, die aktuell in Gitea (git.webklar.com) liegen, angereichert um
|
||||
* die Hosting-Metadaten aus Appwrite. Der Gitea-Token bleibt dadurch auf dem
|
||||
* Server. Die Antwort enthält mehr Felder als hier genutzt werden; für die
|
||||
* Homepage ist nur `total` relevant.
|
||||
*/
|
||||
|
||||
export type ProjectsSnapshot = {
|
||||
/** Zeitpunkt, zu dem der Server-Snapshot erzeugt wurde (ISO) */
|
||||
generatedAt: string;
|
||||
/** Anzahl der Repositories, die aktuell in Gitea existieren */
|
||||
total: number;
|
||||
counts: Record<string, number>;
|
||||
source: string;
|
||||
};
|
||||
|
||||
const API_URL = import.meta.env.VITE_PROJECTS_API ?? "/api/projects";
|
||||
|
||||
export async function fetchProjects(signal?: AbortSignal): Promise<ProjectsSnapshot> {
|
||||
const res = await fetch(API_URL, { signal, headers: { Accept: "application/json" } });
|
||||
if (!res.ok) throw new Error(`Projektzahl konnte nicht geladen werden (HTTP ${res.status})`);
|
||||
const data = (await res.json()) as ProjectsSnapshot;
|
||||
if (typeof data?.total !== "number") throw new Error("Unerwartete Antwort der Projekt-API");
|
||||
return data;
|
||||
}
|
||||
+5
-2
@@ -1,12 +1,15 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, FileText } from "lucide-react";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
|
||||
const contractDivider = (
|
||||
<div className="my-8 border-t border-border" aria-hidden />
|
||||
);
|
||||
|
||||
const AGB = () => {
|
||||
usePageMeta("AGB", "Allgemeine Geschäftsbedingungen der WEBklar GbR.");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
@@ -14,8 +17,8 @@ const AGB = () => {
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link to="/" className="flex items-center gap-2 group">
|
||||
<span className="text-xl font-display font-medium text-foreground tracking-tight">
|
||||
Webklar
|
||||
<span className="text-xl font-display font-medium text-foreground tracking-normal">
|
||||
WEBklar
|
||||
</span>
|
||||
</Link>
|
||||
<Link to="/">
|
||||
|
||||
+7
-4
@@ -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
@@ -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>
|
||||
|
||||
@@ -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="/">
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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="/">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user