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:
@@ -0,0 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
/** Any address that is not a page ends in the error page of its language. */
|
||||
export default function UnknownPage() {
|
||||
notFound();
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { useContent, useLocale } from "@/src/components/ContentProvider";
|
||||
import { pathFor } from "@/src/domain/routes";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const { error: copy } = useContent();
|
||||
const home = pathFor("home", useLocale());
|
||||
|
||||
useEffect(() => {
|
||||
// Log the error to Sentry/GlitchTip
|
||||
Sentry.captureException(error);
|
||||
console.error("Caught in error.tsx:", error);
|
||||
|
||||
// Auto-reload on ChunkLoadError or Server Action errors to fetch the new deployment assets
|
||||
const isChunkLoadError =
|
||||
error.message &&
|
||||
(error.message.includes("ChunkLoadError") ||
|
||||
error.message.includes("Loading chunk") ||
|
||||
error.message.includes("Failed to find Server Action"));
|
||||
|
||||
if (isChunkLoadError) {
|
||||
const lastReload = sessionStorage.getItem("last-chunk-reload");
|
||||
const now = Date.now();
|
||||
if (!lastReload || now - parseInt(lastReload) > 10000) {
|
||||
sessionStorage.setItem("last-chunk-reload", now.toString());
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] px-5 py-20 text-center">
|
||||
<div className="space-y-6 max-w-2xl mx-auto">
|
||||
<span className="inline-block px-3 py-1 bg-red-50 border border-red-100 rounded text-[10px] font-mono text-red-500 uppercase tracking-widest">
|
||||
{copy.kicker}
|
||||
</span>
|
||||
<h1 className="text-5xl md:text-7xl font-black text-slate-900 tracking-tighter">
|
||||
{copy.title}
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-slate-500 font-serif italic max-w-xl mx-auto leading-relaxed">
|
||||
{copy.text}
|
||||
</p>
|
||||
|
||||
<div className="pt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Button href="#" onClick={() => reset()} variant="primary">
|
||||
{copy.retry}
|
||||
</Button>
|
||||
<Button href={home} variant="outline">
|
||||
{copy.home}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-16 max-w-sm mx-auto">
|
||||
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-100 text-left font-mono text-xs text-slate-400">
|
||||
<div className="flex items-center justify-between border-b border-slate-200/60 pb-2 mb-2">
|
||||
<span>STATUS</span>
|
||||
<span className="text-red-500 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse"></span>
|
||||
FAIL
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-slate-200/60 pb-2 mb-2">
|
||||
<span>TRACKING</span>
|
||||
<span className="text-green-500">GLITCHTIP_LOGGED</span>
|
||||
</div>
|
||||
{error.digest && (
|
||||
<div className="flex justify-between">
|
||||
<span>DIGEST</span>
|
||||
<span className="truncate max-w-[150px]">{error.digest}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage } from "@/src/components/LegalPage";
|
||||
import { IMPRINT } from "@/src/content/legal/imprint";
|
||||
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
|
||||
import { languageAlternates, pathFor } from "@/src/domain/routes";
|
||||
|
||||
type Props = { readonly params: Promise<{ readonly locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { title, description } = IMPRINT[locale];
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: pathFor("imprint", locale),
|
||||
languages: languageAlternates("imprint"),
|
||||
},
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params }: Props) {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { title, Body } = IMPRINT[locale];
|
||||
return (
|
||||
<LegalPage title={title}>
|
||||
<Body />
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import type { StaticImageData } from "next/image";
|
||||
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 { ContentProvider } from "@/src/components/ContentProvider";
|
||||
import { BitCurtain } from "@/src/components/bits/BitCurtain";
|
||||
import shareImageDe from "@/src/assets/share/share-de.png";
|
||||
import shareImageEn from "@/src/assets/share/share-en.png";
|
||||
import { contentFor } from "@/src/content";
|
||||
import { INDEXING, SITE_NAME, SITE_URL } from "@/src/content/site";
|
||||
import { robotsMeta } from "@/src/domain/indexing";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
LOCALES,
|
||||
parseLocale,
|
||||
type Locale,
|
||||
} from "@/src/domain/locale";
|
||||
import { pathFor } from "@/src/domain/routes";
|
||||
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",
|
||||
});
|
||||
|
||||
type Props = {
|
||||
readonly children: React.ReactNode;
|
||||
readonly params: Promise<{ readonly locale: string }>;
|
||||
};
|
||||
|
||||
/** The share image is a screenshot of the hero in that language (see scripts/render-share-image.mjs). */
|
||||
const SHARE_IMAGE: Readonly<Record<Locale, StaticImageData>> = {
|
||||
en: shareImageEn,
|
||||
de: shareImageDe,
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
return LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { site } = contentFor(locale);
|
||||
const image = SHARE_IMAGE[locale];
|
||||
const images = [
|
||||
{
|
||||
url: image.src,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
alt: site.shareImageAlt,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
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",
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: pathFor("home", locale),
|
||||
siteName: SITE_NAME,
|
||||
locale: site.ogLocale,
|
||||
alternateLocale: LOCALES.filter((other) => other !== locale).map(
|
||||
(other) => contentFor(other).site.ogLocale,
|
||||
),
|
||||
title: site.title,
|
||||
description: site.description,
|
||||
images,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: site.title,
|
||||
description: site.description,
|
||||
images,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#ffffff",
|
||||
colorScheme: "light",
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children, params }: Props) {
|
||||
// The proxy only lets the two languages through; anything else is a file-like
|
||||
// address that ends in the error page of the main language.
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
className={`${inter.variable} ${newsreader.variable} ${jetbrainsMono.variable}`}
|
||||
>
|
||||
<body className="min-h-screen bg-white">
|
||||
<ContentProvider locale={locale} content={contentFor(locale)}>
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
<Footer />
|
||||
<InteractiveElements />
|
||||
<BitCurtain />
|
||||
</ContentProvider>
|
||||
<BitBurst />
|
||||
<BitGlow />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { useContent, useLocale } from "@/src/components/ContentProvider";
|
||||
import { pathFor } from "@/src/domain/routes";
|
||||
|
||||
export default function NotFound() {
|
||||
const { notFound } = useContent();
|
||||
const home = pathFor("home", useLocale());
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] px-5 py-20 text-center">
|
||||
<div className="space-y-6 max-w-2xl mx-auto">
|
||||
<span className="inline-block px-3 py-1 bg-slate-50 border border-slate-100 rounded text-[10px] font-mono text-slate-500 uppercase tracking-widest">
|
||||
{notFound.kicker}
|
||||
</span>
|
||||
<h1 className="text-5xl md:text-7xl font-black text-slate-900 tracking-tighter">
|
||||
{notFound.title}
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-slate-500 font-serif italic max-w-xl mx-auto leading-relaxed">
|
||||
{notFound.text}
|
||||
</p>
|
||||
|
||||
<div className="pt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Button href={home} variant="primary">
|
||||
{notFound.home}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pt-16 max-w-sm mx-auto">
|
||||
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-100 text-left font-mono text-xs text-slate-400">
|
||||
<div className="flex justify-between border-b border-slate-200/60 pb-2 mb-2">
|
||||
<span>STATUS</span>
|
||||
<span className="text-red-500">404 NOT_FOUND</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-slate-200/60 pb-2 mb-2">
|
||||
<span>ACTION</span>
|
||||
<span>REROUTE_SUGGESTED</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>SYSTEM</span>
|
||||
<span className="text-green-500">ONLINE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { Metadata } from "next";
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||
import { Block } from "@/src/components/home/Block";
|
||||
import { ClosingCta } from "@/src/components/home/ClosingCta";
|
||||
import { Faq } from "@/src/components/home/Faq";
|
||||
import { HeroStatement } from "@/src/components/home/HeroStatement";
|
||||
import { HomeJsonLd } from "@/src/components/home/HomeJsonLd";
|
||||
import { PathPicker } from "@/src/components/home/PathPicker";
|
||||
import { PortraitSection } from "@/src/components/home/PortraitSection";
|
||||
import { StickyBook } from "@/src/components/home/StickyBook";
|
||||
import { AutomationFlow } from "@/src/components/illustrations/AutomationFlow";
|
||||
import { CareerLine } from "@/src/components/illustrations/CareerLine";
|
||||
import { ContactDiagram } from "@/src/components/illustrations/ContactDiagram";
|
||||
import { PipelineRun } from "@/src/components/illustrations/PipelineRun";
|
||||
import { WorkShowcase } from "@/src/components/illustrations/WorkShowcase";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import { contentFor } from "@/src/content";
|
||||
import type { SplitTitle } from "@/src/content/types";
|
||||
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
|
||||
import { languageAlternates, pathFor } from "@/src/domain/routes";
|
||||
|
||||
type Props = { readonly params: Promise<{ readonly locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { site } = contentFor(locale);
|
||||
return {
|
||||
title: { absolute: site.title },
|
||||
description: site.description,
|
||||
alternates: {
|
||||
canonical: pathFor("home", locale),
|
||||
languages: languageAlternates("home"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const MUTED = "text-slate-400";
|
||||
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
|
||||
|
||||
const Title: React.FC<{ title: SplitTitle; muted?: string }> = ({
|
||||
title,
|
||||
muted = MUTED,
|
||||
}) => (
|
||||
<>
|
||||
{title.lead} <span className={muted}>{title.rest}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
export default async function LandingPage({ params }: Props) {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const content = contentFor(locale);
|
||||
const { hello, services, automation, technology, working, work, faq } =
|
||||
content;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col bg-white">
|
||||
<HomeJsonLd locale={locale} />
|
||||
<HeroStatement hero={content.hero} />
|
||||
<StickyBook />
|
||||
|
||||
<PortraitSection
|
||||
id="hello"
|
||||
index="01"
|
||||
label={hello.label}
|
||||
title={hello.title}
|
||||
portraitAlt={hello.portraitAlt}
|
||||
>
|
||||
<p className="m-0 font-sans text-2xl leading-snug text-white md:text-4xl">
|
||||
{hello.lead}
|
||||
</p>
|
||||
{hello.paragraphs.map((paragraph, i) => (
|
||||
<p
|
||||
key={paragraph}
|
||||
className={`m-0 font-sans text-lg leading-relaxed text-slate-300 md:text-xl ${i === 0 ? "mt-8" : "mt-6"}`}
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</PortraitSection>
|
||||
|
||||
<section className="relative overflow-clip bg-slate-900">
|
||||
<div className="mx-auto w-full max-w-[1400px] px-5 pb-20 pt-4 md:px-12 md:pb-36 md:pt-0">
|
||||
<p className="m-0 mb-10 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400">
|
||||
{hello.careerLabel}
|
||||
</p>
|
||||
<CareerLine stations={hello.career} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Block
|
||||
index="02"
|
||||
label={services.label}
|
||||
title={<Title title={services.title} />}
|
||||
>
|
||||
<Reveal>
|
||||
<PathPicker />
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="03"
|
||||
label={automation.label}
|
||||
title={<Title title={automation.title} />}
|
||||
>
|
||||
<Reveal>
|
||||
<p className={`${BODY} mb-12 max-w-3xl`}>{automation.intro}</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<AutomationFlow />
|
||||
</Reveal>
|
||||
<div className="mt-16 grid grid-cols-1 gap-12 md:mt-24 md:grid-cols-3 md:gap-10">
|
||||
{automation.notes.map((group, i) => (
|
||||
<Reveal key={group.title} delay={0.1 * i}>
|
||||
<div className="border-t border-slate-900 pt-6">
|
||||
<p className={`${MONO} m-0`}>{group.title}</p>
|
||||
<ul className="m-0 mt-4 list-none p-0">
|
||||
{group.items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="border-b border-slate-100 py-4 font-sans text-lg text-slate-900 md:text-xl"
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="04"
|
||||
label={technology.label}
|
||||
title={<Title title={technology.title} />}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-12 md:grid-cols-3 md:gap-10">
|
||||
{technology.fit.map((group, i) => (
|
||||
<Reveal key={group.title} delay={0.1 * i}>
|
||||
<div
|
||||
className={`border-t pt-6 ${i === 2 ? "border-slate-300" : "border-slate-900"}`}
|
||||
>
|
||||
<p className={`${MONO} m-0`}>{group.title}</p>
|
||||
<ul className="m-0 mt-4 list-none p-0">
|
||||
{group.items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className={`border-b border-slate-100 py-4 font-sans text-lg md:text-xl ${
|
||||
i === 2 ? "text-slate-400" : "text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Reveal>
|
||||
<div className="mt-16 rounded-3xl bg-slate-50 p-6 md:mt-24 md:p-10">
|
||||
<p className={`${MONO} m-0`}>{technology.techLabel}</p>
|
||||
<div className="mt-8 grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{technology.tech.map((group) => (
|
||||
<div key={group.area}>
|
||||
<p className="m-0 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl">
|
||||
{group.area}
|
||||
</p>
|
||||
<ul className="m-0 mt-4 flex list-none flex-wrap gap-2 p-0">
|
||||
{group.items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-slate-700"
|
||||
>
|
||||
<DecodeText text={item} trigger="both" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-16 md:mt-24">
|
||||
<Reveal>
|
||||
<h3 className="m-0 font-sans text-3xl font-bold tracking-tighter text-slate-900 md:text-5xl">
|
||||
{technology.pipelineTitle}
|
||||
</h3>
|
||||
<p className={`${BODY} mb-12 mt-6 max-w-3xl`}>
|
||||
{technology.pipelineIntro}
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<PipelineRun />
|
||||
</Reveal>
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="05"
|
||||
label={working.label}
|
||||
title={<Title title={working.title} />}
|
||||
>
|
||||
<Reveal>
|
||||
<BitAura source="bottom" tone="light" className="mb-16 md:mb-24">
|
||||
<div className="overflow-hidden rounded-3xl bg-slate-900 p-4 md:p-10">
|
||||
<ContactDiagram />
|
||||
</div>
|
||||
</BitAura>
|
||||
</Reveal>
|
||||
<div className="grid grid-cols-1 gap-x-12 gap-y-12 md:grid-cols-2 md:gap-y-16">
|
||||
{working.principles.map((item, i) => (
|
||||
<Reveal key={item.title} delay={0.08 * i}>
|
||||
<div className="border-t border-slate-900 pt-6">
|
||||
<h3 className="m-0 font-sans text-3xl font-bold tracking-tight text-slate-900 md:text-4xl">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={`${BODY} mt-4`}>{item.text}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="06"
|
||||
label={work.label}
|
||||
tone="dark"
|
||||
title={<Title title={work.title} muted="text-slate-500" />}
|
||||
>
|
||||
<WorkShowcase />
|
||||
<Reveal>
|
||||
<p className="m-0 mt-20 max-w-3xl border-t border-slate-800 pt-8 font-sans text-lg leading-relaxed text-slate-300 md:mt-28 md:text-xl">
|
||||
{work.note}
|
||||
</p>
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block index="07" label={faq.label} title={<Title title={faq.title} />}>
|
||||
<Reveal>
|
||||
<Faq items={faq.items} />
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<ClosingCta contact={content.contact} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage } from "@/src/components/LegalPage";
|
||||
import { PRIVACY } from "@/src/content/legal/privacy";
|
||||
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
|
||||
import { languageAlternates, pathFor } from "@/src/domain/routes";
|
||||
|
||||
type Props = { readonly params: Promise<{ readonly locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { title, description } = PRIVACY[locale];
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: pathFor("privacy", locale),
|
||||
languages: languageAlternates("privacy"),
|
||||
},
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params }: Props) {
|
||||
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
|
||||
const { title, Body } = PRIVACY[locale];
|
||||
return (
|
||||
<LegalPage title={title}>
|
||||
<Body />
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user