Skip to content
All templates

Templates/Solar & Energy

A calm, bilingual full-page template for a solar and energy installer. Photovoltaics, storage, heat pumps and wallboxes, honestly calculated and cleanly built. Real photography, a fresh may-green accent, animated count-up stats and a scroll-driven statement are already built in.

Energy & RenewablesFull page

The finished page to scroll through, switchable between desktop, tablet and mobile.

Source code

The complete page as a single file. Copy it into ./components and add the component to your route.

import {
  createContext,
  useContext,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type FormEvent,
  type ReactNode,
} from "react";
import {
  AnimatePresence,
  motion,
  useReducedMotion,
  useScroll,
  useTransform,
  type Variants,
} from "framer-motion";
import {
  ArrowRight,
  ArrowUpRight,
  BatteryCharging,
  ClipboardList,
  Compass,
  Gauge,
  HelpCircle,
  Leaf,
  Mail,
  MapPin,
  Menu,
  MessageCircle,
  Phone,
  PhoneCall,
  PlugZap,
  ShieldCheck,
  Sparkles,
  Star,
  Sun,
  Target,
  Thermometer,
  TrendingUp,
  Users,
  Wallet,
  X,
  type LucideIcon,
} from "lucide-react";
import gsap from "gsap";
import { MotionPathPlugin } from "gsap/MotionPathPlugin";
import { ScrollTrigger } from "gsap/ScrollTrigger";

// A neutral, fictional solar and energy template. Brand: "Sonnwerk" (solar and
// storage) from Osnabrück. No real names, addresses or external links.
// Bilingual (DE/EN) through an internal context. Carried by one clear may green
// accent colour, real photos and plenty of surface.
//
// Needs GSAP: npm i gsap  (only the "Sonnenlauf" section uses it)

const ease = [0.22, 1, 0.36, 1] as const;
const BRAND = "Sonnwerk";

/* May green palette */
const ACCENT = "#74b32c"; // Primary (surfaces, buttons, active states, icons)
const ACCENT_DARK = "#547f1c"; // Deep green (small texts and links, hover)
const ACCENT_LIGHT = "#a9d16a"; // Bright green on a dark ground
const TINT = "#f1f6e7"; // Lighter green tone (surfaces, chips, soft fills)
const INK = "#1b2418"; // Nahezu Schwarz
const MUTED = "#5d6b52"; // Muted text
const SURFACE = "#f4f7f5"; // Light grey surface
const GREEN_DEEP = "#33521f"; // Dark green (cards with white text)
const DARK = "#141a11"; // Dark section (almost black, slightly green)
const AMBER = "#f5a623"; // Warm amber for the stars

const contact = {
  phoneDisplay: "0541 700 32 10",
  phoneHref: "tel:+495417003210",
  phonePretty: "+49 (0) 541 700 32 10",
  email: "hallo@sonnwerk-energie.de",
  emailHref: "mailto:hallo@sonnwerk-energie.de",
  addressLine1: "Hasetorwall 8",
  addressLine2: "49074 Osnabrück",
};

/* Images (as URL strings, no imports) */
const IMG = {
  heroHouse: "/heros/a-modern-house-with-solar-panels.webp",
  solarCell: "/heros/solar-cell-close-up-shot.webp",
  roofClose: "/heros/niro-solar-flowers.webp",
  workers: "/heros/two-technical-workers-in-an-industrial-warehouse.webp",
};

/* ------------------------------------------------------------------ *
 * Language (DE/EN) through context
 * ------------------------------------------------------------------ */

type Lang = "de" | "en";
type Tx = (de: string, en: string) => string;

const SwCtx = createContext<{ lang: Lang; tx: Tx }>({ lang: "de", tx: (de) => de });
const useSw = () => useContext(SwCtx);
const SetLangCtx = createContext<{ setLang: (l: Lang) => void }>({ setLang: () => {} });

/* ------------------------------------------------------------------ *
 * Scoped styles (fonts, loop animation, reduced motion)
 * ------------------------------------------------------------------ */

const scopedStyles = `
@import url('https://fonts.googleapis.com/css2?family=Albert+Sans:wght@300;400;500;600&family=Sora:wght@400;500;600&display=swap');

.sw-root {
  font-family: "Albert Sans", ui-sans-serif, system-ui, sans-serif;
  font-weight: 400;
  letter-spacing: -0.004em;
  color: ${INK};
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}
.sw-root h1, .sw-root h2, .sw-root h3, .sw-root h4 {
  font-family: "Sora", ui-sans-serif, system-ui, sans-serif;
  font-weight: 500;
  letter-spacing: -0.022em;
}
.sw-root .font-display {
  font-family: "Sora", ui-sans-serif, system-ui, sans-serif;
}
.sw-root .container-editorial {
  width: 100%;
  margin-inline: auto;
  padding-inline: 1.25rem;
  max-width: 1300px;
}
@media (min-width: 480px) {
  .sw-root .container-editorial { padding-inline: 1.5rem; }
}
@media (min-width: 768px) {
  .sw-root .container-editorial { padding-inline: 2.5rem; }
}
@media (min-width: 1280px) {
  .sw-root .container-editorial { padding-inline: 3.5rem; }
}

.sw-loop-mask {
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 12%, #000 88%, transparent);
  mask-image: linear-gradient(to bottom, transparent, #000 12%, #000 88%, transparent);
}
.sw-loop-track { display: flex; flex-direction: column; gap: 1rem; }
.sw-loop-track--up { animation: swLoopUp 26s linear infinite; }
.sw-loop-track--down { animation: swLoopDown 30s linear infinite; }
@keyframes swLoopUp {
  from { transform: translate3d(0, 0, 0); }
  to { transform: translate3d(0, -33.333%, 0); }
}
@keyframes swLoopDown {
  from { transform: translate3d(0, -33.333%, 0); }
  to { transform: translate3d(0, 0, 0); }
}

.sw-marquee { display: flex; width: max-content; animation: swMarquee 34s linear infinite; }
@keyframes swMarquee {
  from { transform: translate3d(0, 0, 0); }
  to { transform: translate3d(-33.333%, 0, 0); }
}

@media (prefers-reduced-motion: reduce) {
  .sw-root *, .sw-root *::before, .sw-root *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  .sw-loop-track--up, .sw-loop-track--down, .sw-marquee { animation: none !important; }
}
`;

/* ------------------------------------------------------------------ *
 * Helpers
 * ------------------------------------------------------------------ */

function Reveal({
  children,
  delay = 0,
  y = 22,
  className,
  as = "div",
}: {
  children: ReactNode;
  delay?: number;
  y?: number;
  className?: string;
  as?: "div" | "section" | "span" | "li" | "article";
}) {
  const reduce = useReducedMotion();
  const MotionTag = motion[as] as typeof motion.div;
  const variants: Variants = {
    hidden: { opacity: 0, y: reduce ? 0 : y },
    show: { opacity: 1, y: 0, transition: { duration: 0.85, ease, delay } },
  };
  return (
    <MotionTag
      className={className}
      initial="hidden"
      whileInView="show"
      viewport={{ once: true, margin: "-80px" }}
      variants={variants}
    >
      {children}
    </MotionTag>
  );
}

function RevealWords({
  text,
  className,
  stagger = 0.05,
}: {
  text: string;
  className?: string;
  stagger?: number;
}) {
  const reduce = useReducedMotion();
  const words = text.split(" ");
  return (
    <motion.span
      className={className}
      initial="hidden"
      whileInView="show"
      viewport={{ once: true, margin: "-80px" }}
      variants={{
        hidden: {},
        show: { transition: { staggerChildren: reduce ? 0 : stagger } },
      }}
    >
      {words.map((w, i) => (
        <motion.span
          key={i}
          className="inline-block whitespace-pre"
          variants={{
            hidden: { opacity: 0, y: reduce ? 0 : 16 },
            show: { opacity: 1, y: 0, transition: { duration: 0.85, ease } },
          }}
        >
          {w + (i < words.length - 1 ? " " : "")}
        </motion.span>
      ))}
    </motion.span>
  );
}

function useIsMobile() {
  const [isMobile, setIsMobile] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(max-width: 767px)");
    const onChange = () => setIsMobile(mq.matches);
    onChange();
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);
  return isMobile;
}

function CountUp({
  target,
  decimals = 0,
  suffix = "",
  prefix = "",
  group = false,
}: {
  target: number;
  decimals?: number;
  suffix?: string;
  prefix?: string;
  group?: boolean;
}) {
  const { lang } = useSw();
  const ref = useRef<HTMLSpanElement>(null);
  const [value, setValue] = useState(0);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    let raf = 0;
    let done = false;
    const obs = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (!entry.isIntersecting || done) continue;
          done = true;
          obs.disconnect();
          if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
            setValue(target);
            return;
          }
          const start = performance.now();
          const step = (now: number) => {
            const r = Math.min((now - start) / 2100, 1);
            const eased = r < 0.5 ? 4 * r * r * r : 1 - Math.pow(-2 * r + 2, 3) / 2;
            setValue(target * eased);
            if (r < 1) raf = requestAnimationFrame(step);
          };
          raf = requestAnimationFrame(step);
        }
      },
      { threshold: 0.55 },
    );
    obs.observe(el);
    return () => {
      obs.disconnect();
      cancelAnimationFrame(raf);
    };
  }, [target]);

  const locale = lang === "en" ? "en-US" : "de-DE";
  const fmt = (v: number) =>
    group
      ? Math.round(v).toLocaleString(locale)
      : lang === "en"
        ? v.toFixed(decimals)
        : v.toFixed(decimals).replace(".", ",");

  return (
    <span ref={ref} aria-label={`${prefix}${fmt(target)}${suffix}`}>
      {prefix}
      {fmt(value)}
      {suffix}
    </span>
  );
}

function Stars({
  count = 5,
  size = 14,
  className = "",
}: {
  count?: number;
  size?: number;
  className?: string;
}) {
  return (
    <span className={`inline-flex items-center gap-0.5 ${className}`} aria-hidden>
      {Array.from({ length: count }).map((_, i) => (
        <Star key={i} strokeWidth={0} style={{ width: size, height: size, fill: AMBER }} />
      ))}
    </span>
  );
}

function ParallaxImage({
  src,
  alt,
  className = "",
  imgClassName = "",
}: {
  src: string;
  alt: string;
  className?: string;
  imgClassName?: string;
}) {
  const ref = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();
  const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] });
  const y = useTransform(scrollYProgress, [0, 1], reduce ? ["0%", "0%"] : ["-5%", "5%"]);
  return (
    <div ref={ref} className={`relative overflow-hidden rounded-3xl ${className}`}>
      <motion.img
        src={src}
        alt={alt}
        loading="lazy"
        style={{ y }}
        className={`h-full w-full scale-110 object-cover ${imgClassName}`}
      />
    </div>
  );
}

/* ------------------------------------------------------------------ *
 * Data (bilingual factories)
 * ------------------------------------------------------------------ */

type Service = {
  id: string;
  title: string;
  sub: string;
  copy: string;
  icon: LucideIcon;
  img: string;
};

const makeServices = (tx: Tx): Service[] => [
  {
    id: "photovoltaik",
    img: "/heros/a-modern-home-with-solar-panels.webp",
    title: tx("Aufdach-Photovoltaik", "Rooftop Photovoltaics"),
    sub: tx("Strom vom eigenen Dach.", "Power from your own roof."),
    copy: tx(
      "Module, Wechselrichter und Montage aus einer Hand. Wir planen die Belegung passend zu Dach, Ausrichtung und Verschattung.",
      "Panels, inverters and installation from a single team. We plan the layout to suit your roof, its orientation and any shading.",
    ),
    icon: Sun,
  },
  {
    id: "speicher",
    img: "/heros/two-technical-workers-in-an-industrial-warehouse.webp",
    title: tx("Batteriespeicher", "Battery Storage"),
    sub: tx("Sonne auch am Abend nutzen.", "Use your solar power in the evening too."),
    copy: tx(
      "Der Speicher hebt Ihren Eigenverbrauch spürbar an. Größe und Technik stimmen wir auf Ihren Haushalt ab, nicht auf ein Standardpaket.",
      "A battery noticeably raises how much of your own power you use. We match its size and technology to your household, not to a standard package.",
    ),
    icon: BatteryCharging,
  },
  {
    id: "waermepumpe",
    img: "/heros/niro-house.webp",
    title: tx("Wärmepumpe", "Heat Pump"),
    sub: tx("Heizen mit eigenem Strom.", "Heat with your own electricity."),
    copy: tx(
      "Wir prüfen, ob Ihr Gebäude passt, und bringen Wärmepumpe und Solaranlage sauber zusammen. Ein Gewerk, ein Ansprechpartner.",
      "We check whether your building is suitable and bring the heat pump and solar system together cleanly. One trade, one contact.",
    ),
    icon: Thermometer,
  },
  {
    id: "wallbox",
    img: "/heros/close-top-view-of-a-modern-car.webp",
    title: tx("Wallbox & Ladeinfrastruktur", "Wallbox & Charging"),
    sub: tx("Zuhause mit Sonne laden.", "Charge at home with sunshine."),
    copy: tx(
      "Von der einzelnen Wallbox bis zum Lastmanagement für mehrere Stellplätze. Geladen wird bevorzugt dann, wenn das Dach liefert.",
      "From a single wallbox to load management for several parking spaces. Charging happens preferably when the roof is producing.",
    ),
    icon: PlugZap,
  },
  {
    id: "wartung",
    img: "/heros/solar-cell-close-up-shot.webp",
    title: tx("Wartung & Monitoring", "Maintenance & Monitoring"),
    sub: tx("Anlage im Blick behalten.", "Keep an eye on your system."),
    copy: tx(
      "Wir überwachen Ertrag und Technik und melden uns, bevor Sie etwas merken. Regelmäßige Checks halten die Leistung oben.",
      "We monitor yield and hardware and get in touch before you notice a thing. Regular checks keep performance high.",
    ),
    icon: Gauge,
  },
  {
    id: "beratung",
    img: "/heros/natural-non-posed-scene-showing-a-worker.webp",
    title: tx("Beratung & Planung", "Advice & Planning"),
    sub: tx("Erst rechnen, dann bauen.", "Do the maths first, then build."),
    copy: tx(
      "Wir schauen uns Verbrauch, Dach und Förderung an und rechnen ehrlich durch, was sich lohnt. Ohne Verkaufsdruck.",
      "We look at your consumption, roof and available subsidies and work out honestly what pays off. No sales pressure.",
    ),
    icon: Compass,
  },
];

