From 75993100faa12be65de09f278c241f59752a3893 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sun, 4 Oct 2026 13:07:22 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 29 +- apps/web/app/(site)/imprint/page.tsx | 55 -- apps/web/app/(site)/layout.tsx | 96 --- apps/web/app/(site)/opengraph-image.alt.txt | 1 - apps/web/app/(site)/opengraph-image.png | Bin 189559 -> 0 bytes apps/web/app/(site)/privacy/page.tsx | 97 --- apps/web/app/(site)/sitemap.ts | 11 - apps/web/app/(site)/twitter-image.alt.txt | 1 - apps/web/app/(site)/twitter-image.png | Bin 189559 -> 0 bytes apps/web/app/[locale]/[...rest]/page.tsx | 6 + apps/web/app/{(site) => [locale]}/error.tsx | 18 +- apps/web/app/[locale]/imprint/page.tsx | 31 + apps/web/app/[locale]/layout.tsx | 138 ++++ .../app/{(site) => [locale]}/not-found.tsx | 19 +- apps/web/app/{(site) => [locale]}/page.tsx | 210 ++--- apps/web/app/[locale]/privacy/page.tsx | 31 + .../{(site) => }/errors/api/relay/route.ts | 0 apps/web/app/{(site) => }/global-error.tsx | 21 +- apps/web/app/{(site) => }/globals.css | 0 apps/web/app/sitemap.ts | 16 + .../app/{(site) => }/stats/api/send/route.ts | 0 apps/web/next.config.mjs | 7 +- apps/web/plans/freelance-relaunch.md | 2 +- apps/web/proxy.ts | 37 + apps/web/scripts/render-share-image.mjs | 87 ++- apps/web/src/assets/share/share-de.png | Bin 0 -> 191013 bytes apps/web/src/assets/share/share-en.png | Bin 0 -> 188820 bytes apps/web/src/components/ContentProvider.tsx | 34 + apps/web/src/components/Footer.tsx | 20 +- apps/web/src/components/Header.tsx | 34 +- .../src/components/InteractiveElements.tsx | 4 +- apps/web/src/components/LanguageSwitch.tsx | 108 +++ apps/web/src/components/bits/BitCurtain.tsx | 275 +++++++ apps/web/src/components/home/ClosingCta.tsx | 17 +- .../src/components/home/ContactComposer.tsx | 25 +- apps/web/src/components/home/Faq.tsx | 6 +- .../web/src/components/home/HeroStatement.tsx | 29 +- apps/web/src/components/home/HomeJsonLd.tsx | 95 ++- apps/web/src/components/home/PathPicker.tsx | 19 +- .../src/components/home/PortraitSection.tsx | 5 +- apps/web/src/components/home/StickyBook.tsx | 4 +- .../illustrations/AutomationFlow.tsx | 99 +-- .../components/illustrations/CareerLine.tsx | 7 +- .../illustrations/ContactDiagram.tsx | 32 +- .../components/illustrations/PathScenes.tsx | 191 ++--- .../components/illustrations/PipelineRun.tsx | 102 +-- .../components/illustrations/WorkShowcase.tsx | 158 ++-- .../web/src/components/locale-switch-store.ts | 73 ++ apps/web/src/content/about.ts | 71 -- apps/web/src/content/automation.ts | 148 +--- apps/web/src/content/contact.ts | 57 -- apps/web/src/content/de.ts | 731 ++++++++++++++++++ apps/web/src/content/en.ts | 708 +++++++++++++++++ apps/web/src/content/faq.ts | 38 - apps/web/src/content/index.ts | 9 + apps/web/src/content/legal/imprint.tsx | 93 +++ apps/web/src/content/legal/privacy.tsx | 235 ++++++ apps/web/src/content/legal/types.ts | 6 + apps/web/src/content/paths.ts | 209 ----- apps/web/src/content/site.ts | 16 +- apps/web/src/content/types.ts | 302 ++++++++ apps/web/src/content/work.ts | 30 + apps/web/src/domain/locale.spec.ts | 165 ++++ apps/web/src/domain/locale.ts | 77 ++ apps/web/src/domain/routes.spec.ts | 298 +++++++ apps/web/src/domain/routes.ts | 151 ++++ apps/web/src/domain/scroll-anchor.spec.ts | 87 +++ apps/web/src/domain/scroll-anchor.ts | 34 + apps/web/src/domain/structured-data.spec.ts | 40 + apps/web/src/domain/structured-data.ts | 9 +- apps/web/src/domain/template.spec.ts | 46 ++ apps/web/src/domain/template.ts | 13 + 72 files changed, 4418 insertions(+), 1405 deletions(-) delete mode 100644 apps/web/app/(site)/imprint/page.tsx delete mode 100644 apps/web/app/(site)/layout.tsx delete mode 100644 apps/web/app/(site)/opengraph-image.alt.txt delete mode 100644 apps/web/app/(site)/opengraph-image.png delete mode 100644 apps/web/app/(site)/privacy/page.tsx delete mode 100644 apps/web/app/(site)/sitemap.ts delete mode 100644 apps/web/app/(site)/twitter-image.alt.txt delete mode 100644 apps/web/app/(site)/twitter-image.png create mode 100644 apps/web/app/[locale]/[...rest]/page.tsx rename apps/web/app/{(site) => [locale]}/error.tsx (88%) create mode 100644 apps/web/app/[locale]/imprint/page.tsx create mode 100644 apps/web/app/[locale]/layout.tsx rename apps/web/app/{(site) => [locale]}/not-found.tsx (80%) rename apps/web/app/{(site) => [locale]}/page.tsx (54%) create mode 100644 apps/web/app/[locale]/privacy/page.tsx rename apps/web/app/{(site) => }/errors/api/relay/route.ts (100%) rename apps/web/app/{(site) => }/global-error.tsx (78%) rename apps/web/app/{(site) => }/globals.css (100%) create mode 100644 apps/web/app/sitemap.ts rename apps/web/app/{(site) => }/stats/api/send/route.ts (100%) create mode 100644 apps/web/proxy.ts create mode 100644 apps/web/src/assets/share/share-de.png create mode 100644 apps/web/src/assets/share/share-en.png create mode 100644 apps/web/src/components/ContentProvider.tsx create mode 100644 apps/web/src/components/LanguageSwitch.tsx create mode 100644 apps/web/src/components/bits/BitCurtain.tsx create mode 100644 apps/web/src/components/locale-switch-store.ts delete mode 100644 apps/web/src/content/about.ts delete mode 100644 apps/web/src/content/contact.ts create mode 100644 apps/web/src/content/de.ts create mode 100644 apps/web/src/content/en.ts delete mode 100644 apps/web/src/content/faq.ts create mode 100644 apps/web/src/content/index.ts create mode 100644 apps/web/src/content/legal/imprint.tsx create mode 100644 apps/web/src/content/legal/privacy.tsx create mode 100644 apps/web/src/content/legal/types.ts delete mode 100644 apps/web/src/content/paths.ts create mode 100644 apps/web/src/content/types.ts create mode 100644 apps/web/src/content/work.ts create mode 100644 apps/web/src/domain/locale.spec.ts create mode 100644 apps/web/src/domain/locale.ts create mode 100644 apps/web/src/domain/routes.spec.ts create mode 100644 apps/web/src/domain/routes.ts create mode 100644 apps/web/src/domain/scroll-anchor.spec.ts create mode 100644 apps/web/src/domain/scroll-anchor.ts create mode 100644 apps/web/src/domain/template.spec.ts create mode 100644 apps/web/src/domain/template.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3852442..5b2a7f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,22 +25,37 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m - Never mention AI as the owner's tool, and no internal method vocabulary (specs, TDD, mutation, coverage, pull requests). The automation service may name AI as part of what the client gets. Plain language. - Modest and factual: no promises the owner did not confirm, no superlatives. See the concept, section 1. -- One page: everything lives on the homepage. The header has only "Get in touch", which jumps to the contact - section (`#contact`). Contact is one email address: no calendar integration, no form. The only other - pages are `/imprint` and `/privacy`. URLs of the previous site redirect to `/` (see `next.config.mjs`). -- Title, description and link previews come from `SITE_TITLE` and `SITE_DESCRIPTION` in `src/content/site.ts`. - The share image is a screenshot of the real hero: after changing the hero headline or its design, run - `pnpm --filter @mintel/web share-image` and commit the two PNGs in `app/(site)/`. +- One page: everything lives on the homepage. The header has the language switch and "Get in touch", which + jumps to the contact section (`#contact`). Contact is one email address: no calendar integration, no form. + The only other pages are the imprint and the privacy notice. URLs of the previous site redirect to `/` + (see `next.config.mjs`). +- Title, description and link previews come from `site` in `src/content/de.ts` and `en.ts`. + The share image is a screenshot of the real hero, one per language: after changing the hero headline or + its design, run `pnpm --filter @mintel/web share-image` and commit the PNGs in `src/assets/share/`. - The email address is kept in parts (`src/content/site.ts`) and assembled in the browser by `EmailLink`. Never write the full address into markup, metadata or copy. - The hero design (binary-digit headline) is approved: do not change its design. - Look at the result before reporting: take screenshots (desktop and mobile), do not ship blind. +## Two languages (German is the main one) + +- German lives at the root (`/`, `/impressum`, `/datenschutz`), English under `/en` (`/en/imprint`, + `/en/privacy`). The app directory renders both under `app/[locale]`; `proxy.ts` maps the public addresses + onto it. All routing and detection rules are pure functions in `src/domain/routes.ts` and `locale.ts`. +- Detection: an address without a language follows the `lang` cookie (set only by the language switch), then + the browser's `Accept-Language`, then German. `/en/...` is never redirected. +- Every visible text is data in `src/content/de.ts` and `en.ts` (type `Content` in `types.ts`). Components + hold no texts: server components take them from `contentFor(locale)`, client components from + `useContent()`. A new text goes into both files; German uses the formal "Sie". +- Never name a file `src/i18n.ts` or `i18n/request.ts`: `@mintel/next-config` would switch on next-intl. +- The privacy notice describes the language cookie; keep it true when the detection changes. + ## Motion (binary micro-interactions) - Building blocks live in `apps/web/src/components/bits/`: `DecodeText` (mono labels resolving from digits), `BitWipe` (headline revealed behind a column of digits), `BitReveal` (picture assembling from bits), - `BinaryTicker`, `ScrollBits`, `BitBurst`. Reuse them instead of inventing new effects. + `BinaryTicker`, `ScrollBits`, `BitBurst`, `BitCurtain` (the language switch transition). Reuse them + instead of inventing new effects. - `DecodeText` is for monospaced text only; in proportional type the changing glyph widths make text jump. - Never put an in-view trigger on an element that clips itself away (clip-path): a fully clipped element never counts as visible. Observe an unclipped wrapper (see `BitWipe`). diff --git a/apps/web/app/(site)/imprint/page.tsx b/apps/web/app/(site)/imprint/page.tsx deleted file mode 100644 index d7cd2c1..0000000 --- a/apps/web/app/(site)/imprint/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type { Metadata } from "next"; -import { EmailLink } from "@/src/components/EmailLink"; -import { LegalPage, LegalSection } from "@/src/components/LegalPage"; -import { OWNER } from "@/src/content/site"; - -export const metadata: Metadata = { - title: "Imprint", - description: "Legal notice (Impressum) for mintel.me.", - alternates: { canonical: "/imprint" }, - robots: { index: false, follow: true }, -}; - -const LINK = "font-bold text-slate-900 underline-offset-4 hover:underline"; - -export default function ImprintPage() { - return ( - - -

