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 { 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>
|
||||
|
||||
@@ -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