const makeStats = (tx: Tx) => [
  {
    key: "kwp",
    target: 18500,
    suffix: " kWp",
    group: true,
    label: tx(
      "installierte Leistung auf Dächern der Region",
      "installed capacity on roofs across the region",
    ),
  },
  {
    key: "co2",
    target: 7400,
    suffix: " t",
    group: true,
    label: tx(
      "eingespartes CO₂ pro Jahr über alle Anlagen",
      "CO₂ saved per year across all systems",
    ),
  },
  {
    key: "projects",
    target: 1200,
    suffix: "+",
    group: false,
    label: tx(
      "abgeschlossene Projekte für Haushalte und Betriebe",
      "completed projects for homes and businesses",
    ),
  },
  {
    key: "years",
    target: 15,
    suffix: "+",
    group: false,
    label: tx(
      "Jahre Erfahrung mit Solar und Speicher in Osnabrück",
      "years of experience with solar and storage in Osnabrück",
    ),
  },
];

const partners = [
  "Nordkontor Logistik",
  "Hofgut Emsblick",
  "Bäckerei Wandel",
  "Maschinenbau Terhaar",
  "Praxis am Wall",
  "Autohaus Kösters",
  "Wohnpark Lindenhof",
  "Tischlerei Brune",
];

const makeBenefits = (tx: Tx) => [
  {
    key: "team",
    icon: ShieldCheck,
    title: tx("Ein Team von Beratung bis Netzanschluss", "One team from advice to grid connection"),
    copy: tx(
      "Planung, Montage und Anmeldung liegen bei uns. Sie koordinieren keine Gewerke und telefonieren niemandem hinterher.",
      "Planning, installation and registration are all on us. You coordinate no trades and chase no one on the phone.",
    ),
  },
  {
    key: "economics",
    icon: Wallet,
    title: tx("Ehrliche Wirtschaftlichkeit", "Honest economics"),
    copy: tx(
      "Sie sehen vorher, wie sich Eigenverbrauch, Einspeisung und Förderung rechnen. Kein geschöntes Angebot, keine versteckten Posten.",
      "You see in advance how self consumption, feed in and subsidies add up. No rose tinted quote, no hidden items.",
    ),
  },
  {
    key: "yield",
    icon: TrendingUp,
    title: tx("Ertrag, der dokumentiert bleibt", "Yield that stays documented"),
    copy: tx(
      "Nach der Inbetriebnahme sehen Sie im Monitoring, was die Anlage leistet. Wir reagieren, bevor aus einer Kleinigkeit ein Ausfall wird.",
      "After commissioning you can see in the monitoring what your system delivers. We react before a small issue becomes an outage.",
    ),
  },
  {
    key: "contact",
    icon: Users,
    title: tx("Ein fester Ansprechpartner", "One dedicated contact"),
    copy: tx(
      "Eine Person kennt Ihr Projekt vom ersten Termin bis zur Wartung. Rückfragen landen direkt bei der richtigen Stelle.",
      "One person knows your project from the first appointment to maintenance. Questions land straight with the right person.",
    ),
  },
];

const makeFeaturedReview = (tx: Tx) => ({
  text: tx(
    "Von der ersten Rechnung bis zur Anmeldung beim Netzbetreiber lief alles über eine Person. Der Speicher wurde ehrlich dimensioniert, nicht schöngerechnet. Nach einem Jahr passt der Ertrag genau zur Prognose.",
    "From the first calculation to registering with the grid operator, everything ran through one person. The battery was sized honestly, not talked up. After a year the yield matches the forecast exactly.",
  ),
  name: tx("Familie Brinkmann", "The Brinkmann family"),
  role: tx("Einfamilienhaus, Georgsmarienhütte", "Single family home, Georgsmarienhütte"),
  img: "/portraits/portrait-morning-light.webp",
  stat: tx("68 %", "68%"),
  statLabel: tx(
    "Eigenverbrauch nach Speicher-Nachrüstung",
    "self consumption after adding a battery",
  ),
});

const makeReviews = (tx: Tx) => [
  {
    initials: "TB",
    img: "/portraits/portrait-korean-model.webp",
    name: "Tobias Berg",
    role: tx("Einfamilienhaus, Belm", "Single family home, Belm"),
    text: tx(
      "Termine wurden gehalten, das Dach war abends wieder sauber und die App lief vom ersten Tag. Beratung ohne Verkaufsdruck.",
      "Appointments were kept, the roof was clean again by the evening and the app worked from day one. Advice without any sales pressure.",
    ),
  },
  {
    initials: "SL",
    img: "/portraits/portrait-laughing-outdoor.webp",
    name: "Sabine Lührmann",
    role: tx("Bäckerei, Osnabrück", "Bakery, Osnabrück"),
    text: tx(
      "Die Anlage läuft parallel zum Betrieb, ohne dass wir schließen mussten. Der Eigenverbrauch am Tag deckt jetzt fast die Öfen.",
      "The system runs alongside our operation without us having to close. Daytime self consumption now covers almost all the ovens.",
    ),
  },
  {
    initials: "MK",
    img: "/portraits/portrait-business-men.webp",
    name: "Markus Köster",
    role: tx("Autohaus, Wallenhorst", "Car dealership, Wallenhorst"),
    text: tx(
      "Lastmanagement für die Wallboxen war genau unser Thema. Wir laden Vorführwagen jetzt, wenn das Dach am meisten liefert.",
      "Load management for the wallboxes was exactly our issue. We now charge demo cars when the roof produces the most.",
    ),
  },
  {
    initials: "AH",
    img: "/portraits/portrait-orange-curls.webp",
    name: "Anke Hügel",
    role: tx("Doppelhaus, Bad Iburg", "Semi detached house, Bad Iburg"),
    text: tx(
      "Wärmepumpe und Solar aus einer Hand, das hat die Abstimmung enorm vereinfacht. Ein Ansprechpartner für beides.",
      "Heat pump and solar from one team made coordination so much easier. One contact for both.",
    ),
  },
  {
    initials: "RP",
    img: "/portraits/portrait-elder-warm.webp",
    name: "Rüdiger Pohl",
    role: tx("Landwirtschaft, Bramsche", "Farm, Bramsche"),
    text: tx(
      "Große Scheunenfläche voll belegt, sauber geplant. Das Monitoring meldet sich, bevor ich selbst etwas bemerke.",
      "Large barn roof fully covered, cleanly planned. The monitoring alerts me before I even notice anything myself.",
    ),
  },
];

const makeCareerSteps = (tx: Tx) => [
  {
    icon: ClipboardList,
    label: tx("Stelle wählen", "Pick a role"),
    desc: tx("Such dir aus, was zu dir passt.", "Choose what fits you."),
  },
  {
    icon: MessageCircle,
    label: tx("Kurz melden", "Get in touch"),
    desc: tx("Ein paar Sätze reichen.", "A few sentences are enough."),
  },
  {
    icon: PhoneCall,
    label: tx("Direkt sprechen", "Talk to us directly"),
    desc: tx("Wir rufen am selben Werktag zurück.", "We call back the same working day."),
  },
];

const makeOpenRoles = (tx: Tx) => [
  {
    title: tx("Solarmonteur (m/w/d)", "Solar Installer (m/f/d)"),
    type: tx("Vollzeit", "Full time"),
    place: "Osnabrück",
  },
  {
    title: tx("Elektriker für Netzanschluss (m/w/d)", "Electrician for Grid Connection (m/f/d)"),
    type: tx("Vollzeit", "Full time"),
    place: "Osnabrück",
  },
  {
    title: tx("Projektplaner Photovoltaik (m/w/d)", "PV Project Planner (m/f/d)"),
    type: tx("Vollzeit", "Full time"),
    place: tx("Hybrid", "Hybrid"),
  },
];

const makeComparison = (tx: Tx) => ({
  others: [
    tx(
      "Wechselnde Subunternehmer ohne feste Zuständigkeit",
      "Changing subcontractors with no clear responsibility",
    ),
    tx(
      "Angebote, die Förderung und Eigenverbrauch schönrechnen",
      "Quotes that talk up subsidies and self consumption",
    ),
    tx(
      "Standardpakete statt Planung fürs eigene Dach",
      "Standard packages instead of planning for your own roof",
    ),
    tx(
      "Nach der Montage ist niemand mehr erreichbar",
      "After installation no one is reachable anymore",
    ),
    tx("Monitoring, das niemand anschaut", "Monitoring that no one ever looks at"),
  ],
  sonnwerk: [
    {
      icon: Users,
      text: tx(
        "Feste Teams, die vom ersten Termin bis zum Anschluss dabei sind",
        "Fixed teams that stay with you from the first appointment to connection",
      ),
    },
    {
      icon: Wallet,
      text: tx(
        "Ehrliche Rechnung mit Eigenverbrauch, Einspeisung und Förderung",
        "An honest calculation of self consumption, feed in and subsidies",
      ),
    },
    {
      icon: Compass,
      text: tx(
        "Belegung geplant für Dach, Ausrichtung und Verschattung",
        "A layout planned for your roof, orientation and shading",
      ),
    },
    {
      icon: PhoneCall,
      text: tx(
        "Ein persönlicher Ansprechpartner, auch nach der Übergabe",
        "A personal contact, even after handover",
      ),
    },
    {
      icon: Gauge,
      text: tx(
        "Aktives Monitoring, wir melden uns bei Abweichungen",
        "Active monitoring, we reach out when something deviates",
      ),
    },
  ],
});

type FaqGroup = {
  key: string;
  label: string;
  icon: LucideIcon;
  items: { q: string; a: string }[];
};

