feat: German as the main language, English under /en, with a language switch

The site now exists in German (at the root, with /impressum and /datenschutz)
and English (/en). A request without a language follows the visitor's choice
from the language switch, then the browser language, then German; the old
English legal addresses move to /en for good.

All visible texts are data in src/content/de.ts and en.ts, so components hold
none of their own. The header carries a DE/EN switch whose bit curtain covers
the page while it is swapped and reopens at the same reading position. Title,
description, canonical, hreflang, Open Graph, the share image, the sitemap and
the structured data are per language. The privacy notice now describes the
cookie that remembers the chosen language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 13:07:22 +02:00
co-authored by Claude Opus 5.5
parent e89a248ca5
commit 75993100fa
72 changed files with 4418 additions and 1405 deletions
+7 -10
View File
@@ -3,16 +3,14 @@ import { Reveal } from "@/src/components/Reveal";
import { EmailLink } from "@/src/components/EmailLink";
import { BitAura } from "@/src/components/bits/BitAura";
import { BitSeam } from "@/src/components/bits/BitSeam";
import { BitHeadline, type BitLine } from "./BitHeadline";
import type { Content } from "@/src/content/types";
import { BitHeadline } from "./BitHeadline";
import { ContactComposer } from "./ContactComposer";
const CLOSING: readonly BitLine[] = [
{ text: "LET'S", tone: "bits" },
{ text: "TALK.", tone: "solid" },
];
/** ClosingCta: the contact section. One address and a message starter, no form to submit. */
export const ClosingCta: React.FC = () => (
export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
contact,
}) => (
<section
id="contact"
data-glow
@@ -33,12 +31,11 @@ export const ClosingCta: React.FC = () => (
<BitHeadline
as="h2"
invert
lines={CLOSING}
lines={contact.headline}
className="font-sans text-[19vw] font-black leading-[0.86] tracking-tighter md:text-[13vw] lg:text-[8.5vw] xl:text-[9.5rem]"
/>
<p className="m-0 mt-10 max-w-xl font-sans text-xl leading-snug text-slate-300 md:text-3xl">
Pick what you need and the email writes itself. Or write to me
directly:
{contact.lead}
</p>
<div className="mt-8">
<EmailLink className="inline-block border-b-2 border-white pb-2 font-sans text-2xl font-bold tracking-tight text-white no-underline transition-opacity hover:opacity-70 md:text-4xl" />
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { CONTACT_TOPICS } from "@/src/content/contact";
import { useContent } from "@/src/components/ContentProvider";
import { CONTACT_EMAIL_PARTS } from "@/src/content/site";
import { assembleEmail, mailtoHref, spellOutEmail } from "@/src/domain/email";
@@ -51,7 +51,9 @@ const withBlanks = (text: string): React.ReactNode =>
* is assembled in the browser, so it never appears in the page source.
*/
export const ContactComposer: React.FC = () => {
const [topicId, setTopicId] = React.useState(CONTACT_TOPICS[0]?.id ?? "");
const { contact } = useContent();
const topics = contact.topics;
const [topicId, setTopicId] = React.useState(topics[0]?.id ?? "");
const [address, setAddress] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
@@ -59,8 +61,7 @@ export const ContactComposer: React.FC = () => {
setAddress(assembleEmail(CONTACT_EMAIL_PARTS));
}, []);
const topic =
CONTACT_TOPICS.find((item) => item.id === topicId) ?? CONTACT_TOPICS[0];
const topic = topics.find((item) => item.id === topicId) ?? topics[0];
const typed = useTyped(topic?.draft.body ?? "");
if (!topic) return null;
@@ -87,18 +88,18 @@ export const ContactComposer: React.FC = () => {
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
</span>
<span className={`${MONO} text-slate-400`}>New message</span>
<span className={`${MONO} text-slate-400`}>{contact.newMessage}</span>
</div>
<span className={`${MONO} flex items-center gap-2 text-slate-500`}>
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400" />
draft
{contact.draft}
</span>
</div>
<div className="border-b border-slate-800 px-5 py-5 md:px-7">
<p className={`${MONO} m-0 text-slate-500`}>I need</p>
<p className={`${MONO} m-0 text-slate-500`}>{contact.need}</p>
<div className="mt-3 flex flex-wrap gap-2">
{CONTACT_TOPICS.map((item) => {
{topics.map((item) => {
const selected = item.id === topic.id;
return (
<button
@@ -121,13 +122,13 @@ export const ContactComposer: React.FC = () => {
<dl className="m-0 border-b border-slate-800 px-5 py-4 font-mono text-[12px] leading-relaxed md:px-7 md:text-[13px]">
<div className="flex gap-4">
<dt className="w-16 shrink-0 text-slate-500">to</dt>
<dt className="w-16 shrink-0 text-slate-500">{contact.to}</dt>
<dd className="m-0 select-all text-slate-200">
{address ?? spellOutEmail(CONTACT_EMAIL_PARTS)}
</dd>
</div>
<div className="mt-1 flex gap-4">
<dt className="w-16 shrink-0 text-slate-500">subject</dt>
<dt className="w-16 shrink-0 text-slate-500">{contact.subject}</dt>
<dd className="m-0 text-white">{topic.draft.subject}</dd>
</div>
</dl>
@@ -150,7 +151,7 @@ export const ContactComposer: React.FC = () => {
}
className="bit-cta bit-cta-light group inline-flex flex-1 items-center justify-between gap-4 rounded-full bg-white px-7 py-4 font-sans text-[12px] font-bold uppercase tracking-[0.18em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
>
Open in my mail app
{contact.open}
<span
aria-hidden="true"
className="transition-transform duration-300 group-hover:translate-x-1"
@@ -163,7 +164,7 @@ export const ContactComposer: React.FC = () => {
onClick={copy}
className="rounded-full border border-slate-700 px-6 py-4 font-sans text-[12px] font-bold uppercase tracking-[0.18em] text-slate-300 transition-colors duration-200 hover:border-slate-400 hover:text-white"
>
{copied ? "Copied" : "Copy address"}
{copied ? contact.copied : contact.copy}
</button>
</div>
</div>
+3 -3
View File
@@ -1,9 +1,9 @@
import * as React from "react";
import { FAQ } from "@/src/content/faq";
import type { FaqItem } from "@/src/content/types";
export const Faq: React.FC = () => (
export const Faq: React.FC<{ items: readonly FaqItem[] }> = ({ items }) => (
<div className="grid grid-cols-1 gap-x-12 md:grid-cols-2">
{FAQ.map((item) => (
{items.map((item) => (
<details
key={item.question}
className="group border-t border-slate-200 py-6 open:pb-8"
+11 -18
View File
@@ -1,16 +1,12 @@
import * as React from "react";
import { Button } from "@/src/components/Button";
import { AbstractCircuit } from "@/src/components/Effects";
import { BitHeadline, type BitLine } from "./BitHeadline";
import type { Content } from "@/src/content/types";
import { BitHeadline } from "./BitHeadline";
const HEADLINE: readonly BitLine[] = [
{ text: "WEB APPS", tone: "solid" },
{ text: "& WEBSITES.", tone: "bits" },
];
const TRUST = ["15 years in web", "Remote, EU core hours"] as const;
export const HeroStatement: React.FC = () => (
export const HeroStatement: React.FC<{ hero: Content["hero"] }> = ({
hero,
}) => (
<section className="relative overflow-hidden bg-white">
<div className="absolute inset-0 opacity-70">
<AbstractCircuit />
@@ -18,16 +14,13 @@ export const HeroStatement: React.FC = () => (
<div className="relative z-10 mx-auto flex min-h-[calc(100dvh-5rem)] w-full max-w-[1400px] flex-col justify-center px-5 py-16 md:px-12 md:py-24">
<BitHeadline
lines={HEADLINE}
lines={hero.headline}
className="font-sans text-[12.5vw] font-black leading-[0.88] tracking-tighter md:text-[11.5vw] xl:text-[10.5rem]"
/>
<div className="mt-10 grid grid-cols-1 gap-10 md:mt-14 md:grid-cols-12 md:items-end md:gap-12">
<p className="m-0 max-w-2xl font-sans text-xl leading-snug text-slate-500 md:col-span-7 md:text-3xl">
Freelance web developer with 15 years of experience.{" "}
<span className="text-slate-900">
I build web apps, interfaces and websites.
</span>
{hero.lead} <span className="text-slate-900">{hero.leadStrong}</span>
</p>
<div className="flex flex-col gap-4 md:col-span-5 md:items-end">
@@ -36,7 +29,7 @@ export const HeroStatement: React.FC = () => (
className="flex w-full flex-col gap-4 sm:flex-row md:w-auto"
>
<Button href="#contact" size="large" className="w-full sm:w-auto">
Get in touch
{hero.primaryCta}
</Button>
<Button
href="#hello"
@@ -44,14 +37,14 @@ export const HeroStatement: React.FC = () => (
size="large"
className="w-full sm:w-auto"
>
What I do
{hero.secondaryCta}
</Button>
</div>
</div>
</div>
<ul className="m-0 mt-14 flex list-none flex-wrap gap-x-8 gap-y-3 border-t border-slate-200 p-0 pt-6 md:mt-20">
{TRUST.map((item) => (
{hero.trust.map((item) => (
<li
key={item}
className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]"
@@ -60,7 +53,7 @@ export const HeroStatement: React.FC = () => (
</li>
))}
<li className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]">
Built and running:{" "}
{hero.builtAndRunning}{" "}
<a
href="https://klz-cables.com"
target="_blank"
+43 -52
View File
@@ -1,58 +1,49 @@
import { TECH } from "@/src/content/about";
import { FAQ } from "@/src/content/faq";
import { SERVICE_PATHS } from "@/src/content/paths";
import {
JOB_TITLE,
OWNER,
PROFILES,
SITE_DESCRIPTION,
SITE_LANGUAGE,
SITE_NAME,
SITE_URL,
} from "@/src/content/site";
import { contentFor } from "@/src/content";
import { OWNER, PROFILES, SITE_NAME, SITE_URL } from "@/src/content/site";
import type { Locale } from "@/src/domain/locale";
import { pathFor } from "@/src/domain/routes";
import {
buildStructuredData,
serializeJsonLd,
} from "@/src/domain/structured-data";
const [postalCode = "", ...locality] = OWNER.city.split(" ");
const STRUCTURED_DATA = buildStructuredData({
siteUrl: SITE_URL,
siteName: SITE_NAME,
language: SITE_LANGUAGE,
description: SITE_DESCRIPTION,
imageUrl: `${SITE_URL}/marc-mintel.png`,
person: {
name: OWNER.name,
jobTitle: JOB_TITLE,
profiles: PROFILES,
knowsAbout: [
"Web applications",
"User interfaces",
"Websites",
"Process automation",
"Web3 frontends",
...TECH.flatMap((group) => group.items),
],
},
address: {
street: OWNER.street,
postalCode,
locality: locality.join(" "),
countryCode: "DE",
},
services: SERVICE_PATHS.map((path) => ({
name: path.service,
description: path.intro,
})),
faq: FAQ,
});
/** Built server-side from the page content; see src/domain/structured-data.ts. */
export const HomeJsonLd: React.FC = () => (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonLd(STRUCTURED_DATA) }}
/>
);
export const HomeJsonLd: React.FC<{ locale: Locale }> = ({ locale }) => {
const { site, services, technology, faq } = contentFor(locale);
const data = buildStructuredData({
siteUrl: SITE_URL,
pageUrl: `${SITE_URL}${pathFor("home", locale)}`,
siteName: SITE_NAME,
language: locale,
description: site.description,
imageUrl: `${SITE_URL}/marc-mintel.png`,
person: {
name: OWNER.name,
jobTitle: site.jobTitle,
profiles: PROFILES,
knowsAbout: [
...site.knowsAbout,
...technology.tech.flatMap((group) => group.items),
],
},
address: {
street: OWNER.street,
postalCode: OWNER.postalCode,
locality: OWNER.locality,
countryCode: OWNER.countryCode,
},
catalogName: site.catalogName,
services: services.paths.map((path) => ({
name: path.service,
description: path.intro,
})),
faq: faq.items,
});
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
/>
);
};
+11 -8
View File
@@ -6,7 +6,8 @@ import { Button } from "@/src/components/Button";
import { BitAura } from "@/src/components/bits/BitAura";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { PathScene } from "@/src/components/illustrations/PathScenes";
import { SERVICE_PATHS, type ServicePath } from "@/src/content/paths";
import { useContent } from "@/src/components/ContentProvider";
import type { ServicePath } from "@/src/content/types";
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
const STEP_MS = 4800;
@@ -143,16 +144,18 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
* All three panels stay in the DOM so the content is readable without JavaScript.
*/
export const PathPicker: React.FC = () => {
const [activeId, setActiveId] = React.useState(SERVICE_PATHS[0]?.id ?? "");
const { services } = useContent();
const paths = services.paths;
const [activeId, setActiveId] = React.useState(paths[0]?.id ?? "");
return (
<div>
<div
role="tablist"
aria-label="What do you need?"
aria-label={services.tablistLabel}
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4"
>
{SERVICE_PATHS.map((path, index) => {
{paths.map((path, index) => {
const selected = path.id === activeId;
return (
<button
@@ -187,7 +190,7 @@ export const PathPicker: React.FC = () => {
})}
</div>
{SERVICE_PATHS.map((path) => {
{paths.map((path) => {
const selected = path.id === activeId;
return (
<div
@@ -214,7 +217,7 @@ export const PathPicker: React.FC = () => {
</p>
</div>
<div className="md:col-span-5">
<p className={`${MONO} m-0`}>What you get</p>
<p className={`${MONO} m-0`}>{services.youGet}</p>
<ul className="m-0 mt-4 list-none p-0">
{path.youGet.map((item) => (
<li
@@ -233,12 +236,12 @@ export const PathPicker: React.FC = () => {
</div>
<div className="mt-14 md:mt-24">
<p className={`${MONO} m-0 mb-6`}>How it runs</p>
<p className={`${MONO} m-0 mb-6`}>{services.howItRuns}</p>
<PathStory path={path} active={selected} />
</div>
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-16 md:p-10">
<p className={`${MONO} m-0`}>What it costs</p>
<p className={`${MONO} m-0`}>{services.whatItCosts}</p>
<div className="mt-6 grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12">
<div className="md:col-span-6">
<p className="m-0 font-sans text-2xl font-bold leading-tight tracking-tight text-slate-900 md:text-4xl">
@@ -17,8 +17,9 @@ export const PortraitSection: React.FC<{
index: string;
label: string;
title: React.ReactNode;
portraitAlt: string;
children: React.ReactNode;
}> = ({ id, index, label, title, children }) => (
}> = ({ id, index, label, title, portraitAlt, children }) => (
<section
id={id}
className="relative scroll-mt-24 overflow-hidden bg-slate-900 text-white"
@@ -33,7 +34,7 @@ export const PortraitSection: React.FC<{
>
<Image
src="/marc-mintel.png"
alt="Marc Mintel, freelance web developer"
alt={portraitAlt}
fill
sizes="(min-width: 768px) 62vw, 100vw"
className="object-cover object-[50%_18%] grayscale"
+3 -1
View File
@@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import { useContent } from "@/src/components/ContentProvider";
/**
* StickyBook: a quiet contact button (wide screens only; on phones the sticky header
@@ -8,6 +9,7 @@ import * as React from "react";
* scrolled away, and steps aside again when the closing section is in view.
*/
export const StickyBook: React.FC = () => {
const { nav } = useContent();
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
@@ -39,7 +41,7 @@ export const StickyBook: React.FC = () => {
tabIndex={visible ? 0 : -1}
className="bit-cta flex items-center gap-3 rounded-full bg-slate-900 px-7 py-4 font-sans text-[11px] font-bold uppercase tracking-[0.2em] text-white no-underline shadow-2xl shadow-slate-900/20 transition-transform duration-300 hover:-translate-y-0.5"
>
Get in touch <span aria-hidden="true">→</span>
{nav.contactLong} <span aria-hidden="true">→</span>
</a>
</div>
);