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:
1 parent
79c3551934
commit
a27b1fe255
3 files changed
+133
-144
No files matched your search
+14
-116
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user