const makeFaqGroups = (tx: Tx): FaqGroup[] => [
  {
    key: "technik",
    label: tx("Leistungen & Technik", "Services & Technology"),
    icon: Sun,
    items: [
      {
        q: tx(
          "Für welche Dächer eignet sich eine Anlage?",
          "Which roofs are suitable for a system?",
        ),
        a: tx(
          "Wir belegen Schräg- und Flachdächer aus Ziegel, Trapezblech oder Bitumen. Entscheidend sind Ausrichtung, Neigung und Verschattung. Beim Vor-Ort-Termin prüfen wir, was sinnvoll möglich ist.",
          "We cover pitched and flat roofs made of tile, trapezoidal sheet or bitumen. What matters is orientation, pitch and shading. During the on site visit we check what makes sense.",
        ),
      },
      {
        q: tx("Lohnt sich ein Batteriespeicher für uns?", "Is a battery worth it for us?"),
        a: tx(
          "Ein Speicher hebt den Eigenverbrauch deutlich an, gerade wenn viel Strom abends gebraucht wird. Ob und in welcher Größe er sich rechnet, zeigen wir Ihnen anhand Ihres Lastprofils, ehrlich durchgerechnet.",
          "A battery raises self consumption considerably, especially when a lot of power is used in the evening. Whether and at what size it pays off, we show you based on your load profile, calculated honestly.",
        ),
      },
      {
        q: tx(
          "Bringt ihr Photovoltaik und Wärmepumpe zusammen?",
          "Do you combine photovoltaics and a heat pump?",
        ),
        a: tx(
          "Ja. Wir planen beides als ein Gewerk, damit Steuerung und Eigenverbrauch zusammenpassen. So heizen Sie einen möglichst großen Teil des Jahres mit Ihrem eigenen Strom.",
          "Yes. We plan both as one trade so that control and self consumption fit together. That way you heat with your own electricity for as much of the year as possible.",
        ),
      },
      {
        q: tx("Kann die Anlage später erweitert werden?", "Can the system be expanded later?"),
        a: tx(
          "Wir planen von Anfang an mit Reserve. Speicher nachrüsten, eine Wallbox ergänzen oder Module hinzufügen ist bei sauberer Auslegung ohne Umbau der Grundanlage möglich.",
          "We plan with headroom from the start. Adding a battery, a wallbox or more panels is possible with clean sizing, without rebuilding the core system.",
        ),
      },
    ],
  },
  {
    key: "ablauf",
    label: tx("Ablauf & Förderung", "Process & Subsidies"),
    icon: Compass,
    items: [
      {
        q: tx(
          "Wie läuft ein Projekt von der Anfrage bis zum Betrieb?",
          "How does a project run from enquiry to operation?",
        ),
        a: tx(
          "Auf die kostenlose Beratung folgt ein Angebot mit Ertragsprognose. Danach übernehmen wir Planung, Montage, Elektrik und die Anmeldung beim Netzbetreiber. Zum Schluss gibt es eine Einweisung in Ihr Monitoring.",
          "The free consultation is followed by a quote with a yield forecast. After that we handle planning, installation, wiring and registration with the grid operator. Finally we walk you through your monitoring.",
        ),
      },
      {
        q: tx("Wie schnell bekomme ich eine Rückmeldung?", "How quickly do I get a response?"),
        a: tx(
          "In der Regel melden wir uns am selben Werktag. Nach dem Vor-Ort-Termin erhalten Sie Ihr Angebot mit Prognose meist innerhalb weniger Tage.",
          "As a rule we get back to you the same working day. After the on site visit you usually receive your quote with a forecast within a few days.",
        ),
      },
      {
        q: tx(
          "Kümmert ihr euch um Anmeldung und Förderung?",
          "Do you take care of registration and subsidies?",
        ),
        a: tx(
          "Ja. Wir übernehmen die Anmeldung beim Netzbetreiber und im Marktstammdatenregister und weisen Sie auf passende Förderprogramme hin. Den Antrag stellen Sie, wir liefern die nötigen Unterlagen.",
          "Yes. We handle registration with the grid operator and the market master data register and point you to suitable subsidy programmes. You submit the application, we supply the necessary documents.",
        ),
      },
      {
        q: tx("Wie lange dauert die Montage?", "How long does installation take?"),
        a: tx(
          "Ein Einfamilienhaus ist meist in ein bis zwei Tagen montiert. Der Netzanschluss hängt vom Netzbetreiber ab. Den zeitlichen Rahmen nennen wir Ihnen vorab, damit Sie planen können.",
          "A single family home is usually installed within one to two days. The grid connection depends on the grid operator. We tell you the timeframe in advance so you can plan.",
        ),
      },
    ],
  },
  {
    key: "kosten",
    label: tx("Kosten & Betrieb", "Cost & Operation"),
    icon: Wallet,
    items: [
      {
        q: tx("Was kostet eine Anlage ungefähr?", "Roughly what does a system cost?"),
        a: tx(
          "Der Preis hängt von Dachfläche, Modulzahl, Speicher und Elektrik ab. Sie erhalten ein festes Angebot mit klaren Positionen und einer Ertragsprognose, damit Sie die Wirtschaftlichkeit nachvollziehen können.",
          "The price depends on roof area, number of panels, battery and wiring. You receive a fixed quote with clear line items and a yield forecast so you can follow the economics.",
        ),
      },
      {
        q: tx("Gibt es lange Vertragsbindungen?", "Are there long contract commitments?"),
        a: tx(
          "Nein. Sie kaufen eine Anlage, keine Laufzeit. Wartung und Monitoring bieten wir separat an, ohne dass Sie sich dauerhaft binden müssen.",
          "No. You buy a system, not a term. We offer maintenance and monitoring separately, without any lasting commitment.",
        ),
      },
      {
        q: tx("Was passiert, wenn eine Störung auftritt?", "What happens if a fault occurs?"),
        a: tx(
          "Im Monitoring sehen wir Abweichungen oft, bevor Sie etwas bemerken. Ein Anruf genügt, Ihr fester Ansprechpartner kümmert sich zeitnah um die Klärung.",
          "In the monitoring we often see deviations before you notice anything. One call is enough, your dedicated contact sorts it out promptly.",
        ),
      },
      {
        q: tx("Wie viel Wartung braucht die Anlage?", "How much maintenance does the system need?"),
        a: tx(
          "Photovoltaik ist wartungsarm. Wir empfehlen einen regelmäßigen Check von Technik und Ertrag. Auf Wunsch übernehmen wir das dauerhaft für Sie.",
          "Photovoltaics needs little maintenance. We recommend a regular check of hardware and yield. On request we take that on for you permanently.",
        ),
      },
    ],
  },
];

const makeNavLinks = (tx: Tx) => [
  { label: tx("Leistungen", "Services"), href: "#leistungen" },
  { label: tx("Warum Sonnwerk", "Why Sonnwerk"), href: "#vorteile" },
  { label: tx("Stimmen", "Reviews"), href: "#stimmen" },
  { label: tx("Karriere", "Careers"), href: "#karriere" },
  { label: "FAQ", href: "#faq" },
];

/* ------------------------------------------------------------------ *
 * Header
 * ------------------------------------------------------------------ */

function LangToggle({ className = "" }: { className?: string }) {
  const { lang } = useSw();
  const { setLang } = useContext(SetLangCtx);
  return (
    <div className={`flex items-center gap-1 text-[13px] ${className}`}>
      {(["de", "en"] as const).map((l, i) => (
        <span key={l} className="flex items-center gap-1">
          {i > 0 && <span className="text-[#c3ccb9]">/</span>}
          <button
            type="button"
            onClick={() => setLang(l)}
            aria-pressed={lang === l}
            className="px-1 py-0.5 uppercase tracking-wide transition-colors"
            style={{
              color: lang === l ? ACCENT_DARK : MUTED,
              fontWeight: lang === l ? 600 : 400,
            }}
          >
            {l}
          </button>
        </span>
      ))}
    </div>
  );
}

