Hero: ehrliche Fakten statt Zahlenleiste

Die Leiste mit Projektzähler (/api/projects), "Am Markt"-Ticker,
99,9 % Systemverfügbarkeit und Flagge ist ersetzt durch drei belegbare
Fakten ohne Count-up: Falkenstein (Server in Deutschland), Live-Vorschau
(im Kundenportal) und ab 20 € (Hosting im Monat). Einspaltig auf dem
Handy, ab md drei Spalten. Der zweite Button "Projekte ansehen" ist
entfernt, ebenso Projekt-Query, FOUNDING_DATE und src/lib/projects.ts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Sonnet 5.5 committed 2026-10-01 10:39:46 +00:00
1 parent 79c3551934
commit a27b1fe255
3 files changed
+133 -144

No files matched your search

+14 -116
View File
@@ -1,25 +1,12 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ArrowRight } from "lucide-react"; 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 { useTheme } from "next-themes";
import { useQuery } from "@tanstack/react-query";
import CountUp from "@/components/CountUp";
import { trackCtaClick } from "@/lib/analytics"; 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 // WebGL-Effekte als eigene Chunks laden, damit der Text nicht auf three.js/ogl warten muss
const Silk = lazy(() => import("@/components/Silk")); 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 }) { function DemoButtonLetters({ text }: { text: string }) {
const chars = text.split(""); const chars = text.split("");
return ( 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 Hero = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { resolvedTheme } = useTheme(); 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 ── // ── Silk-Hintergrund Farben ──
const isDark = resolvedTheme === "dark"; const isDark = resolvedTheme === "dark";
@@ -149,32 +103,6 @@ const Hero = () => {
</div> </div>
</button> </button>
</div> </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> </div>
{/* Trust Line */} {/* Trust Line */}
@@ -184,46 +112,16 @@ const Hero = () => {
</div> </div>
</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="container mx-auto px-6 mt-auto pb-12 pt-20 relative z-10">
<div className="divider mb-12" /> <div className="divider mb-12" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-12"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12">
<div className="animate-fade-in" style={{ animationDelay: '0.6s' }}> {FAKTEN.map((fakt, i) => (
<div className="stat-number text-4xl md:text-5xl text-foreground mb-2"> <div key={fakt.wert} className="animate-fade-in" style={{ animationDelay: `${0.6 + i * 0.1}s` }}>
<CountUp <div className="stat-number text-3xl lg:text-4xl xl:text-5xl text-foreground mb-2">{fakt.wert}</div>
from={0} <div className="label-tag">{fakt.label}</div>
to={projectCount}
direction="up"
duration={1.2}
className="count-up-text"
startWhen={true}
suffix="+"
/>
</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>
</div> </div>
</section> </section>
-28
View File
@@ -1,28 +0,0 @@
/**
* Live-Projektzahl für die Statistik-Zeile im Hero.
*
* Quelle: GET /api/projects (contact-api-Container) – zählt serverseitig die
* Repositories, die aktuell in Gitea (git.webklar.com) liegen, angereichert um
* die Hosting-Metadaten aus Appwrite. Der Gitea-Token bleibt dadurch auf dem
* Server. Die Antwort enthält mehr Felder als hier genutzt werden; für die
* Homepage ist nur `total` relevant.
*/
export type ProjectsSnapshot = {
/** Zeitpunkt, zu dem der Server-Snapshot erzeugt wurde (ISO) */
generatedAt: string;
/** Anzahl der Repositories, die aktuell in Gitea existieren */
total: number;
counts: Record<string, number>;
source: string;
};
const API_URL = import.meta.env.VITE_PROJECTS_API ?? "/api/projects";
export async function fetchProjects(signal?: AbortSignal): Promise<ProjectsSnapshot> {
const res = await fetch(API_URL, { signal, headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`Projektzahl konnte nicht geladen werden (HTTP ${res.status})`);
const data = (await res.json()) as ProjectsSnapshot;
if (typeof data?.total !== "number") throw new Error("Unerwartete Antwort der Projekt-API");
return data;
}
+119
View File
@@ -0,0 +1,119 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, within, fireEvent, act, cleanup } 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";
// 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([]);
});
});