Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80925c2a43 | ||
|
|
b46527e65d | ||
|
|
d78338a969 | ||
|
|
e6d603cd0c | ||
|
|
003f9e67aa | ||
|
|
897d594dde | ||
|
|
194301c6af | ||
|
|
0d12bbc06a | ||
|
|
b2e17d96c2 | ||
|
|
2e65829e15 | ||
|
|
77672b55d3 | ||
|
|
f0ae8c0be2 | ||
|
|
80099cbdb4 | ||
|
|
8d3424967c | ||
|
|
900d3a9ab5 | ||
|
|
c75befe8e5 | ||
|
|
f337102c7a | ||
|
|
b897ae834f | ||
|
|
c246b9811f | ||
|
|
a27b1fe255 | ||
|
|
79c3551934 | ||
|
|
72f278b478 | ||
|
|
034892c899 | ||
|
|
3c9a879d4a | ||
|
|
044f788835 | ||
|
|
2bffca4b33 | ||
|
|
2c1a4c150c | ||
|
|
dc27e508dd |
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/" />
|
||||
|
||||
+42
-20
@@ -2,20 +2,51 @@ import { Toaster } from "@/components/ui/toaster";
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
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 DatenschutzPage from "./pages/Datenschutz";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import { AppRoutes } from "./routes";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
/**
|
||||
* 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);
|
||||
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;
|
||||
};
|
||||
|
||||
const App = () => (
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -23,18 +54,9 @@ const App = () => (
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<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="/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;
|
||||
@@ -25,13 +25,14 @@ const footerLinks: { title: string; links: FooterLink[] }[] = [
|
||||
{ id: 3, title: "Leistungen", href: "/#services" },
|
||||
{ id: 4, title: "Kontakt", to: "/kontakt" },
|
||||
{ id: 9, title: "Wartungs-Abo", to: "/wartungs-abo" },
|
||||
{ id: 13, title: "Hosting-Abo", to: "/hosting-abo" },
|
||||
],
|
||||
},
|
||||
{
|
||||
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;
|
||||
}
|
||||
+26
-19
@@ -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="/">
|
||||
@@ -118,14 +121,17 @@ const AGB = () => {
|
||||
{/* 5. Leistungsmodul C – Hosting */}
|
||||
<section>
|
||||
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-2">
|
||||
5. Leistungsmodul C – Hosting (jährlich)
|
||||
5. Leistungsmodul C – Hosting
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">(aktiv, wenn im Angebot enthalten)</p>
|
||||
<ol className="list-decimal list-inside space-y-2 text-muted-foreground leading-relaxed [&>li]:pl-2">
|
||||
<li>WEBklar stellt optional Hosting-Leistungen zur Verfügung.</li>
|
||||
<li>Hostingverträge haben eine jährliche Laufzeit und verlängern sich automatisch, sofern nicht fristgerecht gekündigt wird.</li>
|
||||
<li>WEBklar ist berechtigt, externe Anbieter (z. B. Rechenzentren) einzusetzen.</li>
|
||||
<li>WEBklar übernimmt keine Haftung für Ausfälle externer Anbieter.</li>
|
||||
<li>
|
||||
Hosting bietet WEBklar als Hosting-Abo an. Für das Hosting-Abo und das Wartungs-Abo gelten die{" "}
|
||||
<a href="https://project.webklar.com/abo-bedingungen.html" className="text-foreground underline hover:no-underline">Vertragsbedingungen für die WEBklar-Abos</a>
|
||||
{" "}mit Leistungsbeschreibungen; sie gehen diesen AGB vor.
|
||||
</li>
|
||||
<li>Bestehende Hosting-Verträge mit jährlicher Laufzeit behalten die bei ihrem Abschluss vereinbarten Bedingungen. Abweichend davon können Verbraucher solche Verträge nach einer automatischen Verlängerung jederzeit mit einer Frist von einem Monat kündigen.</li>
|
||||
<li>WEBklar ist berechtigt, externe Anbieter (z. B. Rechenzentren) einzusetzen.</li>
|
||||
</ol>
|
||||
</section>
|
||||
{contractDivider}
|
||||
@@ -189,8 +195,15 @@ 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>Hosting-Leistungen sind jährlich im Voraus zu zahlen.</li>
|
||||
<li>Für die Abos gelten die Vertragsbedingungen für die WEBklar-Abos.</li>
|
||||
</ol>
|
||||
</section>
|
||||
{contractDivider}
|
||||
@@ -219,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}
|
||||
@@ -239,7 +246,7 @@ const AGB = () => {
|
||||
12. Kündigung
|
||||
</h2>
|
||||
<ol className="list-decimal list-inside space-y-2 text-muted-foreground leading-relaxed [&>li]:pl-2">
|
||||
<li>Laufzeitverträge (z. B. Hosting, Wartung) können zum Ende der jeweiligen Laufzeit gekündigt werden.</li>
|
||||
<li>Laufzeitverträge können zum Ende der jeweiligen Laufzeit gekündigt werden. Laufzeit und Kündigung der Abos richten sich nach den Vertragsbedingungen für die WEBklar-Abos.</li>
|
||||
<li>Das Recht zur außerordentlichen Kündigung bleibt unberührt.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
+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>
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
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";
|
||||
|
||||
const divider = <div className="my-8 border-t border-border" aria-hidden />;
|
||||
const linkClass = "text-foreground underline hover:no-underline";
|
||||
|
||||
const leistungen = [
|
||||
{
|
||||
titel: "Bereitstellung Ihrer Website",
|
||||
text: "Hosting auf unserem Server in Deutschland, SSL-Zertifikat, tägliche Datensicherung, Überwachung der Erreichbarkeit sowie Updates von Betriebssystem und Server-Software.",
|
||||
},
|
||||
{
|
||||
titel: "1 Domain inklusive",
|
||||
text: "Registrierung auf Ihren Namen, Verlängerung und DNS-Verwaltung (.de, .com, .eu, .net oder .org).",
|
||||
},
|
||||
{
|
||||
titel: "1 E-Mail-Postfach",
|
||||
text: `Unter Ihrer eigenen Domain, 5 GB Speicher, Webmail und Zugriff per Outlook oder Smartphone. Mehr Platz gibt es über den E-Mail-Zusatzspeicher: je 5 GB für 1,00 € pro Monat (${STEUER}).`,
|
||||
},
|
||||
{
|
||||
titel: "Störungen inklusive",
|
||||
text: "Störungen an Server, Domain und Postfach in unserem Verantwortungsbereich beheben wir ohne Berechnung. Servicezeiten Montag bis Freitag 9 bis 17 Uhr (außer an gesetzlichen Feiertagen in Rheinland-Pfalz), Antwort spätestens bis zum Ende des nächsten Werktags.",
|
||||
},
|
||||
];
|
||||
|
||||
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-normal">WEBklar</span>
|
||||
</Link>
|
||||
<Button asChild variant="ghost" className="text-muted-foreground hover:text-foreground">
|
||||
<Link to="/">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Zurück
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="pt-32 pb-24">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="mb-12">
|
||||
<div className="label-tag mb-4 flex items-center gap-2">
|
||||
<Server className="w-4 h-4" />
|
||||
Leistungen
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-display font-medium text-foreground tracking-tight uppercase mb-2">
|
||||
Hosting-Abo
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
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>
|
||||
|
||||
<article className="space-y-8 text-foreground">
|
||||
<section>
|
||||
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-4">Das ist enthalten</h2>
|
||||
<ul className="grid gap-5 sm:grid-cols-2">
|
||||
{leistungen.map((l) => (
|
||||
<li key={l.titel} className="rounded-2xl border border-border p-5">
|
||||
<p className="font-medium mb-1">{l.titel}</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">{l.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
{divider}
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">Nur für statische Websites</h2>
|
||||
<div className="text-muted-foreground leading-relaxed space-y-3">
|
||||
<p>
|
||||
Das Hosting-Abo ist für statische Websites bestimmt, die wir erstellt oder zur Betreuung übernommen haben.
|
||||
Statisch heißt: Der Webserver liefert die Dateien Ihrer Website (HTML, CSS, JavaScript, Bilder) unverändert
|
||||
aus. Dazu gehören auch die React- und Vite-Websites, die wir erstellen.
|
||||
</p>
|
||||
<p>
|
||||
Nicht möglich sind ein Content-Management-System, ein Login, eine Datenbank und eigener serverseitiger Code
|
||||
(etwa PHP). Kontakt über E-Mail-Links auf der Website ist möglich. Formulare, die Eingaben an einen Server
|
||||
übermitteln, sind nicht enthalten; eine Lösung dafür bieten wir auf Anfrage gesondert an.
|
||||
</p>
|
||||
<p>
|
||||
Updates und Pflege der Website selbst sind nicht enthalten; bei statischen Websites sind sie für den sicheren
|
||||
Betrieb in der Regel nicht nötig. Braucht Ihre Website dynamische Teile oder wünschen Sie laufende Pflege und
|
||||
eine Stunde Arbeitszeit pro Monat, ist unser{" "}
|
||||
<Link to="/wartungs-abo" className={linkClass}>Wartungs-Abo</Link> das Richtige.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{divider}
|
||||
|
||||
<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>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>
|
||||
</ul>
|
||||
</section>
|
||||
{divider}
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">E-Mail-Zusatzspeicher</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Brauchen Sie mehr Platz für E-Mails, buchen Sie im Kundenportal unter „Verträge“ Zusatzspeicher: Pakete von je
|
||||
5 GB für 1,00 € pro Monat ({STEUER}), bis zu 10 Pakete. Bestellen und verringern können Sie die Pakete dort,
|
||||
sobald Ihr Abo begonnen hat, solange es nicht gekündigt ist und keine Zahlung offen ist. Eine Verringerung wirkt
|
||||
zum Ende des Abrechnungsmonats. Einzelheiten:{" "}
|
||||
<a href={`${PORTAL}/abo-bedingungen.html#zusatzspeicher`} className={linkClass}>§ 10 der Vertragsbedingungen</a>.
|
||||
</p>
|
||||
</section>
|
||||
{divider}
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">Wechsel ins Wartungs-Abo</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Braucht Ihre Website später dynamische Teile oder möchten Sie Pflege und Arbeitszeit dazubuchen, wechseln Sie
|
||||
im Kundenportal unter „Verträge“ ins{" "}
|
||||
<Link to="/wartungs-abo" className={linkClass}>Wartungs-Abo</Link>, auch während der Mindestlaufzeit: sobald
|
||||
Ihr 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. Einzelheiten:{" "}
|
||||
<a href={`${PORTAL}/abo-bedingungen.html#wechsel`} className={linkClass}>§ 9 der Vertragsbedingungen</a>.
|
||||
</p>
|
||||
</section>
|
||||
{divider}
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">So schließen Sie das Abo ab</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Das Hosting-Abo schließen Sie in unserem Kundenportal ab. Den Zugang richten wir Ihnen ein, sprechen Sie uns
|
||||
einfach an. Nach der Anmeldung finden Sie das Angebot im Bereich „Verträge“. Im Kundenportal sehen Sie danach
|
||||
jederzeit Ihre Verträge und Rechnungen, buchen Zusatzspeicher und schicken uns Aufträge über den Chat.
|
||||
</p>
|
||||
</section>
|
||||
{divider}
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">Widerruf und Kündigung</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Verbraucher können jede Bestellung innerhalb von 14 Tagen ab Vertragsschluss widerrufen: den Abschluss des
|
||||
Abos, den Wechsel ins Wartungs-Abo und die Bestellung von Zusatzspeicher. Bereits gezahlte Beträge erstatten
|
||||
wir dann nach der{" "}
|
||||
<a href={`${PORTAL}/abo-bedingungen.html#widerruf`} className={linkClass}>Widerrufsbelehrung</a>.
|
||||
Kündigen können Sie online ohne Anmeldung oder per E-Mail.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Button asChild variant="outline" className="rounded-full">
|
||||
<a href={`${PORTAL}/kuendigen.html`}>Verträge hier kündigen</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="rounded-full">
|
||||
<a href={`${PORTAL}/widerrufen.html`}>Vertrag widerrufen</a>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
{divider}
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">Unterlagen und Kontakt</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
<a href={`${PORTAL}/abo-bedingungen.html`} className={linkClass}>Vertragsbedingungen für die WEBklar-Abos</a>
|
||||
{" · "}
|
||||
<a href={`${PORTAL}/abo-bedingungen.html#hosting`} className={linkClass}>Leistungsbeschreibung A (Hosting-Abo)</a>
|
||||
{" · "}
|
||||
<a href={`${PORTAL}/WEBklar-Abo-Vertragsunterlagen.pdf`} className={linkClass}>Vertragsunterlagen als PDF</a>
|
||||
{" · "}
|
||||
<a href={`${PORTAL}/datenschutz.html`} className={linkClass}>Datenschutz</a>
|
||||
<br />
|
||||
WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler · Telefon 0176 23726355 · E-Mail support@webklar.com
|
||||
</p>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<div className="mt-16 pt-12 border-t border-border flex flex-wrap gap-4">
|
||||
<Button asChild className="btn-minimal rounded-full">
|
||||
<a href={`${PORTAL}/login.html`}>Zum Kundenportal</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="rounded-full">
|
||||
<Link to="/kontakt">Kontakt aufnehmen</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HostingAbo;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+71
-31
@@ -1,23 +1,26 @@
|
||||
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";
|
||||
|
||||
const divider = <div className="my-8 border-t border-border" aria-hidden />;
|
||||
const linkClass = "text-foreground underline hover:no-underline";
|
||||
|
||||
const leistungen = [
|
||||
{
|
||||
titel: "Betrieb & Pflege Ihrer Website",
|
||||
text: "Hosting auf unserem Server in Deutschland, Sicherheitsupdates, SSL-Zertifikat, tägliche Datensicherung und Überwachung der Erreichbarkeit.",
|
||||
text: "Hosting auf unserem Server in Deutschland, Sicherheitsupdates für den Server und für die Software-Komponenten Ihrer Website, die wir einsetzen, SSL-Zertifikat, tägliche Datensicherung und Überwachung der Erreichbarkeit. Für alle Websites und Web-Anwendungen, die wir erstellt oder zur Betreuung übernommen haben, auch mit Content-Management-System, Login oder Datenbank.",
|
||||
},
|
||||
{
|
||||
titel: "1 Domain inklusive",
|
||||
text: "Registrierung auf Ihren Namen, Verlängerung und DNS-Verwaltung (.de, .com, .eu, .net oder .org).",
|
||||
},
|
||||
{
|
||||
titel: "1 E-Mail-Postfach",
|
||||
text: "Mit Ihrer eigenen Domain, 5 GB Speicher, Webmail und Zugriff per Outlook oder Smartphone.",
|
||||
titel: "Beliebig viele Postfächer",
|
||||
text: `Unter Ihrer eigenen Domain, zusammen 5 GB Speicher, Webmail und Zugriff per Outlook oder Smartphone. Mehr Platz gibt es über den E-Mail-Zusatzspeicher: je 5 GB für 1,00 € pro Monat (${STEUER}).`,
|
||||
},
|
||||
{
|
||||
titel: "1 Stunde Arbeitszeit pro Monat",
|
||||
@@ -26,20 +29,25 @@ 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>
|
||||
<Link to="/">
|
||||
<Button variant="ghost" className="text-muted-foreground hover:text-foreground">
|
||||
<Button asChild variant="ghost" className="text-muted-foreground hover:text-foreground">
|
||||
<Link to="/">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Zurück
|
||||
</Button>
|
||||
</Link>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -56,8 +64,9 @@ const WartungsAbo = () => {
|
||||
Wartungs-Abo
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Betrieb, Pflege und Betreuung Ihrer Website: 50 € pro Monat, 12 Monate Mindestlaufzeit.
|
||||
Betrieb, Pflege und Betreuung Ihrer Website: 50,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>
|
||||
|
||||
<article className="space-y-8 text-foreground">
|
||||
@@ -77,20 +86,48 @@ const WartungsAbo = () => {
|
||||
<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>50,00 € pro Monat, monatlich im Voraus. Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.</li>
|
||||
<li>50,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>Arbeit über die enthaltene Stunde hinaus nur nach Ihrer Freigabe, 100 € pro Stunde, abgerechnet in 15-Minuten-Schritten.</li>
|
||||
<li>Arbeit über die enthaltene Stunde hinaus nur nach Ihrer Freigabe, 100 € pro Stunde ({STEUER}), abgerechnet in 15-Minuten-Schritten.</li>
|
||||
</ul>
|
||||
</section>
|
||||
{divider}
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">E-Mail-Postfächer und Zusatzspeicher</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Im Wartungs-Abo richten wir Ihnen beliebig viele Postfächer unter Ihrer Domain ein, mit zusammen 5 GB
|
||||
Speicher. Wie der Speicher auf die Postfächer verteilt wird, bestimmen Sie. Brauchen Sie mehr Platz, buchen Sie
|
||||
im Kundenportal unter „Verträge“ Zusatzspeicher: Pakete von je 5 GB für 1,00 € pro Monat ({STEUER}), bis zu
|
||||
10 Pakete. Bestellen und verringern können Sie die Pakete dort, sobald Ihr Abo begonnen hat, solange es nicht
|
||||
gekündigt ist und keine Zahlung offen ist. Eine Verringerung wirkt zum Ende des Abrechnungsmonats.
|
||||
Einzelheiten:{" "}
|
||||
<a href={`${PORTAL}/abo-bedingungen.html#zusatzspeicher`} className={linkClass}>§ 10 der Vertragsbedingungen</a>.
|
||||
</p>
|
||||
</section>
|
||||
{divider}
|
||||
|
||||
<section>
|
||||
<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 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
|
||||
Abrechnungsmonats möglich, wenn Ihre Website statisch ist. Einzelheiten in{" "}
|
||||
<a href={`${PORTAL}/abo-bedingungen.html#wechsel`} className={linkClass}>§ 9 der Vertragsbedingungen</a>.
|
||||
</p>
|
||||
</section>
|
||||
{divider}
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">So schließen Sie das Abo ab</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Das Wartungs-Abo schließen Sie in unserem Kundenportal ab. Den Zugang richten wir Ihnen ein, sprechen Sie uns
|
||||
einfach an. Nach der Anmeldung finden Sie das Angebot auf der Startseite. Dort sehen Sie danach jederzeit Ihr
|
||||
Stundenkontingent, Ihre Rechnungen und können uns Aufträge schicken.
|
||||
einfach an. Nach der Anmeldung finden Sie das Angebot im Bereich „Verträge“. Im Kundenportal sehen Sie danach
|
||||
jederzeit Ihr Stundenkontingent und Ihre Rechnungen und schicken uns Aufträge über den Chat.
|
||||
</p>
|
||||
</section>
|
||||
{divider}
|
||||
@@ -98,18 +135,19 @@ const WartungsAbo = () => {
|
||||
<section>
|
||||
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">Widerruf und Kündigung</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Verbraucher können den Vertrag innerhalb von 14 Tagen widerrufen. Bereits gezahlte Beträge erstatten wir dann
|
||||
nach der{" "}
|
||||
<a href={`${PORTAL}/abo-bedingungen.html#widerruf`} className="text-foreground underline hover:no-underline">Widerrufsbelehrung</a>.
|
||||
Verbraucher können jede Bestellung innerhalb von 14 Tagen ab Vertragsschluss widerrufen: den Abschluss des
|
||||
Abos, den Wechsel ins Wartungs-Abo und die Bestellung von Zusatzspeicher. Bereits gezahlte Beträge erstatten
|
||||
wir dann nach der{" "}
|
||||
<a href={`${PORTAL}/abo-bedingungen.html#widerruf`} className={linkClass}>Widerrufsbelehrung</a>.
|
||||
Kündigen können Sie online ohne Anmeldung oder per E-Mail.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<a href={`${PORTAL}/kuendigen.html`}>
|
||||
<Button variant="outline" className="rounded-full">Verträge hier kündigen</Button>
|
||||
</a>
|
||||
<a href={`${PORTAL}/widerrufen.html`}>
|
||||
<Button variant="outline" className="rounded-full">Vertrag widerrufen</Button>
|
||||
</a>
|
||||
<Button asChild variant="outline" className="rounded-full">
|
||||
<a href={`${PORTAL}/kuendigen.html`}>Verträge hier kündigen</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="rounded-full">
|
||||
<a href={`${PORTAL}/widerrufen.html`}>Vertrag widerrufen</a>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
{divider}
|
||||
@@ -117,11 +155,13 @@ const WartungsAbo = () => {
|
||||
<section>
|
||||
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">Unterlagen und Kontakt</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
<a href={`${PORTAL}/abo-bedingungen.html`} className="text-foreground underline hover:no-underline">Vertragsbedingungen mit Leistungsbeschreibung</a>
|
||||
<a href={`${PORTAL}/abo-bedingungen.html`} className={linkClass}>Vertragsbedingungen für die WEBklar-Abos</a>
|
||||
{" · "}
|
||||
<a href={`${PORTAL}/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf`} className="text-foreground underline hover:no-underline">Vertragsunterlagen als PDF</a>
|
||||
<a href={`${PORTAL}/abo-bedingungen.html#wartung`} className={linkClass}>Leistungsbeschreibung B (Wartungs-Abo)</a>
|
||||
{" · "}
|
||||
<a href={`${PORTAL}/datenschutz.html`} className="text-foreground underline hover:no-underline">Datenschutz</a>
|
||||
<a href={`${PORTAL}/WEBklar-Abo-Vertragsunterlagen.pdf`} className={linkClass}>Vertragsunterlagen als PDF</a>
|
||||
{" · "}
|
||||
<a href={`${PORTAL}/datenschutz.html`} className={linkClass}>Datenschutz</a>
|
||||
<br />
|
||||
WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler · Telefon 0176 23726355 · E-Mail support@webklar.com
|
||||
</p>
|
||||
@@ -129,12 +169,12 @@ const WartungsAbo = () => {
|
||||
</article>
|
||||
|
||||
<div className="mt-16 pt-12 border-t border-border flex flex-wrap gap-4">
|
||||
<a href={`${PORTAL}/login.html`}>
|
||||
<Button className="btn-minimal rounded-full">Zum Kundenportal</Button>
|
||||
</a>
|
||||
<Link to="/kontakt">
|
||||
<Button variant="outline" className="rounded-full">Kontakt aufnehmen</Button>
|
||||
</Link>
|
||||
<Button asChild className="btn-minimal rounded-full">
|
||||
<a href={`${PORTAL}/login.html`}>Zum Kundenportal</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="rounded-full">
|
||||
<Link to="/kontakt">Kontakt aufnehmen</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import HostingAbo from "@/pages/HostingAbo";
|
||||
import WartungsAbo from "@/pages/WartungsAbo";
|
||||
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 };
|
||||
|
||||
// Texte, die auf einer Seite mehrfach vorkommen können, werden mit getAllByText
|
||||
// geprüft (getByText wirft bei mehreren Treffern); Abwesenheit mit queryAllByText.
|
||||
|
||||
/** Liefert den <section>-Abschnitt zu einer Überschrift. */
|
||||
const sectionOf = (name: string | RegExp): HTMLElement => {
|
||||
const section = screen.getByRole("heading", { name }).closest("section");
|
||||
if (!section) throw new Error(`Abschnitt zur Überschrift ${String(name)} fehlt`);
|
||||
return section;
|
||||
};
|
||||
|
||||
describe("Abo-Seiten", () => {
|
||||
it("Hosting-Abo-Seite: Preis, Postfach, Links", () => {
|
||||
render(
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<HostingAbo />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
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",
|
||||
"https://project.webklar.com/kuendigen.html",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /Vertragsunterlagen als PDF/ })).toHaveAttribute(
|
||||
"href",
|
||||
"https://project.webklar.com/WEBklar-Abo-Vertragsunterlagen.pdf",
|
||||
);
|
||||
});
|
||||
|
||||
it("Hosting-Abo-Seite: Wechsel und Zusatzspeicher nur unter den Bedingungen des Portals", () => {
|
||||
render(
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<HostingAbo />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
// § 9 Abs. 1: Wechsel nur, wenn das Hosting-Abo begonnen hat, nicht gekündigt ist und keine Zahlung offen ist;
|
||||
// mit dem Wechsel beginnt eine neue Mindestlaufzeit.
|
||||
const wechsel = within(sectionOf("Wechsel ins Wartungs-Abo"));
|
||||
expect(wechsel.getAllByText(/begonnen hat/).length).toBeGreaterThan(0);
|
||||
expect(wechsel.getAllByText(/nicht gekündigt/).length).toBeGreaterThan(0);
|
||||
expect(wechsel.getAllByText(/keine Zahlung offen/).length).toBeGreaterThan(0);
|
||||
expect(wechsel.getAllByText(/neue Mindestlaufzeit von 12 Monaten/).length).toBeGreaterThan(0);
|
||||
// § 10 Abs. 1: dieselben Bedingungen für Bestellung und Verringerung des Zusatzspeichers.
|
||||
const speicher = within(sectionOf("E-Mail-Zusatzspeicher"));
|
||||
expect(speicher.getAllByText(/begonnen hat/).length).toBeGreaterThan(0);
|
||||
expect(speicher.getAllByText(/nicht gekündigt/).length).toBeGreaterThan(0);
|
||||
expect(speicher.getAllByText(/keine Zahlung offen/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("Wartungs-Abo-Seite: beliebig viele Postfächer, Zusatzspeicher", () => {
|
||||
render(
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<WartungsAbo />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getAllByText("Beliebig viele Postfächer").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/beliebig viele Postfächer/).length).toBeGreaterThan(0);
|
||||
expect(screen.queryAllByText("1 E-Mail-Postfach").length).toBe(0);
|
||||
expect(screen.queryAllByText(/1 E-Mail-Postfach/).length).toBe(0);
|
||||
expect(screen.getAllByText(/1,00 €/).length).toBeGreaterThan(0);
|
||||
// Wechsel und Zusatzspeicher auch hier nur unter den Bedingungen des Portals.
|
||||
const speicher = within(sectionOf("E-Mail-Postfächer und Zusatzspeicher"));
|
||||
expect(speicher.getAllByText(/begonnen hat/).length).toBeGreaterThan(0);
|
||||
expect(speicher.getAllByText(/nicht gekündigt/).length).toBeGreaterThan(0);
|
||||
expect(speicher.getAllByText(/keine Zahlung offen/).length).toBeGreaterThan(0);
|
||||
const hosting = within(sectionOf("Hosting-Abo für statische Websites"));
|
||||
expect(hosting.getAllByText(/begonnen hat/).length).toBeGreaterThan(0);
|
||||
expect(hosting.getAllByText(/nicht gekündigt/).length).toBeGreaterThan(0);
|
||||
expect(hosting.getAllByText(/keine Zahlung offen/).length).toBeGreaterThan(0);
|
||||
expect(hosting.getAllByText(/neue Mindestlaufzeit von 12 Monaten/).length).toBeGreaterThan(0);
|
||||
expect(hosting.getByRole("link", { name: /§ 9 der Vertragsbedingungen/ })).toHaveAttribute(
|
||||
"href",
|
||||
"https://project.webklar.com/abo-bedingungen.html#wechsel",
|
||||
);
|
||||
});
|
||||
|
||||
it("AGB Modul C verweist auf das Hosting-Abo", () => {
|
||||
render(
|
||||
<MemoryRouter future={routerFuture}>
|
||||
<AGB />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const modulC = within(sectionOf(/Leistungsmodul C/));
|
||||
expect(modulC.getAllByText(/Hosting-Abo/).length).toBeGreaterThan(0);
|
||||
expect(modulC.getByRole("link", { name: "Vertragsbedingungen für die WEBklar-Abos" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://project.webklar.com/abo-bedingungen.html",
|
||||
);
|
||||
expect(screen.queryAllByText(/verlängern sich automatisch/).length).toBe(0);
|
||||
// Rechtsrunde: kein eigener Haftungssatz mehr in Modul C (für die Abos gilt § 13 der Abo-Bedingungen) …
|
||||
expect(modulC.queryAllByText(/keine Haftung für Ausfälle externer Anbieter/).length).toBe(0);
|
||||
// … und Verbraucher können – als Ausnahme von den vereinbarten Bedingungen – automatisch verlängerte
|
||||
// Jahresverträge mit einer Frist von einem Monat kündigen.
|
||||
expect(
|
||||
modulC.getAllByText(
|
||||
/Abweichend davon können Verbraucher solche Verträge nach einer automatischen Verlängerung jederzeit mit einer Frist von einem Monat kündigen\./,
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
Reference in new issue
Block a user