function MobileMenu({ open, onClose }: { open: boolean; onClose: () => void }) {
  const { tx } = useSw();
  const navLinks = makeNavLinks(tx);
  return (
    <AnimatePresence>
      {open && (
        <motion.div
          className="fixed inset-0 z-50 bg-white"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.3, ease }}
        >
          <div className="flex h-[100svh] flex-col">
            <div className="container-editorial flex h-16 shrink-0 items-center justify-between">
              <span className="font-display text-[18px] tracking-tight" style={{ color: INK }}>
                {BRAND}
              </span>
              <div className="flex items-center gap-4">
                <LangToggle />
                <button
                  onClick={onClose}
                  aria-label={tx("Menü schließen", "Close menu")}
                  className="inline-flex h-11 w-11 items-center justify-center rounded-lg transition-colors"
                  style={{ background: TINT, color: INK }}
                >
                  <X strokeWidth={1.5} className="h-5 w-5" />
                </button>
              </div>
            </div>

            <div className="container-editorial flex-1 overflow-y-auto pb-6 pt-4">
              <ul className="flex flex-col gap-1">
                {navLinks.map((l, i) => (
                  <motion.li
                    key={l.href}
                    initial={{ opacity: 0, y: 10 }}
                    animate={{ opacity: 1, y: 0 }}
                    transition={{ duration: 0.4, ease, delay: 0.06 + i * 0.05 }}
                  >
                    <a
                      href={l.href}
                      onClick={onClose}
                      className="flex items-center justify-between rounded-xl py-4 font-display text-[21px] leading-tight transition-colors active:bg-[#f1f6e7]"
                      style={{ color: INK }}
                    >
                      {l.label}
                      <ArrowRight strokeWidth={1.5} className="h-4 w-4" style={{ color: MUTED }} />
                    </a>
                  </motion.li>
                ))}
              </ul>

              <motion.div
                initial={{ opacity: 0, y: 12 }}
                animate={{ opacity: 1, y: 0 }}
                transition={{ duration: 0.5, ease, delay: 0.4 }}
                className="mt-8 rounded-2xl p-5"
                style={{ background: TINT }}
              >
                <p className="text-[11px] uppercase tracking-[0.2em]" style={{ color: MUTED }}>
                  {tx("Direkt erreichbar", "Reach us directly")}
                </p>
                <div className="mt-4 space-y-3 text-[14px]" style={{ color: INK }}>
                  <a href={contact.phoneHref} className="flex items-center gap-3">
                    <Phone
                      strokeWidth={1.5}
                      className="h-4 w-4 shrink-0"
                      style={{ color: ACCENT_DARK }}
                    />
                    {contact.phoneDisplay}
                  </a>
                  <a href={contact.emailHref} className="flex items-center gap-3 break-all">
                    <Mail
                      strokeWidth={1.5}
                      className="h-4 w-4 shrink-0"
                      style={{ color: ACCENT_DARK }}
                    />
                    {contact.email}
                  </a>
                  <p className="flex items-start gap-3" style={{ color: MUTED }}>
                    <MapPin strokeWidth={1.5} className="mt-0.5 h-4 w-4 shrink-0" />
                    <span>
                      {contact.addressLine1}, {contact.addressLine2}
                    </span>
                  </p>
                </div>
              </motion.div>
            </div>

            <div
              className="container-editorial shrink-0 bg-white py-4"
              style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
            >
              <a
                href="#kontakt"
                onClick={onClose}
                className="flex w-full items-center justify-center gap-2 rounded-lg px-5 py-3.5 text-[14px] transition-colors"
                style={{ background: ACCENT, color: INK }}
              >
                {tx("Beratung anfragen", "Request advice")}
                <ArrowRight strokeWidth={1.5} className="h-4 w-4" />
              </a>
            </div>
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

function Header() {
  const { tx } = useSw();
  const navLinks = makeNavLinks(tx);
  const [open, setOpen] = useState(false);
  const [scrolled, setScrolled] = useState(false);
  const isMobile = useIsMobile();

  useEffect(() => {
    const onScroll = () => setScrolled(window.scrollY > 20);
    onScroll();
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, []);

  useEffect(() => {
    document.body.style.overflow = open ? "hidden" : "";
    return () => {
      document.body.style.overflow = "";
    };
  }, [open]);

  return (
    <>
      <header
        className={`fixed inset-x-0 top-0 z-40 transition-all duration-300 ${
          scrolled ? "bg-white/85 backdrop-blur-md" : "bg-transparent"
        }`}
      >
        <div className="container-editorial flex h-16 items-center justify-between md:h-20">
          <a
            href="#top"
            aria-label={tx(`${BRAND} Startseite`, `${BRAND} home`)}
            className="flex items-center gap-2"
          >
            <span
              className="flex h-8 w-8 items-center justify-center rounded-lg"
              style={{ background: ACCENT }}
            >
              <Sun strokeWidth={1.5} className="h-[18px] w-[18px]" style={{ color: INK }} />
            </span>
            <span
              className="font-display text-[19px] tracking-tight md:text-[21px]"
              style={{ color: INK }}
            >
              {BRAND}
            </span>
          </a>

          <nav
            className="hidden items-center gap-8 md:flex"
            aria-label={tx("Hauptnavigation", "Main navigation")}
          >
            {navLinks.map((l) => (
              <a
                key={l.href}
                href={l.href}
                className="text-[14px] transition-colors"
                style={{ color: MUTED }}
              >
                {l.label}
              </a>
            ))}
          </nav>

          <div className="flex items-center gap-3">
            <LangToggle className="hidden sm:flex" />
            <a
              href={contact.phoneHref}
              className="hidden items-center gap-2 text-[14px] transition-colors hover:opacity-70 lg:flex"
              style={{ color: INK }}
            >
              <Phone strokeWidth={1.6} className="h-4 w-4" style={{ color: ACCENT_DARK }} />
              {contact.phoneDisplay}
            </a>
            <a
              href="#kontakt"
              className="hidden items-center gap-2 rounded-lg px-4 py-2.5 text-[14px] transition-colors md:inline-flex"
              style={{ background: ACCENT, color: INK }}
            >
              {tx("Beratung anfragen", "Request advice")}
            </a>
            <button
              onClick={() => setOpen(true)}
              aria-label={tx("Menü öffnen", "Open menu")}
              className="inline-flex h-11 w-11 items-center justify-center rounded-lg transition-colors md:hidden"
              style={{ background: TINT, color: INK }}
            >
              <Menu strokeWidth={1.5} className="h-5 w-5" />
            </button>
          </div>
        </div>
      </header>

      {isMobile && <MobileMenu open={open} onClose={() => setOpen(false)} />}
    </>
  );
}

/* ------------------------------------------------------------------ *
 * Hero
 * ------------------------------------------------------------------ */

function ServiceLoopCard({ s }: { s: Service }) {
  const Icon = s.icon;
  return (
    <article className="relative overflow-hidden rounded-2xl">
      <img src={s.img} alt={s.title} loading="lazy" className="h-[230px] w-full object-cover" />
      <div
        aria-hidden
        className="absolute inset-0"
        style={{
          background:
            "linear-gradient(to top, rgba(20,26,17,0.78) 0%, rgba(20,26,17,0.28) 46%, rgba(20,26,17,0.05) 100%)",
        }}
      />
      <div className="absolute inset-x-0 bottom-0 p-5">
        <span
          className="flex h-9 w-9 items-center justify-center rounded-lg"
          style={{ background: "rgba(255,255,255,0.16)", color: "#ffffff" }}
        >
          <Icon strokeWidth={1.45} className="h-[18px] w-[18px]" />
        </span>
        <h3 className="mt-3 font-display text-[17px] leading-snug text-white">{s.title}</h3>
        <p className="mt-1 text-[13px] leading-relaxed text-white/75">{s.sub}</p>
      </div>
    </article>
  );
}

function LoopColumn({ items, variant }: { items: Service[]; variant: "up" | "down" }) {
  return (
    <div className="sw-loop-mask h-[520px] overflow-hidden">
      <div className={`sw-loop-track sw-loop-track--${variant}`}>
        {[0, 1, 2].map((set) => (
          <div className="flex flex-col gap-4" key={set} aria-hidden={set > 0}>
            {items.map((s, i) => (
              <ServiceLoopCard s={s} key={i} />
            ))}
          </div>
        ))}
      </div>
    </div>
  );
}

function Hero() {
  const { tx } = useSw();
  const services = makeServices(tx);
  const heroDown = [services[0], services[2], services[4]];
  const heroUp = [services[3], services[1], services[5]];

  return (
    <section id="top" className="relative overflow-hidden bg-white pt-28 md:pt-36">
      <div className="container-editorial relative">
        <div className="grid grid-cols-12 items-center gap-y-12 gap-x-8 pb-16 md:pb-20">
          <div className="col-span-12 lg:col-span-6">
            <motion.div
              initial={{ opacity: 0, y: 12 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: 0.8, ease }}
              className="inline-flex items-center gap-2 rounded-full px-3.5 py-1.5 text-[13px]"
              style={{ background: TINT, color: ACCENT_DARK }}
            >
              <Leaf strokeWidth={1.45} className="h-3.5 w-3.5" />
              {tx("Solar & Speicher · Osnabrück", "Solar & Storage · Osnabrück")}
            </motion.div>

            <h1
              className="mt-6 font-display text-[38px] leading-[1.04] sm:text-[52px] md:text-[62px] lg:text-[66px]"
              style={{ color: INK }}
            >
              <RevealWords text={tx("Ihr Dach macht", "Your roof clocks")} />
              <br />
              <span style={{ color: ACCENT_DARK }}>
                <RevealWords
                  text={tx("ab morgen Feierabend.", "off from tomorrow.")}
                  stagger={0.06}
                />
              </span>
            </h1>

            <motion.p
              initial={{ opacity: 0, y: 14 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: 0.9, ease, delay: 0.35 }}
              className="mt-6 max-w-[46ch] text-[15px] leading-relaxed md:text-[17px]"
              style={{ color: MUTED }}
            >
              {tx(
                "Wir planen und bauen Photovoltaik, Speicher und Wärmepumpe für Haushalte und Betriebe in Osnabrück. Ehrlich gerechnet, sauber montiert und aus einer Hand betreut.",
                "We plan and build photovoltaics, storage and heat pumps for homes and businesses in Osnabrück. Honestly calculated, cleanly installed and looked after from a single source.",
              )}
            </motion.p>

            <motion.div
              initial={{ opacity: 0, y: 14 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: 0.9, ease, delay: 0.5 }}
              className="mt-8 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center"
            >
              <a
                href="#kontakt"
                className="group inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3.5 text-[15px] transition-colors"
                style={{ background: ACCENT, color: INK }}
              >
                {tx("Beratung anfragen", "Request advice")}
                <ArrowRight
                  strokeWidth={1.45}
                  className="h-4 w-4 transition-transform group-hover:translate-x-0.5"
                />
              </a>
              <a
                href={contact.phoneHref}
                className="group inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3.5 text-[15px] transition-colors hover:opacity-80"
                style={{ background: TINT, color: INK }}
              >
                <Phone strokeWidth={1.45} className="h-4 w-4" style={{ color: ACCENT_DARK }} />
                {contact.phoneDisplay}
              </a>
            </motion.div>

            <motion.a
              href="#stimmen"
              initial={{ opacity: 0, y: 14 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{ duration: 0.9, ease, delay: 0.62 }}
              className="mt-8 inline-flex items-center gap-3"
              aria-label={tx("Google-Bewertung ansehen", "View Google rating")}
            >
              <span
                className="flex h-9 w-9 items-center justify-center rounded-full text-[13px] font-medium"
                style={{ background: TINT, color: INK }}
              >
                G
              </span>
              <span className="flex items-baseline gap-2">
                <span className="font-display text-[18px]" style={{ color: INK }}>
                  <CountUp target={4.9} decimals={1} />
                </span>
                <Stars size={15} />
              </span>
              <span className="text-[13px]" style={{ color: MUTED }}>
                {tx("aus 214 Google-Bewertungen", "from 214 Google reviews")}
              </span>
            </motion.a>
          </div>

          <Reveal className="col-span-12 lg:col-span-6" delay={0.25} y={28}>
            <div className="hidden grid-cols-2 gap-4 md:grid">
              <LoopColumn items={heroDown} variant="down" />
              <div className="pt-10">
                <LoopColumn items={heroUp} variant="up" />
              </div>
            </div>
            <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:hidden">
              {services.slice(0, 4).map((s) => (
                <ServiceLoopCard s={s} key={s.id} />
              ))}
            </div>
          </Reveal>
        </div>

        <Reveal y={28} className="pb-16 md:pb-24">
          <ParallaxImage
            src={IMG.heroHouse}
            alt={tx(
              "Modernes Wohnhaus mit Photovoltaik-Anlage auf dem Dach",
              "Modern home with a rooftop photovoltaic system",
            )}
            className="aspect-[16/10] md:aspect-[2.5/1]"
          />
        </Reveal>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * Statement (per-word colour reveal on scroll)
 * ------------------------------------------------------------------ */

function Statement() {
  const { tx } = useSw();
  const STATEMENT_TEXT = tx(
    "Sonnwerk plant Solaranlagen, die sich rechnen und laufen, ohne dass Sie sie im Blick behalten müssen. Ehrlich kalkuliert, sauber montiert und aus einer Hand betreut.",
    "Sonnwerk plans solar systems that pay off and just run, without you having to keep an eye on them. Honestly calculated, cleanly installed and looked after from a single source.",
  );
  const ref = useRef<HTMLElement>(null);
  const [progress, setProgress] = useState(0);
  const words = STATEMENT_TEXT.split(" ");

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setProgress(1);
      return;
    }
    let raf = 0;
    const compute = () => {
      const rect = el.getBoundingClientRect();
      const span = Math.max(rect.height - window.innerHeight, 1);
      setProgress(Math.min(Math.max(-rect.top / span, 0), 1));
      raf = 0;
    };
    const onScroll = () => {
      if (!raf) raf = requestAnimationFrame(compute);
    };
    compute();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      cancelAnimationFrame(raf);
    };
  }, [words.length]);

  const from = [200, 209, 190];
  const to = [27, 36, 24];

  return (
    // The run up is shorter on the phone. At 220vh the sentence was fully
    // uncovered long before the end of the section, and almost a whole screen of
    // empty white followed. That read like a fault.
    <section
      className="relative h-[150vh] bg-white md:h-[220vh]"
      aria-labelledby="statement-title"
      ref={ref}
    >
      <div className="sticky top-0 flex h-screen items-center">
        <div className="container-editorial">
          <div
            className="inline-flex items-center gap-2 text-[12px] uppercase tracking-[0.2em]"
            style={{ color: ACCENT_DARK }}
          >
            <Target strokeWidth={1.45} className="h-3.5 w-3.5" />
            {tx("Unser Anspruch", "Our standard")}
          </div>
          <h2
            id="statement-title"
            className="mt-6 max-w-[24ch] font-display text-[28px] leading-[1.22] sm:text-[38px] md:text-[52px] lg:text-[58px]"
            aria-label={STATEMENT_TEXT}
          >
            {words.map((word, n) => {
              const r = n / words.length;
              const raw = Math.min(Math.max((progress - r * 0.62) * 4, 0), 1);
              const t = raw * raw * (3 - 2 * raw);
              const color = from.map((c, k) => Math.round(c + (to[k] - c) * t));
              return (
                <span
                  aria-hidden="true"
                  style={{ color: `rgb(${color[0]}, ${color[1]}, ${color[2]})` }}
                  key={n}
                >
                  {word}{" "}
                </span>
              );
            })}
          </h2>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * Proof (stats + logo band)
 * ------------------------------------------------------------------ */

function Proof() {
  const { tx } = useSw();
  const stats = makeStats(tx);
  return (
    <section
      className="py-16 md:py-28"
      style={{ background: SURFACE }}
      aria-labelledby="proof-title"
    >
      <div className="container-editorial">
        <Reveal className="max-w-2xl">
          <div
            className="inline-flex items-center gap-2 text-[12px] uppercase tracking-[0.2em]"
            style={{ color: ACCENT_DARK }}
          >
            <Sparkles strokeWidth={1.45} className="h-3.5 w-3.5" />
            {tx("Sonnwerk in Zahlen", "Sonnwerk in numbers")}
          </div>
          <h2
            id="proof-title"
            className="mt-5 font-display text-[28px] leading-[1.1] sm:text-[34px] md:text-[44px]"
            style={{ color: INK }}
          >
            {tx("Sonne lässt sich sehen. Und rechnen.", "Sunshine you can see. And count on.")}
          </h2>
          <p className="mt-5 text-[15px] leading-relaxed md:text-[16px]" style={{ color: MUTED }}>
            {tx(
              "Klare Planung, saubere Montage und Anlagen, die Jahr für Jahr das liefern, was wir vorher versprochen haben.",
              "Clear planning, clean installation and systems that deliver year after year what we promised upfront.",
            )}
          </p>
        </Reveal>

        <div className="mt-12 grid grid-cols-2 gap-4 md:mt-16 md:grid-cols-4 md:gap-5">
          {stats.map((s, i) => (
            <Reveal key={s.key} delay={i * 0.08}>
              <div className="h-full rounded-2xl bg-white p-5 md:p-7">
                {/* Without a break and at 30px, "18,500 kWp" ran out of the card on the phone
                    and pushed over the card next to it. The unit may now move to the second
                    line. */}
                <p
                  className="font-display text-[24px] leading-[1.1] tabular-nums sm:text-[34px] md:text-[42px]"
                  style={{ color: INK }}
                >
                  <CountUp target={s.target} suffix={s.suffix} group={s.group} />
                </p>
                <p
                  className="mt-4 max-w-[24ch] text-[13.5px] leading-relaxed"
                  style={{ color: MUTED }}
                >
                  {s.label}
                </p>
              </div>
            </Reveal>
          ))}
        </div>

        <div className="mt-16 md:mt-20">
          <p
            className="text-center text-[12px] uppercase tracking-[0.2em]"
            style={{ color: MUTED }}
          >
            {tx(
              "Betriebe und Höfe, die auf Sonnwerk setzen",
              "Businesses and farms that rely on Sonnwerk",
            )}
          </p>
          <div className="sw-loop-mask mt-8 overflow-hidden">
            <div className="sw-marquee">
              {[0, 1, 2].map((set) => (
                <div className="flex shrink-0" key={set} aria-hidden={set > 0}>
                  {partners.map((name, i) => (
                    <span
                      key={i}
                      className="mx-5 whitespace-nowrap font-display text-[18px] sm:mx-8 sm:text-[22px]"
                      style={{ color: "#98a290" }}
                    >
                      {name}
                    </span>
                  ))}
                </div>
              ))}
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * Services overview (two indexed lists around a central CTA)
 * ------------------------------------------------------------------ */

function ServiceIndexItem({ s }: { s: Service }) {
  const Icon = s.icon;
  return (
    <a
      href="#kontakt"
      className="group flex items-center gap-4 rounded-xl px-3 py-4 transition-colors hover:bg-muted"
    >
      <span
        className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl"
        style={{ background: TINT, color: ACCENT_DARK }}
      >
        <Icon strokeWidth={1.55} className="h-[22px] w-[22px]" />
      </span>
      <span className="flex-1">
        <h3 className="font-display text-[17px] leading-snug" style={{ color: INK }}>
          {s.title}
        </h3>
        <p className="mt-0.5 text-[13px]" style={{ color: MUTED }}>
          {s.sub}
        </p>
      </span>
      <ArrowUpRight
        strokeWidth={1.6}
        className="h-4 w-4 shrink-0 transition-all group-hover:-translate-y-0.5 group-hover:translate-x-0.5"
        style={{ color: "#9aa48d" }}
      />
    </a>
  );
}

function ServicesOverview() {
  const { tx } = useSw();
  const services = makeServices(tx);
  return (
    <section id="leistungen" className="bg-white py-16 md:py-28" aria-labelledby="services-title">
      <div className="container-editorial">
        <Reveal className="mx-auto max-w-2xl text-center">
          <div
            className="inline-flex items-center gap-2 text-[12px] uppercase tracking-[0.2em]"
            style={{ color: ACCENT_DARK }}
          >
            <Sun strokeWidth={1.45} className="h-3.5 w-3.5" />
            {tx("Leistungen", "Services")}
          </div>
          <h2
            id="services-title"
            className="mt-5 font-display text-[28px] leading-[1.1] sm:text-[34px] md:text-[44px]"
            style={{ color: INK }}
          >
            {tx(
              "Ein Partner für Strom, Speicher und Wärme.",
              "One partner for power, storage and heat.",
            )}
          </h2>
          <p className="mt-5 text-[15px] leading-relaxed md:text-[16px]" style={{ color: MUTED }}>
            {tx(
              "Sonnwerk verbindet die passende Technik mit sauberer Planung, einem eingespielten Team und klaren Zuständigkeiten.",
              "Sonnwerk combines the right technology with clean planning, a well practised team and clear responsibilities.",
            )}
          </p>
        </Reveal>

        <div className="mt-14 grid grid-cols-1 items-center gap-8 lg:grid-cols-[1fr_auto_1fr] lg:gap-6">
          <Reveal className="order-2 lg:order-1">
            <div>
              {services.slice(0, 3).map((s) => (
                <ServiceIndexItem s={s} key={s.id} />
              ))}
            </div>
          </Reveal>

          <Reveal className="order-1 lg:order-2" delay={0.1}>
            <ParallaxImage
              src={IMG.solarCell}
              alt={tx(
                "Solarmodule auf einem Dach in der Nachmittagssonne",
                "Solar panels on a roof in the afternoon sun",
              )}
              className="h-[300px] w-full sm:h-[360px] lg:h-[420px] lg:w-[340px]"
            />
          </Reveal>

          <Reveal className="order-3" delay={0.15}>
            <div>
              {services.slice(3, 6).map((s) => (
                <ServiceIndexItem s={s} key={s.id} />
              ))}
            </div>
          </Reveal>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * Benefits + featured review
 * ------------------------------------------------------------------ */

function Benefits() {
  const { tx } = useSw();
  const benefits = makeBenefits(tx);
  const featuredReview = makeFeaturedReview(tx);
  return (
    <section
      id="vorteile"
      className="py-16 md:py-28"
      style={{ background: SURFACE }}
      aria-labelledby="benefits-title"
    >
      <div className="container-editorial">
        <div className="grid grid-cols-12 gap-y-12 gap-x-8 lg:gap-x-12">
          <div className="col-span-12 lg:col-span-7">
            <Reveal>
              <div
                className="inline-flex items-center gap-2 text-[12px] uppercase tracking-[0.2em]"
                style={{ color: ACCENT_DARK }}
              >
                <ShieldCheck strokeWidth={1.45} className="h-3.5 w-3.5" />
                {tx("Warum Sonnwerk", "Why Sonnwerk")}
              </div>
              <h2
                id="benefits-title"
                className="mt-5 max-w-[20ch] font-display text-[28px] leading-[1.1] sm:text-[34px] md:text-[44px]"
                style={{ color: INK }}
              >
                {tx(
                  "Damit Ihre Anlage kein Projekt für nebenbei wird.",
                  "So your system never becomes a side project.",
                )}
              </h2>
              <p
                className="mt-5 max-w-[48ch] text-[15px] leading-relaxed md:text-[16px]"
                style={{ color: MUTED }}
              >
                {tx(
                  "Vier Dinge, die Ihnen Planung, Bau und Betrieb abnehmen.",
                  "Four things that take planning, construction and operation off your hands.",
                )}
              </p>
            </Reveal>

            <div className="mt-10 grid gap-x-8 gap-y-9 sm:grid-cols-2 md:mt-12">
              {benefits.map((b, i) => {
                const Icon = b.icon;
                return (
                  <Reveal key={b.key} delay={i * 0.06}>
                    <div>
                      <span
                        className="flex h-11 w-11 items-center justify-center rounded-xl"
                        style={{ background: TINT, color: ACCENT_DARK }}
                      >
                        <Icon strokeWidth={1.55} className="h-[22px] w-[22px]" />
                      </span>
                      <h3
                        className="mt-5 font-display text-[18px] leading-snug"
                        style={{ color: INK }}
                      >
                        {b.title}
                      </h3>
                      <p className="mt-2 text-[14px] leading-relaxed" style={{ color: MUTED }}>
                        {b.copy}
                      </p>
                    </div>
                  </Reveal>
                );
              })}
            </div>
          </div>

          <div className="col-span-12 lg:col-span-5">
            <Reveal delay={0.1} y={28}>
              <figure className="flex h-full flex-col overflow-hidden rounded-3xl bg-white">
                <img
                  src={IMG.roofClose}
                  alt={tx(
                    "Nahaufnahme von Solarmodulen und Dachkante",
                    "Close up of solar panels and the roof edge",
                  )}
                  loading="lazy"
                  className="h-44 w-full object-cover md:h-52"
                />
                <div className="flex flex-1 flex-col justify-between p-8 md:p-10">
                  <div>
                    <Stars size={17} />
                    <blockquote
                      className="mt-6 font-display text-[20px] leading-[1.35] md:text-[23px]"
                      style={{ color: INK }}
                    >
                      „{featuredReview.text}"
                    </blockquote>
                  </div>
                  <div className="mt-8">
                    <div className="rounded-2xl px-6 py-5" style={{ background: TINT }}>
                      <p
                        className="font-display text-[32px] leading-none"
                        style={{ color: ACCENT_DARK }}
                      >
                        {featuredReview.stat}
                      </p>
                      <p className="mt-2 text-[13px]" style={{ color: MUTED }}>
                        {featuredReview.statLabel}
                      </p>
                    </div>
                    <figcaption className="mt-6 flex items-center gap-3">
                      <img
                        src={featuredReview.img}
                        alt={featuredReview.name}
                        loading="lazy"
                        className="h-11 w-11 shrink-0 rounded-full object-cover"
                      />
                      <span>
                        <span className="block text-[14px] font-medium" style={{ color: INK }}>
                          {featuredReview.name}
                        </span>
                        <span className="block text-[13px]" style={{ color: MUTED }}>
                          {featuredReview.role}
                        </span>
                      </span>
                    </figcaption>
                  </div>
                </div>
              </figure>
            </Reveal>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * Sonnenlauf: ein Tag auf dem Dach (GSAP)
 * ------------------------------------------------------------------ */

// Braucht GSAP: npm i gsap
//
// Der einzige Abschnitt der Seite, der GSAP braucht, und zwar aus zwei
// Gruenden. ScrollTrigger haelt die Buehne fest und haengt den Tagesverlauf
// ans Rad statt an eine Uhr: man scrollt durch den Tag, und man kann mitten am
// Vormittag stehen bleiben und lesen. Und MotionPathPlugin setzt die Sonne auf
// den Himmelsbogen, indem es den Pfad selbst vermisst. Von Hand muesste man
// die Bahn ein zweites Mal als Formel fuehren und bei jeder Fensterbreite neu
// rechnen; hier steht sie einmal im SVG, und die Sonne haelt sich daran.

const TAG_VON = 5; // Sonnenaufgang, in Stunden
const TAG_BIS = 21; // Sonnenuntergang
const SPITZE_KW = 7.4; // Momentanleistung im Maximum
const TAG_KWH = 46.2; // Ertrag ueber den ganzen Tag

// Masse der Buehne im viewBox. Der Boden ist die Traufkante, links und rechts
// stehen Sonnenaufgang und Sonnenuntergang.
const SL_LINKS = 96;
const SL_RECHTS = 904;
const SL_BODEN = 372;

/**
 * Die Ertragskurve als Formel statt als abgetippter Pfad. Dieselbe Kurve
 * zeichnet die Flaeche und rechnet die Zahlen daneben aus, Bild und Werte
 * koennen also nicht auseinanderlaufen.
 */
function glocke(p: number): number {
  const t = Math.min(Math.max(p, 0), 1);
  return Math.pow(Math.sin(Math.PI * t), 1.35);
}

/**
 * Aus einer Formel ein Pfad mit echten Tangenten (Hermite als Bezier
 * geschrieben). An den Uebergaengen passen damit nicht nur die Punkte,
 * sondern auch die Richtungen, und die Kurve hat nirgends einen Knick.
 */
function ausFormel(f: (p: number) => number, hoch: number, stuecke = 16): string {
  const X = (p: number) => SL_LINKS + (SL_RECHTS - SL_LINKS) * p;
  const Y = (p: number) => SL_BODEN - hoch * f(p);
  const h = 1e-4;
  const dY = (p: number) => {
    const a = Math.max(0, p - h);
    const b = Math.min(1, p + h);
    return (Y(b) - Y(a)) / (b - a);
  };
  const dX = SL_RECHTS - SL_LINKS;
  const z = (n: number) => n.toFixed(1);
  let d = `M ${z(X(0))} ${z(Y(0))}`;
  for (let i = 0; i < stuecke; i++) {
    const p0 = i / stuecke;
    const p1 = (i + 1) / stuecke;
    const dp = p1 - p0;
    d +=
      ` C ${z(X(p0) + (dp / 3) * dX)} ${z(Y(p0) + (dp / 3) * dY(p0))},` +
      ` ${z(X(p1) - (dp / 3) * dX)} ${z(Y(p1) - (dp / 3) * dY(p1))},` +
      ` ${z(X(p1))} ${z(Y(p1))}`;
  }
  return d;
}

const ERTRAG_PFAD = ausFormel(glocke, 232);
const HIMMEL_PFAD = ausFormel((p) => Math.sin(Math.PI * p), 300);

// Der Tagesertrag ist die Flaeche unter der Kurve bis zu diesem Punkt, nicht
// die Kurve selbst. Einmal beim Laden aufsummiert, danach nur nachgeschlagen.
const SL_SUMME = (() => {
  const n = 200;
  const werte: number[] = [0];
  let s = 0;
  for (let i = 1; i <= n; i++) {
    s += glocke((i - 0.5) / n);
    werte.push(s);
  }
  return werte.map((v) => v / s);
})();

const anteil = (p: number) => SL_SUMME[Math.round(Math.min(Math.max(p, 0), 1) * 200)];

const uhrzeit = (p: number) => {
  const stunden = TAG_VON + (TAG_BIS - TAG_VON) * Math.min(Math.max(p, 0), 1);
  const h = Math.floor(stunden);
  const m = Math.round((stunden - h) * 60);
  return `${String(m === 60 ? h + 1 : h).padStart(2, "0")}:${String(m === 60 ? 0 : m).padStart(2, "0")}`;
};

function Sonnenlauf() {
  const { tx } = useSw();
  const wurzel = useRef<HTMLElement>(null);
  const buehne = useRef<HTMLDivElement>(null);
  const sonne = useRef<SVGGElement>(null);
  const kern = useRef<SVGCircleElement>(null);
  const bahn = useRef<SVGPathElement>(null);
  const fenster = useRef<SVGRectElement>(null);
  const uhr = useRef<HTMLSpanElement>(null);
  const kw = useRef<HTMLSpanElement>(null);
  const kwh = useRef<HTMLSpanElement>(null);
  const kennung = useId().replace(/:/g, "");

  useEffect(() => {
    const el = wurzel.current;
    const buehneEl = buehne.current;
    const sonneEl = sonne.current;
    const bahnEl = bahn.current;
    const fensterEl = fenster.current;
    if (!el || !buehneEl || !sonneEl || !bahnEl || !fensterEl) return;

    gsap.registerPlugin(MotionPathPlugin, ScrollTrigger);

    const breite = SL_RECHTS - SL_LINKS;
    const schreiben = (p: number) => {
      if (uhr.current) uhr.current.textContent = uhrzeit(p);
      if (kw.current) kw.current.textContent = (SPITZE_KW * glocke(p)).toFixed(1);
      if (kwh.current) kwh.current.textContent = (TAG_KWH * anteil(p)).toFixed(1);
    };

    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      gsap.set(fensterEl, { attr: { width: breite } });
      gsap.set(sonneEl, { xPercent: -50, yPercent: -50, x: SL_RECHTS, y: SL_BODEN });
      schreiben(1);
      return;
    }

    const ctx = gsap.context(() => {
      // Am Schreibtisch bleibt die Buehne stehen und der Tag laeuft am Rad.
      // Auf dem Telefon nicht: dort nimmt eine festgehaltene Flaeche die ganze
      // Ansicht ein, und der Daumen kommt an nichts anderes mehr heran.
      const mm = gsap.matchMedia(el);

      const zeitachse = (fest: boolean) => {
        const tl = gsap.timeline({
          scrollTrigger: {
            trigger: fest ? buehneEl : el,
            start: fest ? "top top" : "top 78%",
            end: fest ? "+=150%" : "bottom 60%",
            scrub: 0.8,
            pin: fest ? buehneEl : false,
            invalidateOnRefresh: true,
            onUpdate: (t) => schreiben(t.progress),
          },
        });

        // Die Sonne haengt am Bogen, nicht an gerechneten Koordinaten:
        // MotionPath misst den Pfad und setzt sie darauf.
        tl.fromTo(
          sonneEl,
          {
            motionPath: { path: bahnEl, align: bahnEl, alignOrigin: [0.5, 0.5], start: 0, end: 0 },
          },
          {
            motionPath: { path: bahnEl, align: bahnEl, alignOrigin: [0.5, 0.5], start: 0, end: 1 },
            ease: "none",
            duration: 1,
          },
          0,
        );

        // Die Ertragsflaeche waechst mit: ein Fenster, das aufgeht, kein
        // zweiter Pfad, der sich zeichnet. So bleibt die Flaeche gefuellt.
        tl.fromTo(
          fensterEl,
          { attr: { width: 0 } },
          { attr: { width: breite }, ease: "none", duration: 1 },
          0,
        );

        // Morgens und abends steht die Sonne tief und warm, mittags hell.
        tl.fromTo(
          kern.current,
          { fill: "#e08b2a" },
          { fill: "#ffc94d", ease: "none", duration: 0.5 },
          0,
        );
        tl.to(kern.current, { fill: "#e08b2a", ease: "none", duration: 0.5 }, 0.5);

        return tl;
      };

      mm.add("(min-width: 768px)", () => {
        const tl = zeitachse(true);
        return () => tl.scrollTrigger?.kill();
      });
      mm.add("(max-width: 767px)", () => {
        const tl = zeitachse(false);
        return () => tl.scrollTrigger?.kill();
      });

      schreiben(0);
    }, el);

    return () => ctx.revert();
  }, []);

  const stunden = [
    { p: 0, label: tx("Aufgang", "Sunrise") },
    { p: 0.5, label: tx("Mittag", "Midday") },
    { p: 1, label: tx("Untergang", "Sunset") },
  ];

  return (
    <section ref={wurzel} className="relative bg-white" aria-labelledby="sonnenlauf-title">
      {/* Die Buehne ist genau eine Bildschirmhoehe hoch, nicht mindestens.
          Vorher stand hier min-h-screen, und der Inhalt war mit gut tausend
          Pixeln hoeher als ein normales Notebook-Fenster: das untere Ende der
          Zeichnung lag waehrend der Animation ausserhalb des Bildes. Jetzt
          teilt sich die Hoehe auf Text und Zeichnung auf, und die Zeichnung
          nimmt, was uebrig bleibt. */}
      <div ref={buehne} className="overflow-hidden py-16 md:h-[100svh] md:py-0">
        {/* Oben mehr Luft als unten: die Kopfzeile der Seite steht fest und
            liegt sonst auf dem Kicker. */}
        <div className="container-editorial flex h-full flex-col justify-center gap-8 md:gap-10 md:pt-[104px] md:pb-10 lg:pt-[112px] lg:pb-14">
          <div className="grid gap-8 lg:grid-cols-12 lg:items-end lg:gap-16">
            <div className="lg:col-span-5">
              <div
                className="inline-flex items-center gap-2 text-[12px] uppercase tracking-[0.2em]"
                style={{ color: ACCENT_DARK }}
              >
                <Sun strokeWidth={1.45} className="h-3.5 w-3.5" />
                {tx("Ein Tag auf dem Dach", "A day on the roof")}
              </div>
              <h2
                id="sonnenlauf-title"
                className="mt-6 max-w-[16ch] font-display text-[28px] leading-[1.18] sm:text-[38px] lg:text-[46px]"
              >
                {tx("Von Aufgang bis Untergang", "From sunrise to sunset")}
              </h2>
              <p className="mt-6 max-w-[46ch] text-[15px] leading-relaxed" style={{ color: MUTED }}>
                {tx(
                  "Eine Anlage mit 9,8 kWp auf einem Satteldach nach Süden, aufgezeichnet an einem klaren Julitag. Scrollen Sie durch den Tag: die Fläche unter der Kurve ist der Ertrag, der oben steht.",
                  "A 9.8 kWp system on a south facing gable roof, recorded on a clear day in July. Scroll through the day: the area under the curve is the yield shown above.",
                )}
              </p>
            </div>

            <div className="lg:col-span-7">
              <dl className="grid grid-cols-3 gap-4 sm:gap-8">
                {[
                  {
                    wert: uhr,
                    label: tx("Uhrzeit", "Time of day"),
                    einheit: "",
                    start: uhrzeit(0),
                  },
                  {
                    wert: kw,
                    label: tx("Momentan", "Right now"),
                    einheit: "kW",
                    start: "0.0",
                  },
                  {
                    wert: kwh,
                    label: tx("Seit Aufgang", "Since sunrise"),
                    einheit: "kWh",
                    start: "0.0",
                  },
                ].map((feld) => (
                  <div key={feld.label}>
                    <dt
                      className="text-[11px] uppercase tracking-[0.18em]"
                      style={{ color: `${INK}8c` }}
                    >
                      {feld.label}
                    </dt>
                    <dd className="mt-2 font-display text-[26px] leading-none sm:text-[38px]">
                      <span ref={feld.wert} className="tabular-nums">
                        {feld.start}
                      </span>
                      {feld.einheit ? (
                        <span className="ml-1 text-[13px] sm:text-[15px]" style={{ color: MUTED }}>
                          {feld.einheit}
                        </span>
                      ) : null}
                    </dd>
                  </div>
                ))}
              </dl>
            </div>
          </div>

          {/* Auf dem Schreibtisch bekommt die Zeichnung den Rest der Hoehe und
              skaliert sich hinein (preserveAspectRatio meet). Auf dem Telefon
              behaelt sie ihr eigenes Verhaeltnis, dort wird nichts
              festgehalten. */}
          <div className="md:min-h-0 md:flex-1">
            <svg
              viewBox="0 0 1000 468"
              preserveAspectRatio="xMidYMid meet"
              className="block w-full md:h-full"
              role="img"
              aria-label={tx(
                "Der Tagesverlauf einer Solaranlage: die Sonne wandert über den Himmel, darunter wächst die Ertragskurve.",
                "The daily curve of a solar system: the sun travels across the sky and the yield curve grows underneath.",
              )}
            >
              <defs>
                <clipPath id={`sl-fenster-${kennung}`}>
                  <rect ref={fenster} x={SL_LINKS} y="0" width="0" height="468" />
                </clipPath>
                <linearGradient id={`sl-flaeche-${kennung}`} x1="0" y1="0" x2="0" y2="1">
                  <stop offset="0" stopColor={ACCENT} stopOpacity="0.32" />
                  <stop offset="1" stopColor={ACCENT} stopOpacity="0.04" />
                </linearGradient>
                <radialGradient id={`sl-hof-${kennung}`}>
                  <stop offset="0.42" stopColor={AMBER} stopOpacity="0.34" />
                  <stop offset="1" stopColor={AMBER} stopOpacity="0" />
                </radialGradient>
              </defs>

              {/* Der Himmelsbogen liegt nur da, damit die Sonne etwas hat,
                  woran sie sich halten kann. Sichtbar ist er als feine
                  gestrichelte Spur. */}
              <path
                ref={bahn}
                d={HIMMEL_PFAD}
                fill="none"
                stroke={INK}
                strokeOpacity="0.14"
                strokeWidth="1"
                strokeDasharray="2 9"
                strokeLinecap="round"
              />

              {/* Die volle Ertragskurve blass im Hintergrund: man sieht, wohin
                  der Tag noch geht. */}
              <path
                d={ERTRAG_PFAD}
                fill="none"
                stroke={ACCENT}
                strokeOpacity="0.22"
                strokeWidth="1.6"
              />

              <g clipPath={`url(#sl-fenster-${kennung})`}>
                <path
                  d={`${ERTRAG_PFAD} L ${SL_RECHTS} ${SL_BODEN} L ${SL_LINKS} ${SL_BODEN} Z`}
                  fill={`url(#sl-flaeche-${kennung})`}
                />
                <path
                  d={ERTRAG_PFAD}
                  fill="none"
                  stroke={ACCENT}
                  strokeWidth="2.2"
                  strokeLinecap="round"
                />
              </g>

              {/* Die Traufkante mit einer Reihe Module darauf. */}
              <line
                x1="60"
                y1={SL_BODEN}
                x2="940"
                y2={SL_BODEN}
                stroke={INK}
                strokeOpacity="0.18"
                strokeWidth="1"
              />
              {Array.from({ length: 9 }).map((_, i) => {
                const x = 322 + i * 40;
                return (
                  <path
                    key={i}
                    d={`M ${x} ${SL_BODEN + 82} L ${x + 26} ${SL_BODEN + 48} L ${x + 60} ${SL_BODEN + 48} L ${x + 34} ${SL_BODEN + 82} Z`}
                    fill={GREEN_DEEP}
                    fillOpacity={0.09 + (i % 3) * 0.03}
                  />
                );
              })}

              {stunden.map((s) => {
                const x = SL_LINKS + (SL_RECHTS - SL_LINKS) * s.p;
                return (
                  <g key={s.label}>
                    <line
                      x1={x}
                      y1={SL_BODEN}
                      x2={x}
                      y2={SL_BODEN + 10}
                      stroke={INK}
                      strokeOpacity="0.25"
                      strokeWidth="1"
                    />
                    {/* Auf dem Telefon faellt die Beschriftung weg. Das SVG ist
                        dort auf ein gutes Drittel geschrumpft, die Schrift
                        waere fuenf Pixel hoch, und die Uhrzeit steht ohnehin
                        gross ueber der Zeichnung. */}
                    <text
                      className="hidden sm:block"
                      x={x}
                      y={SL_BODEN + 32}
                      textAnchor={s.p === 0 ? "start" : s.p === 1 ? "end" : "middle"}
                      fontSize="15"
                      fill={INK}
                      fillOpacity="0.5"
                      style={{ letterSpacing: "0.08em" }}
                    >
                      {s.label}
                    </text>
                  </g>
                );
              })}

              {/* Die Sonne. Der Hof ist ein zweiter, weicher Kreis, kein
                  Filter: ein Blur ueber der ganzen Flaeche kostet auf dem
                  Telefon mehr, als der Schein wert ist. */}
              <g ref={sonne}>
                <circle r="46" fill={`url(#sl-hof-${kennung})`} />
                <circle ref={kern} r="12" fill="#e08b2a" />
              </g>
            </svg>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * Reviews (horizontal drag slider)
 * ------------------------------------------------------------------ */

function Reviews() {
  const { tx } = useSw();
  const reviews = makeReviews(tx);
  const sliderRef = useRef<HTMLDivElement>(null);
  const reduceMotion = useReducedMotion();

  useEffect(() => {
    const el = sliderRef.current;
    if (!el) return;
    let down = false;
    let startX = 0;
    let startLeft = 0;
    let moved = false;
    const onDown = (e: PointerEvent) => {
      if (e.pointerType !== "mouse") return;
      down = true;
      moved = false;
      startX = e.clientX;
      startLeft = el.scrollLeft;
      el.classList.add("cursor-grabbing");
    };
    const onMove = (e: PointerEvent) => {
      if (!down) return;
      const dx = e.clientX - startX;
      if (Math.abs(dx) > 4) moved = true;
      el.scrollLeft = startLeft - dx;
    };
    const onUp = () => {
      down = false;
      el.classList.remove("cursor-grabbing");
    };
    const onClick = (e: MouseEvent) => {
      if (moved) {
        e.preventDefault();
        e.stopPropagation();
      }
    };
    el.addEventListener("pointerdown", onDown);
    window.addEventListener("pointermove", onMove);
    window.addEventListener("pointerup", onUp);
    el.addEventListener("click", onClick, true);
    return () => {
      el.removeEventListener("pointerdown", onDown);
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
      el.removeEventListener("click", onClick, true);
    };
  }, []);

  return (
    <section id="stimmen" className="bg-white py-16 md:py-28" aria-labelledby="reviews-title">
      <div className="container-editorial">
        <div className="flex flex-col justify-between gap-6 sm:flex-row sm:items-end">
          <Reveal>
            <div
              className="inline-flex items-center gap-2 text-[12px] uppercase tracking-[0.2em]"
              style={{ color: ACCENT_DARK }}
            >
              <Star strokeWidth={1.45} className="h-3.5 w-3.5" />
              {tx("Stimmen aus der Region", "Voices from the region")}
            </div>
            <h2
              id="reviews-title"
              className="mt-5 max-w-[22ch] font-display text-[28px] leading-[1.1] sm:text-[34px] md:text-[44px]"
              style={{ color: INK }}
            >
              {tx("Was sich rechnet, spricht sich herum.", "What pays off gets around.")}
            </h2>
          </Reveal>
          <Reveal delay={0.08}>
            <div
              className="flex items-center gap-3 rounded-2xl px-5 py-4"
              style={{ background: TINT }}
            >
              <span
                className="flex h-9 w-9 items-center justify-center rounded-full bg-white text-[13px] font-medium"
                style={{ color: INK }}
              >
                G
              </span>
              <span>
                <span className="flex items-center gap-2">
                  <strong className="font-display text-[17px]" style={{ color: INK }}>
                    {tx("4,9", "4.9")}
                  </strong>
                  <Stars size={14} />
                </span>
                <span className="text-[12.5px]" style={{ color: MUTED }}>
                  {tx("214 Google-Bewertungen", "214 Google reviews")}
                </span>
              </span>
            </div>
          </Reveal>
        </div>
      </div>

      <div
        ref={sliderRef}
        className="mt-10 flex cursor-grab gap-5 overflow-x-auto px-5 pb-4 [scrollbar-width:none] sm:px-6 md:mt-12 md:px-10 [&::-webkit-scrollbar]:hidden"
      >
        {reviews.map((r, i) => (
          <motion.article
            key={i}
            initial={{ opacity: 0, y: reduceMotion ? 0 : 20 }}
            whileInView={{ opacity: 1, y: 0 }}
            // Ohne den eingezogenen Rand: die Karten stehen nebeneinander in
            // einem waagerechten Streifen. Auf dem Handy ragte die zweite nur
            // ein Stueck ins Bild, lag damit im eingezogenen Rand und blieb
            // auf opacity 0. Neben der ersten Karte stand dann eine leere
            // weisse Flaeche statt der angeschnittenen naechsten.
            viewport={{ once: true, amount: 0.02 }}
            transition={{
              duration: reduceMotion ? 0 : 0.7,
              ease,
              delay: reduceMotion ? 0 : i * 0.07,
            }}
            className="w-[300px] shrink-0 rounded-2xl p-7 sm:w-[360px]"
            style={{ background: SURFACE }}
          >
            <Stars size={15} />
            <p className="mt-5 text-[15px] leading-relaxed" style={{ color: "#3c4736" }}>
              {r.text}
            </p>
            <footer className="mt-7 flex items-center gap-3">
              <img
                src={r.img}
                alt={r.name}
                loading="lazy"
                className="h-11 w-11 shrink-0 rounded-full object-cover"
              />
              <span>
                <span className="block text-[14px] font-medium" style={{ color: INK }}>
                  {r.name}
                </span>
                <span className="block text-[13px]" style={{ color: MUTED }}>
                  {r.role}
                </span>
              </span>
            </footer>
          </motion.article>
        ))}
        <div className="w-1 shrink-0" aria-hidden />
      </div>
      <p className="container-editorial mt-2 text-[13px]" style={{ color: "#98a290" }}>
        {tx("Zum Blättern seitwärts wischen", "Swipe sideways to browse")}
      </p>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * Careers
 * ------------------------------------------------------------------ */

function Careers() {
  const { tx } = useSw();
  const careerSteps = makeCareerSteps(tx);
  const openRoles = makeOpenRoles(tx);
  return (
    <section
      id="karriere"
      className="relative isolate overflow-hidden py-16 text-white md:py-28"
      style={{ background: DARK }}
      aria-labelledby="careers-title"
    >
      <img
        src={IMG.workers}
        alt={tx("Zwei Monteure in einer Industriehalle", "Two technicians in an industrial hall")}
        loading="lazy"
        className="absolute inset-0 -z-10 h-full w-full object-cover"
      />
      <div
        aria-hidden
        className="absolute inset-0 -z-10"
        style={{
          background:
            "linear-gradient(to right, rgba(15,20,12,0.92) 0%, rgba(15,20,12,0.72) 42%, rgba(15,20,12,0.4) 100%)",
        }}
      />
      <div className="container-editorial">
        <div className="grid grid-cols-12 gap-y-12 gap-x-8 lg:gap-x-16">
          <div className="col-span-12 lg:col-span-6">
            <Reveal>
              <div
                className="inline-flex items-center gap-2 text-[12px] uppercase tracking-[0.2em]"
                style={{ color: ACCENT_LIGHT }}
              >
                <Users strokeWidth={1.45} className="h-3.5 w-3.5" />
                {tx("Arbeiten bei Sonnwerk", "Working at Sonnwerk")}
              </div>
              <h2
                id="careers-title"
                className="mt-5 max-w-[18ch] font-display text-[28px] leading-[1.08] sm:text-[34px] md:text-[46px]"
              >
                {tx(
                  "Kein Bewerbungsmarathon. Einfach anfangen.",
                  "No application marathon. Just get started.",
                )}
              </h2>
              <p className="mt-5 max-w-[46ch] text-[15px] leading-relaxed text-white/75 md:text-[16px]">
                {tx(
                  "Du willst faire Arbeit, planbare Wochen und ein Team, das direkt mit dir spricht? Dann lern uns in wenigen Minuten kennen.",
                  "You want fair work, predictable weeks and a team that talks to you directly? Then get to know us in just a few minutes.",
                )}
              </p>

              <div className="mt-8 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
                <a
                  href="#kontakt"
                  className="group inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3.5 text-[15px] transition-colors"
                  style={{ background: ACCENT, color: INK }}
                >
                  {tx("In 60 Sekunden bewerben", "Apply in 60 seconds")}
                  <ArrowRight
                    strokeWidth={1.45}
                    className="h-4 w-4 transition-transform group-hover:translate-x-0.5"
                  />
                </a>
                <a
                  href={contact.phoneHref}
                  className="inline-flex items-center justify-center gap-2 rounded-lg bg-white/10 px-6 py-3.5 text-[15px] text-white transition-colors hover:bg-muted/15"
                >
                  <Phone strokeWidth={1.45} className="h-4 w-4" />
                  {tx("Direkt anrufen", "Call us directly")}
                </a>
              </div>

              <ul className="mt-10 space-y-3">
                {openRoles.map((role, i) => (
                  <Reveal as="li" key={role.title} delay={i * 0.06}>
                    <a
                      href="#kontakt"
                      className="group flex items-center justify-between gap-4 rounded-xl bg-white/[0.08] px-5 py-4 transition-colors hover:bg-muted/[0.14]"
                    >
                      <span>
                        <span className="block text-[15px] font-medium text-white">
                          {role.title}
                        </span>
                        <span className="mt-0.5 block text-[13px] text-white/60">
                          {role.type} · {role.place}
                        </span>
                      </span>
                      <ArrowUpRight
                        strokeWidth={1.6}
                        className="h-5 w-5 shrink-0 text-white/45 transition-all group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-white"
                      />
                    </a>
                  </Reveal>
                ))}
              </ul>
            </Reveal>
          </div>

          <div className="col-span-12 lg:col-span-5 lg:col-start-8">
            <Reveal delay={0.1} y={28}>
              <div
                className="rounded-3xl p-8 text-white md:p-10"
                style={{ background: GREEN_DEEP }}
              >
                <p className="text-[12px] uppercase tracking-[0.2em] text-white/60">
                  {tx("Dein schnellster Weg zu uns", "Your fastest route to us")}
                </p>
                <ol className="mt-8 space-y-7">
                  {careerSteps.map((step, i) => {
                    const Icon = step.icon;
                    return (
                      <li key={step.label} className="flex items-start gap-4">
                        <span
                          className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl"
                          style={{ background: "rgba(255,255,255,0.12)", color: ACCENT_LIGHT }}
                        >
                          <Icon strokeWidth={1.6} className="h-[22px] w-[22px]" />
                        </span>
                        <span className="pt-0.5">
                          <span className="flex items-center gap-2 text-[15px] font-medium text-white">
                            <span className="text-white/45">0{i + 1}</span>
                            {step.label}
                          </span>
                          <span className="mt-1 block text-[13.5px] text-white/65">
                            {step.desc}
                          </span>
                        </span>
                      </li>
                    );
                  })}
                </ol>
                <div className="mt-9 pt-2">
                  <a
                    href="#kontakt"
                    className="flex items-center justify-between text-[14px] text-white/85 transition-opacity hover:opacity-70"
                  >
                    {tx("Alle offenen Stellen ansehen", "View all open positions")}
                    <ArrowRight strokeWidth={1.6} className="h-4 w-4" />
                  </a>
                </div>
              </div>
            </Reveal>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * Comparison
 * ------------------------------------------------------------------ */

function Comparison() {
  const { tx } = useSw();
  const comparison = makeComparison(tx);
  return (
    <section className="bg-white py-16 md:py-28" aria-labelledby="comparison-title">
      <div className="container-editorial">
        <Reveal className="mx-auto max-w-2xl text-center">
          <div
            className="inline-flex items-center gap-2 text-[12px] uppercase tracking-[0.2em]"
            style={{ color: ACCENT_DARK }}
          >
            <Sparkles strokeWidth={1.45} className="h-3.5 w-3.5" />
            {tx("Der Unterschied", "The difference")}
          </div>
          <h2
            id="comparison-title"
            className="mt-5 font-display text-[28px] leading-[1.1] sm:text-[34px] md:text-[44px]"
            style={{ color: INK }}
          >
            {tx(
              "Warum Haushalte und Betriebe zu Sonnwerk wechseln.",
              "Why homes and businesses switch to Sonnwerk.",
            )}
          </h2>
          <p className="mt-5 text-[15px] leading-relaxed md:text-[16px]" style={{ color: MUTED }}>
            {tx(
              "Damit Ihre Anlage planbar bleibt, statt ein Thema zu werden, um das Sie sich ständig selbst kümmern.",
              "So your system stays predictable, instead of becoming something you constantly have to manage yourself.",
            )}
          </p>
        </Reveal>

        <div className="mt-14 grid grid-cols-1 gap-5 md:grid-cols-2">
          <Reveal>
            <article className="h-full rounded-3xl p-8 md:p-10" style={{ background: SURFACE }}>
              <h3 className="text-[15px] font-medium" style={{ color: MUTED }}>
                {tx("Andere Anbieter", "Other providers")}
              </h3>
              <ul className="mt-7 space-y-4">
                {comparison.others.map((p) => (
                  <li key={p} className="flex items-start gap-3">
                    <span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[#e3e6e1]">
                      <X strokeWidth={1.6} className="h-3 w-3" style={{ color: "#98a290" }} />
                    </span>
                    <span className="text-[14.5px] leading-relaxed" style={{ color: MUTED }}>
                      {p}
                    </span>
                  </li>
                ))}
              </ul>
            </article>
          </Reveal>

          <Reveal delay={0.1}>
            <article
              className="h-full rounded-3xl p-8 text-white md:p-10"
              style={{ background: GREEN_DEEP }}
            >
              <h3 className="flex items-center gap-2 text-[15px] font-medium">
                <Sun strokeWidth={1.45} className="h-4 w-4" style={{ color: ACCENT_LIGHT }} />
                {BRAND}
              </h3>
              <ul className="mt-7 space-y-5">
                {comparison.sonnwerk.map((p) => {
                  const Icon = p.icon;
                  return (
                    <li key={p.text} className="flex items-start gap-3.5">
                      <span
                        className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl"
                        style={{ background: "rgba(255,255,255,0.12)", color: ACCENT_LIGHT }}
                      >
                        <Icon strokeWidth={1.45} className="h-[18px] w-[18px]" />
                      </span>
                      <span className="pt-1 text-[14.5px] leading-relaxed text-white/90">
                        {p.text}
                      </span>
                    </li>
                  );
                })}
              </ul>
            </article>
          </Reveal>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * FAQ
 * ------------------------------------------------------------------ */

function Faq() {
  const { tx } = useSw();
  const faqGroups = makeFaqGroups(tx);
  const [tab, setTab] = useState(0);
  const [openIndex, setOpenIndex] = useState(0);
  const group = faqGroups[tab];
  const reduce = useReducedMotion();

  return (
    <section
      id="faq"
      className="py-16 md:py-28"
      style={{ background: SURFACE }}
      aria-labelledby="faq-title"
    >
      <div className="container-editorial">
        <Reveal className="max-w-2xl">
          <div
            className="inline-flex items-center gap-2 text-[12px] uppercase tracking-[0.2em]"
            style={{ color: ACCENT_DARK }}
          >
            <HelpCircle strokeWidth={1.45} className="h-3.5 w-3.5" />
            {tx("Häufige Fragen", "Frequently asked")}
          </div>
          <h2
            id="faq-title"
            className="mt-5 font-display text-[28px] leading-[1.1] sm:text-[34px] md:text-[44px]"
            style={{ color: INK }}
          >
            {tx(
              "Was vor dem ersten Termin oft gefragt wird.",
              "What people often ask before the first appointment.",
            )}
          </h2>
        </Reveal>

        <Reveal className="mt-10 flex flex-wrap gap-2.5 md:mt-12" delay={0.05}>
          {faqGroups.map((g, i) => {
            const Icon = g.icon;
            const active = i === tab;
            return (
              <button
                key={g.key}
                type="button"
                onClick={() => {
                  setTab(i);
                  setOpenIndex(0);
                }}
                className="inline-flex items-center gap-2 rounded-full px-4 py-2.5 text-[14px] transition-colors"
                style={
                  active
                    ? { background: ACCENT, color: INK }
                    : { background: "#ffffff", color: MUTED }
                }
              >
                <Icon strokeWidth={1.45} className="h-4 w-4" />
                {g.label}
              </button>
            );
          })}
        </Reveal>

        <Reveal
          className="mt-8 grid grid-cols-1 gap-x-16 gap-y-4 lg:grid-cols-[1fr_auto]"
          delay={0.1}
        >
          <div className="max-w-3xl space-y-3">
            {group.items.map((f, i) => {
              const isOpen = openIndex === i;
              return (
                <div key={f.q} className="overflow-hidden rounded-2xl bg-white">
                  <button
                    type="button"
                    aria-expanded={isOpen}
                    onClick={() => setOpenIndex(isOpen ? -1 : i)}
                    className="flex w-full items-center justify-between gap-6 px-5 py-5 text-left md:px-6"
                  >
                    <span
                      className="font-display text-[16px] leading-snug md:text-[18px]"
                      style={{ color: INK }}
                    >
                      {f.q}
                    </span>
                    <span
                      className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors"
                      style={
                        isOpen
                          ? { background: ACCENT, color: INK }
                          : { background: TINT, color: ACCENT_DARK }
                      }
                    >
                      <span className="block h-[1.5px] w-3.5 bg-current" />
                      <span
                        className={`absolute block h-3.5 w-[1.5px] bg-current transition-transform duration-300 ${
                          isOpen ? "scale-y-0" : "scale-y-100"
                        }`}
                      />
                    </span>
                  </button>
                  <AnimatePresence initial={false}>
                    {isOpen && (
                      <motion.div
                        initial={{ height: 0, opacity: 0 }}
                        animate={{ height: "auto", opacity: 1 }}
                        exit={{ height: 0, opacity: 0 }}
                        transition={{ duration: reduce ? 0 : 0.35, ease }}
                        className="overflow-hidden"
                      >
                        <p
                          className="px-5 pb-6 pr-12 text-[14.5px] leading-relaxed md:px-6"
                          style={{ color: MUTED }}
                        >
                          {f.a}
                        </p>
                      </motion.div>
                    )}
                  </AnimatePresence>
                </div>
              );
            })}
          </div>

          <aside className="lg:w-[280px]">
            <div className="rounded-2xl bg-white p-6">
              <p className="font-display text-[17px] leading-snug" style={{ color: INK }}>
                {tx("Ihre Frage ist nicht dabei?", "Your question not here?")}
              </p>
              <p className="mt-2 text-[13.5px] leading-relaxed" style={{ color: MUTED }}>
                {tx(
                  "Ein kurzes Gespräch reicht meistens, um Ihren Bedarf einzuordnen.",
                  "A short conversation is usually enough to gauge what you need.",
                )}
              </p>
              <a
                href={contact.phoneHref}
                className="mt-5 inline-flex items-center gap-2 text-[15px] font-medium"
                style={{ color: ACCENT_DARK }}
              >
                <Phone strokeWidth={1.45} className="h-4 w-4" />
                {contact.phoneDisplay}
              </a>
            </div>
          </aside>
        </Reveal>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * Contact
 * ------------------------------------------------------------------ */

const labelCls = "block text-[11px] uppercase tracking-[0.18em] text-[#1b2418]/55 mb-3";
const inputCls =
  "w-full border-0 border-b border-[#1b2418]/15 bg-transparent px-0 py-2.5 text-[16px] text-[#1b2418] outline-none transition-colors placeholder:text-[#1b2418]/35 focus:border-[#74b32c]";

function Contact() {
  const { tx } = useSw();
  const [submitted, setSubmitted] = useState(false);

  const onSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setSubmitted(true);
  };

  return (
    <section id="kontakt" className="bg-white py-16 md:py-28">
      <div className="container-editorial">
        <div className="grid grid-cols-12 gap-y-12 gap-x-8 lg:gap-x-16">
          <Reveal className="col-span-12 lg:col-span-5">
            <div className="flex h-full flex-col">
              <div
                className="inline-flex w-fit items-center gap-2 text-[12px] uppercase tracking-[0.2em]"
                style={{ color: ACCENT_DARK }}
              >
                <Leaf strokeWidth={1.45} className="h-3.5 w-3.5" />
                {tx("Kontakt", "Contact")}
              </div>
              <h2
                className="mt-5 font-display text-[30px] leading-[1.06] sm:text-[40px] md:text-[50px]"
                style={{ color: INK }}
              >
                {tx("Bereit für Ihr eigenes Kraftwerk?", "Ready for your own power plant?")}
              </h2>
              <p
                className="mt-5 max-w-[40ch] text-[15px] leading-relaxed md:text-[16px]"
                style={{ color: MUTED }}
              >
                {tx(
                  "Schreiben Sie uns kurz, worum es geht. Wir melden uns am selben Werktag und vereinbaren einen kostenlosen Vor-Ort-Termin.",
                  "Drop us a line about what you need. We get back to you the same working day and arrange a free on site visit.",
                )}
              </p>

              <div className="mt-auto space-y-4 pt-10">
                <a
                  href={contact.emailHref}
                  className="group flex items-center gap-4 transition-colors hover:opacity-70"
                  style={{ color: INK }}
                >
                  <span
                    className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full"
                    style={{ background: TINT }}
                  >
                    <Mail
                      strokeWidth={1.6}
                      className="h-[18px] w-[18px]"
                      style={{ color: ACCENT_DARK }}
                    />
                  </span>
                  <span className="break-all text-[15px]">{contact.email}</span>
                </a>
                <a
                  href={contact.phoneHref}
                  className="group flex items-center gap-4 transition-colors hover:opacity-70"
                  style={{ color: INK }}
                >
                  <span
                    className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full"
                    style={{ background: TINT }}
                  >
                    <Phone
                      strokeWidth={1.6}
                      className="h-[18px] w-[18px]"
                      style={{ color: ACCENT_DARK }}
                    />
                  </span>
                  <span className="text-[15px]">{contact.phonePretty}</span>
                </a>
                <div className="flex items-start gap-4" style={{ color: INK }}>
                  <span
                    className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full"
                    style={{ background: TINT }}
                  >
                    <MapPin
                      strokeWidth={1.6}
                      className="h-[18px] w-[18px]"
                      style={{ color: ACCENT_DARK }}
                    />
                  </span>
                  <span className="text-[15px] leading-snug">
                    {contact.addressLine1}
                    <br />
                    {contact.addressLine2}
                  </span>
                </div>
              </div>
            </div>
          </Reveal>

          <Reveal className="col-span-12 lg:col-span-7" delay={0.1}>
            <div className="rounded-3xl p-6 sm:p-8 md:p-12" style={{ background: SURFACE }}>
              {submitted ? (
                <div className="flex min-h-[440px] flex-col items-center justify-center text-center">
                  <div
                    className="flex h-14 w-14 items-center justify-center rounded-full"
                    style={{ background: TINT }}
                  >
                    <ShieldCheck
                      strokeWidth={1.4}
                      className="h-6 w-6"
                      style={{ color: ACCENT_DARK }}
                    />
                  </div>
                  <h3 className="mt-6 font-display text-[24px]" style={{ color: INK }}>
                    {tx("Danke für Ihre Nachricht.", "Thanks for your message.")}
                  </h3>
                  <p
                    className="mt-3 max-w-[40ch] text-[15px] leading-relaxed"
                    style={{ color: MUTED }}
                  >
                    {tx(
                      "Wir melden uns in der Regel am selben Werktag bei Ihnen.",
                      "We usually get back to you the same working day.",
                    )}
                  </p>
                </div>
              ) : (
                <form onSubmit={onSubmit} className="space-y-7 sm:space-y-9">
                  <div className="grid grid-cols-2 gap-x-6 gap-y-7 sm:gap-x-8 sm:gap-y-9">
                    <div className="col-span-2 sm:col-span-1">
                      <label className={labelCls} htmlFor="sw-name">
                        {tx("Name", "Name")}
                      </label>
                      <input
                        id="sw-name"
                        name="name"
                        required
                        maxLength={100}
                        className={inputCls}
                        placeholder={tx("Vor- und Nachname", "First and last name")}
                      />
                    </div>
                    <div className="col-span-2 sm:col-span-1">
                      <label className={labelCls} htmlFor="sw-email">
                        {tx("E-Mail", "Email")}
                      </label>
                      <input
                        id="sw-email"
                        name="email"
                        type="email"
                        required
                        maxLength={255}
                        className={inputCls}
                        placeholder={tx("name@beispiel.de", "name@example.com")}
                      />
                    </div>
                    <div className="col-span-2 sm:col-span-1">
                      <label className={labelCls} htmlFor="sw-ort">
                        {tx("Ort", "Location")}{" "}
                        <span className="normal-case tracking-normal text-[#1b2418]/40">
                          {tx("(optional)", "(optional)")}
                        </span>
                      </label>
                      <input
                        id="sw-ort"
                        name="ort"
                        maxLength={120}
                        className={inputCls}
                        placeholder={tx("PLZ oder Stadt", "Postcode or city")}
                      />
                    </div>
                    <div className="col-span-2 sm:col-span-1">
                      <label className={labelCls} htmlFor="sw-thema">
                        {tx("Thema", "Topic")}{" "}
                        <span className="normal-case tracking-normal text-[#1b2418]/40">
                          {tx("(optional)", "(optional)")}
                        </span>
                      </label>
                      <input
                        id="sw-thema"
                        name="thema"
                        maxLength={120}
                        className={inputCls}
                        placeholder={tx("z.B. PV mit Speicher", "e.g. PV with storage")}
                      />
                    </div>
                    <div className="col-span-2">
                      <label className={labelCls} htmlFor="sw-nachricht">
                        {tx("Ihre Nachricht", "Your message")}
                      </label>
                      <textarea
                        id="sw-nachricht"
                        name="nachricht"
                        rows={3}
                        maxLength={1000}
                        className={inputCls + " resize-none"}
                        placeholder={tx(
                          "Worum geht es? Ein paar Sätze reichen.",
                          "What is it about? A few sentences are enough.",
                        )}
                      />
                    </div>
                  </div>

                  <div className="pt-2">
                    <button
                      type="submit"
                      className="group inline-flex items-center justify-center gap-2 rounded-lg px-7 py-4 text-[15px] transition-colors"
                      style={{ background: ACCENT, color: INK }}
                    >
                      {tx("Anfrage senden", "Send enquiry")}
                      <ArrowRight
                        strokeWidth={1.45}
                        className="h-4 w-4 transition-transform group-hover:translate-x-0.5"
                      />
                    </button>
                  </div>
                </form>
              )}
            </div>
          </Reveal>
        </div>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ *
 * Footer
 * ------------------------------------------------------------------ */

function Footer() {
  const { tx } = useSw();
  const footerCols = [
    {
      title: tx("Leistungen", "Services"),
      links: [
        tx("Aufdach-Photovoltaik", "Rooftop Photovoltaics"),
        tx("Batteriespeicher", "Battery Storage"),
        tx("Wärmepumpe", "Heat Pump"),
        tx("Wallbox & Ladeinfrastruktur", "Wallbox & Charging"),
      ],
    },
    {
      title: tx("Unternehmen", "Company"),
      links: [
        tx("Über uns", "About us"),
        tx("Referenzen", "References"),
        tx("Karriere", "Careers"),
        tx("Kontakt", "Contact"),
      ],
    },
  ];
  const legalLinks = [
    tx("Impressum", "Legal notice"),
    tx("Datenschutz", "Privacy"),
    tx("AGB", "Terms"),
  ];

  return (
    <footer
      className="rounded-t-[32px] text-white md:rounded-t-[44px]"
      style={{ background: DARK }}
    >
      <div className="container-editorial py-16 md:py-20">
        <div className="grid grid-cols-12 gap-y-12 gap-x-8 md:gap-x-10">
          <div className="col-span-12 md:col-span-4">
            <div className="flex items-center gap-2">
              <span
                className="flex h-8 w-8 items-center justify-center rounded-lg"
                style={{ background: ACCENT }}
              >
                <Sun strokeWidth={1.5} className="h-[18px] w-[18px]" style={{ color: INK }} />
              </span>
              <span className="font-display text-[22px] tracking-tight">{BRAND}</span>
            </div>
            <p className="mt-6 max-w-[34ch] text-[14px] leading-relaxed text-white/60">
              {tx(
                "Photovoltaik, Speicher und Wärme für Osnabrück und Umgebung. Persönlich beraten, sauber gebaut, dauerhaft betreut.",
                "Photovoltaics, storage and heat for Osnabrück and the surrounding area. Personal advice, clean construction, lasting support.",
              )}
            </p>
            <a
              href="#stimmen"
              className="mt-6 inline-flex items-center gap-3 rounded-full bg-white/[0.06] px-4 py-2.5"
            >
              <span
                className="flex h-7 w-7 items-center justify-center rounded-full bg-white text-[12px] font-medium"
                style={{ color: INK }}
              >
                G
              </span>
              <span className="font-display text-[14px]">{tx("4,9", "4.9")}</span>
              <Stars size={13} />
              <span className="text-[12.5px] text-white/55">
                {tx("214 Bewertungen", "214 reviews")}
              </span>
            </a>
          </div>

          {footerCols.map((c) => (
            <div key={c.title} className="col-span-6 md:col-span-2">
              <p className="text-[11px] uppercase tracking-[0.2em] text-white/45">{c.title}</p>
              <ul className="mt-5 space-y-3">
                {c.links.map((l) => (
                  <li key={l}>
                    <a
                      href="#kontakt"
                      className="text-[14px] text-white/75 transition-colors hover:text-white"
                    >
                      {l}
                    </a>
                  </li>
                ))}
              </ul>
            </div>
          ))}

          <div className="col-span-12 md:col-span-4">
            <p className="text-[11px] uppercase tracking-[0.2em] text-white/45">
              {tx("Kontakt", "Contact")}
            </p>
            <ul className="mt-5 space-y-4 text-[14px] text-white/80">
              <li>
                <a
                  href={contact.phoneHref}
                  className="flex items-start gap-3 transition-colors hover:text-white"
                >
                  <Phone
                    strokeWidth={1.6}
                    className="mt-0.5 h-4 w-4 shrink-0"
                    style={{ color: ACCENT_LIGHT }}
                  />
                  <span>{contact.phoneDisplay}</span>
                </a>
              </li>
              <li>
                <a
                  href={contact.emailHref}
                  className="flex items-start gap-3 break-all transition-colors hover:text-white"
                >
                  <Mail
                    strokeWidth={1.6}
                    className="mt-0.5 h-4 w-4 shrink-0"
                    style={{ color: ACCENT_LIGHT }}
                  />
                  <span>{contact.email}</span>
                </a>
              </li>
              <li className="flex items-start gap-3 leading-relaxed text-white/60">
                <MapPin
                  strokeWidth={1.6}
                  className="mt-0.5 h-4 w-4 shrink-0"
                  style={{ color: ACCENT_LIGHT }}
                />
                <span>
                  {contact.addressLine1}
                  <br />
                  {contact.addressLine2}
                </span>
              </li>
            </ul>
          </div>
        </div>

        <div className="mt-16 flex flex-col items-start justify-between gap-4 pt-8 text-[12.5px] text-white/50 sm:flex-row sm:items-center">
          <p>
            © {new Date().getFullYear()} {BRAND}{" "}
            {tx("Solar & Speicher GmbH", "Solar & Storage GmbH")}
          </p>
          <ul className="flex flex-wrap items-center gap-x-6 gap-y-2">
            {legalLinks.map((l) => (
              <li key={l}>
                <a href="#" className="transition-colors hover:text-white">
                  {l}
                </a>
              </li>
            ))}
          </ul>
        </div>
      </div>
    </footer>
  );
}

/* ------------------------------------------------------------------ *
 * Smooth in-page scrolling for hash links
 * ------------------------------------------------------------------ */

function useSmoothAnchors(rootRef: React.RefObject<HTMLDivElement | null>) {
  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const onClick = (e: MouseEvent) => {
      const target = e.target as HTMLElement | null;
      const anchor = target?.closest?.('a[href^="#"]') as HTMLAnchorElement | null;
      if (!anchor) return;
      const href = anchor.getAttribute("href");
      if (!href || href === "#") return;
      const id = href.slice(1);
      const el = document.getElementById(id);
      if (!el) return;
      e.preventDefault();
      el.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "start" });
    };
    root.addEventListener("click", onClick);
    return () => root.removeEventListener("click", onClick);
  }, [rootRef]);
}

/* ------------------------------------------------------------------ *
 * Page
 * ------------------------------------------------------------------ */

export function SolarSonnwerkPage() {
  const rootRef = useRef<HTMLDivElement>(null);
  useSmoothAnchors(rootRef);

  const [lang, setLang] = useState<Lang>("de");
  useEffect(() => {
    if (typeof document !== "undefined" && document.documentElement.lang.startsWith("en")) {
      setLang("en");
    }
  }, []);

  const swValue = useMemo(
    () => ({ lang, tx: (de: string, en: string) => (lang === "en" ? en : de) }),
    [lang],
  );
  const setLangValue = useMemo(() => ({ setLang }), []);

  return (
    <SetLangCtx.Provider value={setLangValue}>
      <SwCtx.Provider value={swValue}>
        <div ref={rootRef} className="sw-root min-h-screen bg-white" style={{ color: INK }}>
          <style>{scopedStyles}</style>
          <Header />
          <Hero />
          <Statement />
          <Proof />
          <ServicesOverview />
          <Benefits />
          <Sonnenlauf />
          <Reviews />
          <Careers />
          <Comparison />
          <Faq />
          <Contact />
          <Footer />
        </div>
      </SwCtx.Provider>
    </SetLangCtx.Provider>
  );
}

export default SolarSonnwerkPage;
FAQ

Frequently asked questions

A curated landing page made of matching library sections in a sensible order from hero to footer. You copy the sections one by one and assemble them with the composition snippet.

Didn't find your question? We're happy to help.

Get in touch