- {OWNER.name} -
- {OWNER.street} -
- {OWNER.city} -
- {OWNER.country} -

-
- - -

- Email: -

-
- - -

- VAT identification number according to section 27a of the German VAT - Act (Umsatzsteuergesetz): {OWNER.vatId} -

-
- - -

{OWNER.name}, address as above (section 18 paragraph 2 MStV).

-
- - -

- I am neither willing nor obliged to take part in dispute resolution - proceedings before a consumer arbitration board. -

-
-
- ); -} diff --git a/apps/web/app/(site)/layout.tsx b/apps/web/app/(site)/layout.tsx deleted file mode 100644 index ee78ffa..0000000 --- a/apps/web/app/(site)/layout.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import type { Metadata, Viewport } from "next"; -import { Inter, JetBrains_Mono, Newsreader } from "next/font/google"; -import { Analytics } from "@/src/components/Analytics"; -import { BitBurst } from "@/src/components/bits/BitBurst"; -import { BitGlow } from "@/src/components/bits/BitGlow"; -import { Footer } from "@/src/components/Footer"; -import { Header } from "@/src/components/Header"; -import { InteractiveElements } from "@/src/components/InteractiveElements"; -import { - INDEXING, - SITE_DESCRIPTION, - SITE_LANGUAGE, - SITE_NAME, - SITE_TITLE, - SITE_URL, -} from "@/src/content/site"; -import { robotsMeta } from "@/src/domain/indexing"; -import "./globals.css"; - -const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); -// A real monospaced fallback has almost the same letter width. The default -// fallback (a resized Arial) wraps the mono labels differently, so the hero -// jumped when the font arrived. -const jetbrainsMono = JetBrains_Mono({ - subsets: ["latin"], - variable: "--font-jetbrains-mono", - display: "swap", - adjustFontFallback: false, - fallback: ["ui-monospace", "Menlo", "Consolas", "monospace"], -}); -const newsreader = Newsreader({ - subsets: ["latin"], - variable: "--font-newsreader", - style: "italic", - display: "swap", -}); - -export const metadata: Metadata = { - metadataBase: new URL(SITE_URL), - title: { - default: SITE_TITLE, - template: `%s | ${SITE_NAME}`, - }, - description: SITE_DESCRIPTION, - applicationName: SITE_NAME, - authors: [{ name: SITE_NAME, url: SITE_URL }], - creator: SITE_NAME, - robots: robotsMeta(INDEXING), - icons: { - icon: "/favicon.svg", - apple: "/apple-touch-icon.png", - }, - // The images come from opengraph-image.png and twitter-image.png next to this file. - openGraph: { - type: "website", - url: "/", - siteName: SITE_NAME, - locale: "en_US", - title: SITE_TITLE, - description: SITE_DESCRIPTION, - }, - twitter: { - card: "summary_large_image", - title: SITE_TITLE, - description: SITE_DESCRIPTION, - }, -}; - -export const viewport: Viewport = { - themeColor: "#ffffff", - colorScheme: "light", - viewportFit: "cover", -}; - -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - -
-
{children}
-