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:
@@ -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`).
|
||||
|
||||
@@ -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 (
|
||||
<LegalPage title="Imprint">
|
||||
<LegalSection heading="Information according to section 5 DDG">
|
||||
<p>
|
||||
{OWNER.name}
|
||||
<br />
|
||||
{OWNER.street}
|
||||
<br />
|
||||
{OWNER.city}
|
||||
<br />
|
||||
{OWNER.country}
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Contact">
|
||||
<p>
|
||||
Email: <EmailLink className={LINK} />
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="VAT identification number">
|
||||
<p>
|
||||
VAT identification number according to section 27a of the German VAT
|
||||
Act (Umsatzsteuergesetz): {OWNER.vatId}
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Responsible for the content">
|
||||
<p>{OWNER.name}, address as above (section 18 paragraph 2 MStV).</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Consumer dispute resolution">
|
||||
<p>
|
||||
I am neither willing nor obliged to take part in dispute resolution
|
||||
proceedings before a consumer arbitration board.
|
||||
</p>
|
||||
</LegalSection>
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<html
|
||||
lang={SITE_LANGUAGE}
|
||||
className={`${inter.variable} ${newsreader.variable} ${jetbrainsMono.variable}`}
|
||||
>
|
||||
<body className="min-h-screen bg-white">
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
<Footer />
|
||||
<InteractiveElements />
|
||||
<BitBurst />
|
||||
<BitGlow />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
Web apps and websites. Marc Mintel, freelance web developer with 15 years of experience.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 185 KiB |
@@ -1,97 +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: "Privacy",
|
||||
description: "How mintel.me handles personal data.",
|
||||
alternates: { canonical: "/privacy" },
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
const LINK = "font-bold text-slate-900 underline-offset-4 hover:underline";
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<LegalPage title="Privacy">
|
||||
<LegalSection heading="Who is responsible">
|
||||
<p>
|
||||
{OWNER.name}, {OWNER.street}, {OWNER.city}, {OWNER.country}.
|
||||
<br />
|
||||
Email: <EmailLink className={LINK} />
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="In short">
|
||||
<p>
|
||||
This website sets no cookies, shows no advertising and includes no
|
||||
content from third parties. Fonts and images are served from this
|
||||
website itself.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Server logs">
|
||||
<p>
|
||||
When you open a page, the server processes technical data that your
|
||||
browser sends: your IP address, the time, the page requested and the
|
||||
browser type. This is necessary to deliver the website and to keep it
|
||||
secure. The legal basis is my legitimate interest in running the
|
||||
website (Article 6(1)(f) GDPR).
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Visitor statistics">
|
||||
<p>
|
||||
I count page views with Umami, which I run on my own server. It works
|
||||
without cookies and does not create profiles of individual visitors.
|
||||
The data is not passed on to third parties. The legal basis is my
|
||||
legitimate interest in understanding how the website is used (Article
|
||||
6(1)(f) GDPR).
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Error reports">
|
||||
<p>
|
||||
If something on this website fails technically, an error report is
|
||||
sent to an error tracking service that I run on my own server. It
|
||||
contains technical details such as the page, the browser and the error
|
||||
message. Sessions are not recorded. The legal basis is my legitimate
|
||||
interest in a working website (Article 6(1)(f) GDPR).
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Storage in your browser">
|
||||
<p>
|
||||
If loading a page fails, the website stores a timestamp in your
|
||||
browser's session storage so that it reloads only once. It is
|
||||
deleted when you close the tab and is not sent anywhere.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="When you write to me">
|
||||
<p>
|
||||
If you send me an email, I process what you write and your address in
|
||||
order to answer you and, if it comes to that, to prepare a contract
|
||||
(Article 6(1)(b) GDPR). I keep the correspondence for as long as it is
|
||||
needed for that purpose or as the law requires.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Your rights">
|
||||
<p>You have the right to:</p>
|
||||
<ul>
|
||||
<li>ask what data I hold about you</li>
|
||||
<li>have incorrect data corrected</li>
|
||||
<li>have your data deleted or its processing restricted</li>
|
||||
<li>object to processing that is based on legitimate interest</li>
|
||||
<li>receive your data in a portable format</li>
|
||||
<li>complain to a data protection supervisory authority</li>
|
||||
</ul>
|
||||
<p>
|
||||
To use any of these rights, write to <EmailLink className={LINK} />.
|
||||
</p>
|
||||
</LegalSection>
|
||||
</LegalPage>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/src/content/site";
|
||||
|
||||
/**
|
||||
* The site is a single page; the legal pages are not meant for search results.
|
||||
* No modification date: it would have to be the date the content last changed,
|
||||
* and a date that moves with every build teaches search engines to ignore it.
|
||||
*/
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [{ url: `${SITE_URL}/` }];
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
Web apps and websites. Marc Mintel, freelance web developer with 15 years of experience.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 185 KiB |
@@ -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();
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
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,
|
||||
@@ -11,6 +13,9 @@ export default function Error({
|
||||
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);
|
||||
@@ -37,22 +42,21 @@ export default function Error({
|
||||
<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">
|
||||
Error 500
|
||||
{copy.kicker}
|
||||
</span>
|
||||
<h1 className="text-5xl md:text-7xl font-black text-slate-900 tracking-tighter">
|
||||
Kritischer Fehler.
|
||||
{copy.title}
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-slate-500 font-serif italic max-w-xl mx-auto leading-relaxed">
|
||||
Ein unerwartetes Problem ist aufgetreten. Unsere Systeme haben den
|
||||
Vorfall protokolliert.
|
||||
{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">
|
||||
System neu starten (Retry)
|
||||
{copy.retry}
|
||||
</Button>
|
||||
<Button href="/" variant="outline">
|
||||
Zurück zur Basis
|
||||
<Button href={home} variant="outline">
|
||||
{copy.home}
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,28 @@
|
||||
export const metadata = {
|
||||
title: "Page not found",
|
||||
description: "This page does not exist.",
|
||||
};
|
||||
"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">
|
||||
Error 404
|
||||
{notFound.kicker}
|
||||
</span>
|
||||
<h1 className="text-5xl md:text-7xl font-black text-slate-900 tracking-tighter">
|
||||
Page not found.
|
||||
{notFound.title}
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-slate-500 font-serif italic max-w-xl mx-auto leading-relaxed">
|
||||
This address does not exist. The page may have been moved or removed.
|
||||
{notFound.text}
|
||||
</p>
|
||||
|
||||
<div className="pt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Button href="/" variant="primary">
|
||||
Back to the homepage
|
||||
<Button href={home} variant="primary">
|
||||
{notFound.home}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -13,120 +13,86 @@ 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,
|
||||
type WorkItem,
|
||||
} from "@/src/components/illustrations/WorkShowcase";
|
||||
import { WorkShowcase } from "@/src/components/illustrations/WorkShowcase";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import { CAREER, FIT, TECH } from "@/src/content/about";
|
||||
import { SITE_DESCRIPTION, SITE_TITLE } from "@/src/content/site";
|
||||
import { AUTOMATION_NOTES } from "@/src/content/automation";
|
||||
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";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { absolute: SITE_TITLE },
|
||||
description: SITE_DESCRIPTION,
|
||||
alternates: { canonical: "/" },
|
||||
};
|
||||
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 PRINCIPLES = [
|
||||
{
|
||||
title: "One contact",
|
||||
text: "You talk to me, and I am the one who does the work.",
|
||||
},
|
||||
{
|
||||
title: "Hours you can check",
|
||||
text: "I track my time. The monthly invoice shows what the hours were spent on.",
|
||||
},
|
||||
{
|
||||
title: "Careful work",
|
||||
text: "I test what I build before I hand it over, and I say so when something is not finished or I am not sure.",
|
||||
},
|
||||
{
|
||||
title: "What I build is yours",
|
||||
text: "Code and design belong to you. You can hand them to another developer.",
|
||||
},
|
||||
] as const;
|
||||
const Title: React.FC<{ title: SplitTitle; muted?: string }> = ({
|
||||
title,
|
||||
muted = MUTED,
|
||||
}) => (
|
||||
<>
|
||||
{title.lead} <span className={muted}>{title.rest}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const WORK: readonly WorkItem[] = [
|
||||
{
|
||||
name: "klz-cables.com",
|
||||
href: "https://klz-cables.com",
|
||||
client: "Cable supplier",
|
||||
text: "The company website of a supplier of energy cables, in German and English.",
|
||||
did: [
|
||||
"Product catalogue with categories and detail pages",
|
||||
"Datasheets generated from the product data",
|
||||
"Content the client can edit in a CMS",
|
||||
],
|
||||
tech: ["Next.js", "TypeScript", "Headless CMS", "Two languages"],
|
||||
desktop: { src: "/work/klz-long.webp", width: 1200, height: 1680 },
|
||||
mobile: { src: "/work/klz-mobile.webp", width: 390, height: 3600 },
|
||||
},
|
||||
{
|
||||
name: "e-tib.com",
|
||||
href: "https://e-tib.com",
|
||||
client: "Cable network construction",
|
||||
text: "The company website of a group in cable network construction, in German and English.",
|
||||
did: [
|
||||
"Pages for services, references, careers and events",
|
||||
"The same content in two languages",
|
||||
"Works on phone, tablet and desktop",
|
||||
],
|
||||
tech: ["Next.js", "TypeScript", "Headless CMS", "Two languages"],
|
||||
desktop: { src: "/work/etib-long.webp", width: 1200, height: 4333 },
|
||||
mobile: { src: "/work/etib-mobile.webp", width: 390, height: 3600 },
|
||||
},
|
||||
];
|
||||
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;
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="relative flex flex-col bg-white">
|
||||
<HomeJsonLd />
|
||||
<HeroStatement />
|
||||
<HomeJsonLd locale={locale} />
|
||||
<HeroStatement hero={content.hero} />
|
||||
<StickyBook />
|
||||
|
||||
<PortraitSection
|
||||
id="hello"
|
||||
index="01"
|
||||
label="Hello"
|
||||
title={<>I am Marc.</>}
|
||||
label={hello.label}
|
||||
title={hello.title}
|
||||
portraitAlt={hello.portraitAlt}
|
||||
>
|
||||
<p className="m-0 font-sans text-2xl leading-snug text-white md:text-4xl">
|
||||
I have been building for the web for 15 years: first in agencies,
|
||||
later as a senior developer, and now on my own.
|
||||
</p>
|
||||
<p className="m-0 mt-8 font-sans text-lg leading-relaxed text-slate-300 md:text-xl">
|
||||
My strength is the part people see and use: design and interface. I am
|
||||
also at home with what lies behind it, so I can build a complete
|
||||
application and not just its surface.
|
||||
</p>
|
||||
<p className="m-0 mt-6 font-sans text-lg leading-relaxed text-slate-300 md:text-xl">
|
||||
You work with me directly. Whether you run a product team or are
|
||||
commissioning something like this for the first time.
|
||||
{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">
|
||||
The path so far
|
||||
{hello.careerLabel}
|
||||
</p>
|
||||
<CareerLine stations={CAREER} />
|
||||
<CareerLine stations={hello.career} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Block
|
||||
index="02"
|
||||
label="Services"
|
||||
title={
|
||||
<>
|
||||
What do you need? <span className={MUTED}>Four typical cases.</span>
|
||||
</>
|
||||
}
|
||||
label={services.label}
|
||||
title={<Title title={services.title} />}
|
||||
>
|
||||
<Reveal>
|
||||
<PathPicker />
|
||||
@@ -135,26 +101,17 @@ export default function LandingPage() {
|
||||
|
||||
<Block
|
||||
index="03"
|
||||
label="Process automation"
|
||||
title={
|
||||
<>
|
||||
Let the systems <span className={MUTED}>do the copying.</span>
|
||||
</>
|
||||
}
|
||||
label={automation.label}
|
||||
title={<Title title={automation.title} />}
|
||||
>
|
||||
<Reveal>
|
||||
<p className={`${BODY} mb-12 max-w-3xl`}>
|
||||
In most companies, people move information from one tool to another:
|
||||
read it here, type it in there, send an email about it. Automation
|
||||
connects those tools so that the work passes from one to the next on
|
||||
its own. Three typical examples:
|
||||
</p>
|
||||
<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) => (
|
||||
{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>
|
||||
@@ -176,15 +133,11 @@ export default function LandingPage() {
|
||||
|
||||
<Block
|
||||
index="04"
|
||||
label="Technology"
|
||||
title={
|
||||
<>
|
||||
Which projects <span className={MUTED}>I am right for.</span>
|
||||
</>
|
||||
}
|
||||
label={technology.label}
|
||||
title={<Title title={technology.title} />}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-12 md:grid-cols-3 md:gap-10">
|
||||
{FIT.map((group, i) => (
|
||||
{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"}`}
|
||||
@@ -209,9 +162,9 @@ export default function LandingPage() {
|
||||
|
||||
<Reveal>
|
||||
<div className="mt-16 rounded-3xl bg-slate-50 p-6 md:mt-24 md:p-10">
|
||||
<p className={`${MONO} m-0`}>What I work with</p>
|
||||
<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">
|
||||
{TECH.map((group) => (
|
||||
{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}
|
||||
@@ -235,13 +188,10 @@ export default function LandingPage() {
|
||||
<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">
|
||||
What happens before a change goes live.
|
||||
{technology.pipelineTitle}
|
||||
</h3>
|
||||
<p className={`${BODY} mb-12 mt-6 max-w-3xl`}>
|
||||
Software is connected: a change in one place can break another. I
|
||||
secure what I build with automated tests, and every change has to
|
||||
pass them before it is released. This is what such a run looks
|
||||
like.
|
||||
{technology.pipelineIntro}
|
||||
</p>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
@@ -252,12 +202,8 @@ export default function LandingPage() {
|
||||
|
||||
<Block
|
||||
index="05"
|
||||
label="Working with me"
|
||||
title={
|
||||
<>
|
||||
How I work. <span className={MUTED}>Nothing unusual.</span>
|
||||
</>
|
||||
}
|
||||
label={working.label}
|
||||
title={<Title title={working.title} />}
|
||||
>
|
||||
<Reveal>
|
||||
<BitAura source="bottom" tone="light" className="mb-16 md:mb-24">
|
||||
@@ -267,7 +213,7 @@ export default function LandingPage() {
|
||||
</BitAura>
|
||||
</Reveal>
|
||||
<div className="grid grid-cols-1 gap-x-12 gap-y-12 md:grid-cols-2 md:gap-y-16">
|
||||
{PRINCIPLES.map((item, i) => (
|
||||
{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">
|
||||
@@ -282,39 +228,25 @@ export default function LandingPage() {
|
||||
|
||||
<Block
|
||||
index="06"
|
||||
label="My work"
|
||||
label={work.label}
|
||||
tone="dark"
|
||||
title={
|
||||
<>
|
||||
Two sites I built.{" "}
|
||||
<span className="text-slate-500">Both are live.</span>
|
||||
</>
|
||||
}
|
||||
title={<Title title={work.title} muted="text-slate-500" />}
|
||||
>
|
||||
<WorkShowcase items={WORK} />
|
||||
<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">
|
||||
Besides client work I build my own software, among other things a
|
||||
system in Rust and Solidity that runs on seven blockchains.
|
||||
{work.note}
|
||||
</p>
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<Block
|
||||
index="07"
|
||||
label="Questions"
|
||||
title={
|
||||
<>
|
||||
Good to know <span className={MUTED}>before we talk.</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Block index="07" label={faq.label} title={<Title title={faq.title} />}>
|
||||
<Reveal>
|
||||
<Faq />
|
||||
<Faq items={faq.items} />
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<ClosingCta />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Inter, Newsreader } from "next/font/google";
|
||||
import { contentFor } from "@/src/content";
|
||||
import { DEFAULT_LOCALE, type Locale } from "@/src/domain/locale";
|
||||
import { localeOfPath } from "@/src/domain/routes";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
@@ -20,6 +23,11 @@ export default function GlobalError({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
// The root layout failed, so the language is read from the address.
|
||||
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
|
||||
useEffect(() => setLocale(localeOfPath(window.location.pathname)), []);
|
||||
const copy = contentFor(locale).error;
|
||||
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
|
||||
@@ -41,19 +49,18 @@ export default function GlobalError({
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable} ${newsreader.variable}`}>
|
||||
<html lang={locale} className={`${inter.variable} ${newsreader.variable}`}>
|
||||
<body className="min-h-screen bg-white font-sans text-slate-900">
|
||||
<div className="flex flex-col items-center justify-center min-h-screen 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-200 rounded text-[10px] font-mono text-red-600 uppercase tracking-widest border-dashed">
|
||||
Root Level Error
|
||||
{copy.kicker}
|
||||
</span>
|
||||
<h1 className="text-4xl md:text-6xl font-black tracking-tighter text-slate-900">
|
||||
Systemausfall der Hauptebene.
|
||||
{copy.title}
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-slate-500 font-serif italic max-w-xl mx-auto">
|
||||
Ein kritischer Fehler auf der Root-Layout Ebene hat das Rendering
|
||||
blockiert. Der Vorfall wurde zur Untersuchung protokolliert.
|
||||
{copy.text}
|
||||
</p>
|
||||
|
||||
<div className="pt-8">
|
||||
@@ -62,7 +69,7 @@ export default function GlobalError({
|
||||
className="relative inline-flex items-center justify-center gap-3 overflow-hidden rounded-full font-bold uppercase tracking-[0.15em] transition-all duration-300 group cursor-pointer px-8 py-4 text-[10px] bg-slate-900 text-white hover:shadow-xl hover:-translate-y-0.5"
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-3">
|
||||
Notfall-Neustart (Reset)
|
||||
{copy.retry}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/src/content/site";
|
||||
import { sitemapEntries } from "@/src/domain/routes";
|
||||
|
||||
/**
|
||||
* The site is a single page in two languages; the legal pages are not meant for
|
||||
* search results. No modification date: it would have to be the date the content
|
||||
* last changed, and a date that moves with every build teaches search engines to
|
||||
* ignore it.
|
||||
*/
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return sitemapEntries(SITE_URL, ["home"]).map((entry) => ({
|
||||
url: entry.url,
|
||||
alternates: { languages: { ...entry.alternates.languages } },
|
||||
}));
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const nextConfig = {
|
||||
],
|
||||
},
|
||||
// The site is a single page. Old URLs from the previous site lead to it.
|
||||
// The language of a request and the legal pages are handled in proxy.ts.
|
||||
async redirects() {
|
||||
return [
|
||||
"/about",
|
||||
@@ -41,11 +42,7 @@ const nextConfig = {
|
||||
"/technologies/:path*",
|
||||
"/tags/:path*",
|
||||
]
|
||||
.map((source) => ({ source, destination: "/", permanent: true }))
|
||||
.concat([
|
||||
{ source: "/impressum", destination: "/imprint", permanent: true },
|
||||
{ source: "/datenschutz", destination: "/privacy", permanent: true },
|
||||
]);
|
||||
.map((source) => ({ source, destination: "/", permanent: true }));
|
||||
},
|
||||
// In Standalone mode, Next.js expects the tracing root to be the monorepo root
|
||||
outputFileTracingRoot: path.join(dirname, '../../'),
|
||||
|
||||
@@ -16,7 +16,7 @@ books a 20-minute intro call within 2 minutes. Low sales overhead, more bookable
|
||||
- **No internal method vocabulary** on client-facing pages: no specs, TDD, mutation testing, coverage, pull requests. Translate into what the client gets: changes do not break things, a written update every week, predictable cost. Technical detail belongs on `/process` and `/notes`, for engineers.
|
||||
- Do not say: "everything", "fullstack for anyone", "vibe coding". No location or tax talk; say "remote, available during European business hours".
|
||||
- Pick the visitor up: introduce me first, then let them choose their need (developer for a product, make an app reliable, just a website), then explain for that need what they get, how it runs, what it costs.
|
||||
- Language: English primary, German route `/de` for DACH.
|
||||
- Language (owner decision, 2026-10-04): German is the main language at the root, English lives under `/en`. The language is detected from the browser and can be switched in the header.
|
||||
|
||||
### Audiences (priority order)
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { LOCALE_COOKIE } from "@/src/domain/locale";
|
||||
import { routeRequest } from "@/src/domain/routes";
|
||||
|
||||
const PERMANENT = 308;
|
||||
const TEMPORARY = 307;
|
||||
|
||||
/**
|
||||
* Picks the language of a request and maps the public addresses onto the app
|
||||
* directory. All decisions are made in src/domain/routes.ts; this only carries
|
||||
* them out.
|
||||
*/
|
||||
export function proxy(request: NextRequest) {
|
||||
const decision = routeRequest({
|
||||
pathname: request.nextUrl.pathname,
|
||||
cookie: request.cookies.get(LOCALE_COOKIE)?.value,
|
||||
acceptLanguage: request.headers.get("accept-language"),
|
||||
});
|
||||
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = decision.to;
|
||||
const response =
|
||||
decision.kind === "redirect"
|
||||
? NextResponse.redirect(url, decision.permanent ? PERMANENT : TEMPORARY)
|
||||
: NextResponse.rewrite(url);
|
||||
|
||||
// The answer depends on the visitor's language: caches must keep one per language.
|
||||
if (decision.negotiated) {
|
||||
response.headers.append("Vary", "Accept-Language, Cookie");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Pages only: not Next's own files, the analytics and error proxies, or files.
|
||||
matcher: ["/((?!_next/|stats/|errors/|.*\\..*).*)"],
|
||||
};
|
||||
@@ -1,25 +1,35 @@
|
||||
// Renders the share image (Open Graph and Twitter) from the real hero, so it always
|
||||
// matches the site. Run it after the hero headline or its design changes:
|
||||
// Renders the share images (Open Graph and Twitter) from the real hero, one per
|
||||
// language, so they always match the site. Run it after the hero headline or its
|
||||
// design changes:
|
||||
//
|
||||
// pnpm --filter @mintel/web share-image (uses the live site)
|
||||
// pnpm --filter @mintel/web share-image http://mintel.localhost/
|
||||
// pnpm --filter @mintel/web share-image http://localhost:3000
|
||||
//
|
||||
// It drives the locally installed Chrome through Playwright and writes
|
||||
// app/(site)/opengraph-image.png and app/(site)/twitter-image.png.
|
||||
import { copyFileSync } from "node:fs";
|
||||
// src/assets/share/share-de.png and share-en.png (used by app/[locale]/layout.tsx).
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const url = process.argv[2] ?? "https://mintel.me/";
|
||||
const out = "app/(site)/opengraph-image.png";
|
||||
const origin = (process.argv[2] ?? "https://mintel.me").replace(/\/$/, "");
|
||||
// The browser language matches the page, so the language detection lets it through.
|
||||
const SHOTS = [
|
||||
{ locale: "de", path: "/", browserLocale: "de-DE" },
|
||||
{ locale: "en", path: "/en", browserLocale: "en-US" },
|
||||
];
|
||||
const browser = await chromium.launch({ channel: "chrome", headless: true });
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1200, height: 630 },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
await page.goto(url, { waitUntil: "load", timeout: 90000 });
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
header, footer, #hero-cta, [class*="fixed"] { display: none !important; }
|
||||
for (const shot of SHOTS) {
|
||||
const out = `src/assets/share/share-${shot.locale}.png`;
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1200, height: 630 },
|
||||
deviceScaleFactor: 1,
|
||||
locale: shot.browserLocale,
|
||||
});
|
||||
await page.goto(`${origin}${shot.path}`, {
|
||||
waitUntil: "load",
|
||||
timeout: 90000,
|
||||
});
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
header, footer, #hero-cta, [class*="fixed"], nextjs-portal { display: none !important; }
|
||||
main > div > section:first-of-type > div.relative { min-height: 630px !important; height: 630px !important; padding: 0 72px !important; justify-content: center !important; max-width: none !important; }
|
||||
main > div > section:first-of-type ul { display: none !important; }
|
||||
main > div > section:first-of-type h1 { font-size: 150px !important; }
|
||||
@@ -29,26 +39,29 @@ await page.addStyleTag({
|
||||
main > div > section:first-of-type > div.relative { padding-top: 34px !important; }
|
||||
html, body { overflow: hidden !important; }
|
||||
`,
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
const section = document.querySelector("main section");
|
||||
const tag = document.createElement("div");
|
||||
tag.textContent = "MARC MINTEL";
|
||||
tag.style.cssText =
|
||||
"position:absolute;left:72px;top:54px;z-index:20;font:700 15px ui-monospace,Menlo,monospace;letter-spacing:.3em;color:#0f172a";
|
||||
const site = document.createElement("div");
|
||||
site.textContent = "mintel.me";
|
||||
site.style.cssText =
|
||||
"position:absolute;right:72px;top:54px;z-index:20;font:500 15px ui-monospace,Menlo,monospace;letter-spacing:.2em;color:#64748b";
|
||||
section.style.position = "relative";
|
||||
section.append(tag, site);
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
await page.waitForTimeout(4500);
|
||||
await page.screenshot({
|
||||
path: out,
|
||||
clip: { x: 0, y: 0, width: 1200, height: 630 },
|
||||
});
|
||||
});
|
||||
// Let the page finish starting up first; it would discard the added labels.
|
||||
await page.waitForTimeout(2000);
|
||||
await page.evaluate(() => {
|
||||
const section = document.querySelector("main section");
|
||||
const tag = document.createElement("div");
|
||||
tag.textContent = "MARC MINTEL";
|
||||
tag.style.cssText =
|
||||
"position:absolute;left:72px;top:54px;z-index:20;font:700 15px ui-monospace,Menlo,monospace;letter-spacing:.3em;color:#0f172a";
|
||||
const site = document.createElement("div");
|
||||
site.textContent = "mintel.me";
|
||||
site.style.cssText =
|
||||
"position:absolute;right:72px;top:54px;z-index:20;font:500 15px ui-monospace,Menlo,monospace;letter-spacing:.2em;color:#64748b";
|
||||
section.style.position = "relative";
|
||||
section.append(tag, site);
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
await page.waitForTimeout(4500);
|
||||
await page.screenshot({
|
||||
path: out,
|
||||
clip: { x: 0, y: 0, width: 1200, height: 630 },
|
||||
});
|
||||
await page.close();
|
||||
console.log("written:", out);
|
||||
}
|
||||
await browser.close();
|
||||
copyFileSync(out, "app/(site)/twitter-image.png");
|
||||
console.log("written:", out, "and app/(site)/twitter-image.png");
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { Content } from "@/src/content/types";
|
||||
import type { Locale } from "@/src/domain/locale";
|
||||
|
||||
type Value = { readonly locale: Locale; readonly content: Content };
|
||||
|
||||
const ContentContext = React.createContext<Value | null>(null);
|
||||
|
||||
/**
|
||||
* ContentProvider: hands the language of the page and its texts to the client
|
||||
* components. The layout picks both; only the texts of that one language are
|
||||
* sent to the browser.
|
||||
*/
|
||||
export const ContentProvider: React.FC<
|
||||
Value & { children: React.ReactNode }
|
||||
> = ({ locale, content, children }) => {
|
||||
const value = React.useMemo(() => ({ locale, content }), [locale, content]);
|
||||
return (
|
||||
<ContentContext.Provider value={value}>{children}</ContentContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useValue = (): Value => {
|
||||
const value = React.useContext(ContentContext);
|
||||
if (value === null) {
|
||||
throw new Error("useContent and useLocale need a ContentProvider above.");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const useContent = (): Content => useValue().content;
|
||||
export const useLocale = (): Locale => useValue().locale;
|
||||
@@ -1,11 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
|
||||
import LogoBlack from "../assets/logo/Logo-Black-Transparent.svg";
|
||||
import { pathFor } from "@/src/domain/routes";
|
||||
import { BinaryTicker } from "./bits/BinaryTicker";
|
||||
import { useContent, useLocale } from "./ContentProvider";
|
||||
import { LanguageSwitch } from "./LanguageSwitch";
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
const { nav } = useContent();
|
||||
const locale = useLocale();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
@@ -38,24 +45,25 @@ export const Footer: React.FC = () => {
|
||||
<span>© {currentYear}</span>
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-3 md:justify-end">
|
||||
<Link
|
||||
href="/#contact"
|
||||
href={`${pathFor("home", locale)}#contact`}
|
||||
className="hover:text-slate-900 transition-colors duration-300 no-underline"
|
||||
>
|
||||
Get in touch
|
||||
{nav.contactLong}
|
||||
</Link>
|
||||
<Link
|
||||
href="/imprint"
|
||||
href={pathFor("imprint", locale)}
|
||||
className="hover:text-slate-900 transition-colors duration-300 no-underline"
|
||||
>
|
||||
Imprint
|
||||
{nav.imprint}
|
||||
</Link>
|
||||
<Link
|
||||
href="/privacy"
|
||||
href={pathFor("privacy", locale)}
|
||||
className="hover:text-slate-900 transition-colors duration-300 no-underline"
|
||||
>
|
||||
Privacy
|
||||
{nav.privacy}
|
||||
</Link>
|
||||
</div>
|
||||
<LanguageSwitch variant="text" />
|
||||
{/* Subtle binary decoration */}
|
||||
<span className="flex gap-3 text-[8px] text-slate-300 tracking-[0.5em] select-none font-mono">
|
||||
{[77, 73, 78].map((code) => (
|
||||
|
||||
@@ -3,12 +3,17 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
|
||||
import { pathFor } from "@/src/domain/routes";
|
||||
import IconWhite from "../assets/logo/Icon-White-Transparent.svg";
|
||||
import { DecodeText } from "./bits/DecodeText";
|
||||
import { ScrollBits } from "./bits/ScrollBits";
|
||||
import { useContent, useLocale } from "./ContentProvider";
|
||||
import { LanguageSwitch } from "./LanguageSwitch";
|
||||
|
||||
/** Header: the logo and one action. Everything else lives on the homepage. */
|
||||
/** Header: the logo, the language switch and one action. Everything else lives on the homepage. */
|
||||
export const Header: React.FC = () => {
|
||||
const { nav } = useContent();
|
||||
const home = pathFor("home", useLocale());
|
||||
const [isScrolled, setIsScrolled] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -32,11 +37,11 @@ export const Header: React.FC = () => {
|
||||
|
||||
<div className="mx-auto w-full max-w-[1400px] px-5 md:px-12 py-4 flex items-center justify-between relative z-10">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Marc Mintel, back to top"
|
||||
href={home}
|
||||
aria-label={nav.logoLabel}
|
||||
onClick={(event) => {
|
||||
// Already on the homepage: scroll up instead of reloading the route.
|
||||
if (window.location.pathname === "/") {
|
||||
if (window.location.pathname === home) {
|
||||
event.preventDefault();
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
@@ -56,15 +61,18 @@ export const Header: React.FC = () => {
|
||||
|
||||
<ScrollBits className="hidden font-mono text-[10px] tracking-[0.3em] text-slate-400 md:block" />
|
||||
|
||||
<Link
|
||||
href="/#contact"
|
||||
className="bit-cta bit-cta-quiet font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full no-underline hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
|
||||
style={{
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
<DecodeText text="Get in touch" trigger="hover" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2.5 md:gap-4">
|
||||
<LanguageSwitch />
|
||||
<Link
|
||||
href={`${home}#contact`}
|
||||
className="bit-cta bit-cta-quiet font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full no-underline hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
|
||||
style={{
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
<DecodeText text={nav.contact} trigger="hover" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useContent } from "./ContentProvider";
|
||||
|
||||
export const InteractiveElements: React.FC = () => {
|
||||
const { nav } = useContent();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
|
||||
@@ -42,7 +44,7 @@ export const InteractiveElements: React.FC = () => {
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className={`floating-back-to-top ${showBackToTop ? "visible" : ""}`}
|
||||
aria-label="Back to top"
|
||||
aria-label={nav.backToTop}
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { LOCALES, localeCookie, type Locale } from "@/src/domain/locale";
|
||||
import { switchPath } from "@/src/domain/routes";
|
||||
import { DecodeText } from "./bits/DecodeText";
|
||||
import { useContent, useLocale } from "./ContentProvider";
|
||||
import {
|
||||
getIdleState,
|
||||
getSwitchState,
|
||||
startSwitch,
|
||||
subscribe,
|
||||
} from "./locale-switch-store";
|
||||
|
||||
const EASE = "cubic-bezier(0.23, 1, 0.32, 1)";
|
||||
|
||||
/**
|
||||
* LanguageSwitch: the two languages side by side, with a marker that slides to
|
||||
* the chosen one. Choosing a language closes the bit curtain (see BitCurtain),
|
||||
* swaps the page behind it and keeps the reading position. The choice is stored
|
||||
* in a cookie so that the language detection does not undo it.
|
||||
*
|
||||
* The options are plain links to the other language's address, so the switch
|
||||
* also works without JavaScript and for search engines.
|
||||
*/
|
||||
export const LanguageSwitch: React.FC<{
|
||||
/** `pill` for the header, `text` for the footer. */
|
||||
variant?: "pill" | "text";
|
||||
}> = ({ variant = "pill" }) => {
|
||||
const locale = useLocale();
|
||||
const { nav } = useContent();
|
||||
const pathname = usePathname();
|
||||
const state = React.useSyncExternalStore(
|
||||
subscribe,
|
||||
getSwitchState,
|
||||
getIdleState,
|
||||
);
|
||||
// The marker moves as soon as a language is chosen, before the page follows.
|
||||
const shown = state.phase === "idle" ? locale : state.target;
|
||||
|
||||
const choose = (target: Locale) => (event: React.MouseEvent) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (target === locale) return;
|
||||
document.cookie = localeCookie(target);
|
||||
startSwitch(locale, target, switchPath(pathname, target));
|
||||
};
|
||||
|
||||
if (variant === "text") {
|
||||
return (
|
||||
<nav aria-label={nav.language} className="flex gap-x-4">
|
||||
{LOCALES.map((option) => (
|
||||
<a
|
||||
key={option}
|
||||
href={switchPath(pathname, option)}
|
||||
hrefLang={option}
|
||||
lang={option}
|
||||
aria-current={option === locale ? "true" : undefined}
|
||||
onClick={choose(option)}
|
||||
className={`no-underline transition-colors duration-300 hover:text-slate-900 ${
|
||||
option === locale ? "text-slate-900" : "text-slate-400"
|
||||
}`}
|
||||
>
|
||||
{nav.languageNames[option]}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={nav.language}
|
||||
className="group/lang relative flex items-center rounded-full border border-slate-200 bg-white/70 p-[3px] font-mono text-[10px] font-bold tracking-[0.2em] transition-colors duration-500 hover:border-slate-400"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-[3px] left-[3px] w-[calc(50%-3px)] rounded-full bg-slate-900 shadow-sm transition-transform duration-500"
|
||||
style={{
|
||||
transform: `translateX(${LOCALES.indexOf(shown) * 100}%)`,
|
||||
transitionTimingFunction: EASE,
|
||||
}}
|
||||
/>
|
||||
{LOCALES.map((option) => (
|
||||
<a
|
||||
key={option}
|
||||
href={switchPath(pathname, option)}
|
||||
hrefLang={option}
|
||||
lang={option}
|
||||
aria-label={nav.languageNames[option]}
|
||||
aria-current={option === locale ? "true" : undefined}
|
||||
title={nav.languageNames[option]}
|
||||
onClick={choose(option)}
|
||||
className={`relative z-10 grid h-8 w-10 place-items-center rounded-full pl-[0.2em] no-underline transition-colors duration-300 md:h-9 md:w-11 ${
|
||||
option === shown
|
||||
? "text-white"
|
||||
: "text-slate-400 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
<DecodeText text={option.toUpperCase()} trigger="hover" />
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useContent, useLocale } from "@/src/components/ContentProvider";
|
||||
import {
|
||||
endSwitch,
|
||||
enterPhase,
|
||||
getIdleState,
|
||||
getSwitchState,
|
||||
subscribe,
|
||||
type SwitchState,
|
||||
} from "@/src/components/locale-switch-store";
|
||||
import {
|
||||
captureAnchor,
|
||||
restoreAnchor,
|
||||
type SectionBox,
|
||||
} from "@/src/domain/scroll-anchor";
|
||||
|
||||
const COVER_MS = 460;
|
||||
const REVEAL_MS = 560;
|
||||
/** The curtain stays closed at least this long, so the language name can be read. */
|
||||
const HOLD_MS = 320;
|
||||
const LABEL_DELAY_MS = 140;
|
||||
const LABEL_MS = 420;
|
||||
/** If the new page has not arrived by then, load it the plain way. */
|
||||
const GIVE_UP_MS = 6000;
|
||||
|
||||
const CELL = 16;
|
||||
/** How ragged the edge of the curtain is, as a share of the screen width. */
|
||||
const SPREAD = 0.24;
|
||||
const TRAIL = 11;
|
||||
|
||||
const INK = "#0f172a"; // slate-900
|
||||
const MARK = "#fde68a";
|
||||
|
||||
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
|
||||
const easeInOut = (t: number): number =>
|
||||
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
|
||||
/** A fixed pseudo-random number per row, so the edge looks the same after the page swap. */
|
||||
const rowLag = (row: number): number => {
|
||||
const x = Math.sin(row * 12.9898) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
const sectionBoxes = (): SectionBox[] =>
|
||||
Array.from(document.querySelectorAll("main section")).map((section) => {
|
||||
const rect = section.getBoundingClientRect();
|
||||
return { top: rect.top + window.scrollY, height: rect.height };
|
||||
});
|
||||
|
||||
/** A tile of faint digits that textures the closed curtain. */
|
||||
const makeTile = (dpr: number, mono: string): HTMLCanvasElement => {
|
||||
const cells = 8;
|
||||
const tile = document.createElement("canvas");
|
||||
tile.width = cells * CELL * dpr;
|
||||
tile.height = cells * CELL * dpr;
|
||||
const ctx = tile.getContext("2d");
|
||||
if (!ctx) return tile;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.fillStyle = INK;
|
||||
ctx.fillRect(0, 0, cells * CELL, cells * CELL);
|
||||
ctx.font = `500 11px ${mono}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(148, 163, 184, 0.16)";
|
||||
for (let row = 0; row < cells; row++) {
|
||||
for (let col = 0; col < cells; col++) {
|
||||
const bit = rowLag(row * cells + col + 1) < 0.5 ? "0" : "1";
|
||||
ctx.fillText(bit, col * CELL + CELL / 2, row * CELL + CELL / 2);
|
||||
}
|
||||
}
|
||||
return tile;
|
||||
};
|
||||
|
||||
/**
|
||||
* BitCurtain: the transition of the language switch. A curtain of binary digits
|
||||
* closes over the page from left to right, the name of the new language resolves
|
||||
* out of bits on it, the page is swapped behind it, and the curtain opens again
|
||||
* on the same spot of the page in the other language.
|
||||
*
|
||||
* With reduced motion there is no curtain: the page is swapped at once.
|
||||
*/
|
||||
export const BitCurtain: React.FC = () => {
|
||||
const locale = useLocale();
|
||||
const { nav } = useContent();
|
||||
const router = useRouter();
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const state = React.useSyncExternalStore(
|
||||
subscribe,
|
||||
getSwitchState,
|
||||
getIdleState,
|
||||
);
|
||||
const active = state.phase !== "idle";
|
||||
|
||||
// The loop below reads these through refs, so it survives re-renders.
|
||||
const localeRef = React.useRef(locale);
|
||||
localeRef.current = locale;
|
||||
const namesRef = React.useRef(nav.languageNames);
|
||||
namesRef.current = nav.languageNames;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) return;
|
||||
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext("2d") ?? null;
|
||||
|
||||
const mono =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--font-jetbrains-mono")
|
||||
.trim() || "ui-monospace";
|
||||
const font = `${mono}, ui-monospace, Menlo, monospace`;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let pattern: CanvasPattern | null = null;
|
||||
|
||||
const resize = () => {
|
||||
if (!canvas || !ctx) return;
|
||||
width = window.innerWidth;
|
||||
height = window.innerHeight;
|
||||
canvas.width = Math.ceil(width * dpr);
|
||||
canvas.height = Math.ceil(height * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
pattern = ctx.createPattern(makeTile(dpr, font), "repeat");
|
||||
// The tile is drawn at device resolution; scale it back to CSS pixels.
|
||||
pattern?.setTransform(new DOMMatrix().scale(1 / dpr));
|
||||
};
|
||||
|
||||
const paint = (current: Exclude<SwitchState, { phase: "idle" }>) => {
|
||||
if (!ctx) return;
|
||||
const now = performance.now();
|
||||
const elapsed = now - current.phaseAt;
|
||||
const opening = current.phase === "reveal";
|
||||
const progress =
|
||||
current.phase === "hold"
|
||||
? 1
|
||||
: easeInOut(clamp01(elapsed / (opening ? REVEAL_MS : COVER_MS)));
|
||||
const rows = Math.ceil(height / CELL);
|
||||
|
||||
// The edge of the curtain in each row. Closing, the curtain is left of it;
|
||||
// opening, right of it.
|
||||
const edge = (row: number): number =>
|
||||
current.phase === "hold"
|
||||
? width
|
||||
: clamp01(progress * (1 + SPREAD) - rowLag(row) * SPREAD) * width;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const x = edge(row);
|
||||
if (opening) ctx.rect(x, row * CELL, width - x, CELL);
|
||||
else ctx.rect(0, row * CELL, x, CELL);
|
||||
}
|
||||
ctx.clip();
|
||||
ctx.fillStyle = pattern ?? INK;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// The name of the language, resolving out of bits. Clipped to the curtain.
|
||||
const sinceStart = now - current.startedAt - LABEL_DELAY_MS;
|
||||
const name = namesRef.current[current.target].toUpperCase();
|
||||
const shown = Math.floor(clamp01(sinceStart / LABEL_MS) * name.length);
|
||||
const text = Array.from(name)
|
||||
.map((char, i) => (i < shown ? char : Math.random() < 0.5 ? "0" : "1"))
|
||||
.join("");
|
||||
const size = Math.min(width * 0.15, 168);
|
||||
ctx.globalAlpha = clamp01(sinceStart / 160);
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
ctx.font = `800 ${size}px ${font}`;
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillText(text, width / 2, height / 2 + size * 0.2);
|
||||
const textWidth = ctx.measureText(text).width;
|
||||
ctx.fillStyle = MARK;
|
||||
ctx.fillRect(
|
||||
width / 2 - textWidth / 2,
|
||||
height / 2 + size * 0.42,
|
||||
textWidth * clamp01(sinceStart / LABEL_MS),
|
||||
Math.max(3, size * 0.035),
|
||||
);
|
||||
ctx.font = `500 12px ${font}`;
|
||||
ctx.fillStyle = "#94a3b8";
|
||||
ctx.fillText(
|
||||
`${current.from.toUpperCase()} → ${current.target.toUpperCase()}`,
|
||||
width / 2,
|
||||
height / 2 - size * 0.78,
|
||||
);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.restore();
|
||||
|
||||
// Loose digits along the moving edge.
|
||||
if (current.phase !== "hold") {
|
||||
ctx.font = `700 12px ${font}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const x = edge(row);
|
||||
if (x <= 0 || x >= width) continue;
|
||||
for (let k = -2; k < TRAIL; k++) {
|
||||
// k counts cells from the edge into the curtain; negative is on the page.
|
||||
const cx = x + (opening ? 1 : -1) * (k + 0.5) * CELL;
|
||||
const onPage = k < 0;
|
||||
ctx.globalAlpha = onPage ? 0.5 : (1 - k / TRAIL) * 0.9;
|
||||
ctx.fillStyle = onPage ? INK : k < 2 ? MARK : "#ffffff";
|
||||
ctx.fillText(
|
||||
Math.random() < 0.5 ? "0" : "1",
|
||||
cx,
|
||||
row * CELL + CELL / 2,
|
||||
);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
};
|
||||
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
const current = getSwitchState();
|
||||
if (current.phase === "idle") return;
|
||||
const elapsed = performance.now() - current.phaseAt;
|
||||
|
||||
if (current.phase === "cover" && (still || elapsed >= COVER_MS)) {
|
||||
enterPhase("hold", captureAnchor(sectionBoxes(), window.scrollY));
|
||||
router.push(current.href, { scroll: false });
|
||||
} else if (current.phase === "hold") {
|
||||
const arrived = localeRef.current === current.target;
|
||||
if (arrived && (still || elapsed >= HOLD_MS)) {
|
||||
window.scrollTo({
|
||||
top: restoreAnchor(current.anchor, sectionBoxes()),
|
||||
behavior: "instant",
|
||||
});
|
||||
if (still) endSwitch();
|
||||
else enterPhase("reveal");
|
||||
} else if (elapsed > GIVE_UP_MS) {
|
||||
endSwitch();
|
||||
window.location.assign(current.href);
|
||||
return;
|
||||
}
|
||||
} else if (current.phase === "reveal" && elapsed >= REVEAL_MS) {
|
||||
endSwitch();
|
||||
return;
|
||||
}
|
||||
|
||||
const next = getSwitchState();
|
||||
if (next.phase === "idle") return;
|
||||
if (!still) paint(next);
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const started = getSwitchState();
|
||||
if (started.phase !== "idle") router.prefetch(started.href);
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, [active, router]);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`fixed inset-0 z-[300] motion-reduce:hidden ${active ? "block" : "hidden"}`}
|
||||
style={{
|
||||
// Closed: solid behind the canvas, so the page swap never shows through.
|
||||
backgroundColor: state.phase === "hold" ? INK : "transparent",
|
||||
}}
|
||||
>
|
||||
<canvas ref={canvasRef} className="block h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -3,13 +3,9 @@
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
import * as React from "react";
|
||||
import { useNarrow } from "./useNarrow";
|
||||
import {
|
||||
KIND_LABEL,
|
||||
WORKFLOWS,
|
||||
type FlowNode,
|
||||
type NodeKind,
|
||||
type Workflow,
|
||||
} from "@/src/content/automation";
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
import { workflowsFor, type FlowNode } from "@/src/content/automation";
|
||||
import type { NodeKind } from "@/src/content/types";
|
||||
|
||||
/**
|
||||
* AutomationFlow: a workflow drawn as connected systems, with the work visibly
|
||||
@@ -95,6 +91,9 @@ const KIND_STYLE: Readonly<
|
||||
human: { stroke: "#f8fafc", dash: "2 4", tag: "#f8fafc" },
|
||||
};
|
||||
|
||||
/** The order of the legend below the drawing. */
|
||||
const KINDS: readonly NodeKind[] = ["trigger", "rule", "ai", "action", "human"];
|
||||
|
||||
type Clock = {
|
||||
/** Which run of the workflow is playing. */
|
||||
readonly run: number;
|
||||
@@ -107,7 +106,12 @@ type Clock = {
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
|
||||
|
||||
export const AutomationFlow: React.FC = () => {
|
||||
const [flowId, setFlowId] = React.useState(WORKFLOWS[0]?.id ?? "");
|
||||
const { automation } = useContent();
|
||||
const workflows = React.useMemo(
|
||||
() => workflowsFor(automation.workflows),
|
||||
[automation.workflows],
|
||||
);
|
||||
const [flowId, setFlowId] = React.useState(workflows[0]?.id ?? "");
|
||||
const [clock, setClock] = React.useState<Clock>({ run: 0, step: 0, t: 0 });
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const [inView, setInView] = React.useState(false);
|
||||
@@ -116,7 +120,7 @@ export const AutomationFlow: React.FC = () => {
|
||||
const tall = useNarrow();
|
||||
const canvas = tall ? TALL : WIDE;
|
||||
|
||||
const flow = WORKFLOWS.find((item) => item.id === flowId) ?? WORKFLOWS[0];
|
||||
const flow = workflows.find((item) => item.id === flowId) ?? workflows[0];
|
||||
|
||||
React.useEffect(() => {
|
||||
const node = ref.current;
|
||||
@@ -195,10 +199,15 @@ export const AutomationFlow: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const logLines: string[] = [];
|
||||
const logLines: { text: string; kind: NodeKind }[] = [];
|
||||
const log = (node: Placed) => {
|
||||
if (logLines.every((line) => line.text !== node.log)) {
|
||||
logLines.push({ text: node.log, kind: node.kind });
|
||||
}
|
||||
};
|
||||
if (firstNode) {
|
||||
const first = nodeOf(firstNode);
|
||||
if (first) logLines.push(first.log);
|
||||
if (first) log(first);
|
||||
}
|
||||
steps.forEach((edges, index) => {
|
||||
const arrived =
|
||||
@@ -206,7 +215,7 @@ export const AutomationFlow: React.FC = () => {
|
||||
if (!arrived) return;
|
||||
edges.forEach(([, to]) => {
|
||||
const node = nodeOf(to);
|
||||
if (node && !logLines.includes(node.log)) logLines.push(node.log);
|
||||
if (node) log(node);
|
||||
});
|
||||
});
|
||||
const finished = clock.step >= steps.length;
|
||||
@@ -215,10 +224,10 @@ export const AutomationFlow: React.FC = () => {
|
||||
<div ref={ref}>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Example workflows"
|
||||
aria-label={automation.examplesLabel}
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
{WORKFLOWS.map((item) => {
|
||||
{workflows.map((item) => {
|
||||
const selected = item.id === flow.id;
|
||||
return (
|
||||
<button
|
||||
@@ -251,7 +260,7 @@ export const AutomationFlow: React.FC = () => {
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-amber-300 animate-pulse"}`}
|
||||
/>
|
||||
{finished ? "run complete" : "running"}
|
||||
{finished ? automation.runComplete : automation.running}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -383,53 +392,51 @@ export const AutomationFlow: React.FC = () => {
|
||||
|
||||
<div className="grid grid-cols-1 border-t border-slate-800 md:grid-cols-12">
|
||||
<div className="border-b border-slate-800 px-5 py-5 md:col-span-7 md:border-b-0 md:border-r md:px-7">
|
||||
<p className={`${MONO} m-0 text-slate-500`}>Log</p>
|
||||
<p className={`${MONO} m-0 text-slate-500`}>{automation.log}</p>
|
||||
<ol
|
||||
className="m-0 mt-3 min-h-[7.5rem] list-none p-0 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]"
|
||||
aria-live="off"
|
||||
>
|
||||
{logLines.map((line, index) => (
|
||||
<li key={line} className="flex gap-3">
|
||||
<li key={line.text} className="flex gap-3">
|
||||
<span className="text-slate-600">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
line.startsWith("unclear")
|
||||
? "text-amber-200"
|
||||
: undefined
|
||||
line.kind === "human" ? "text-amber-200" : undefined
|
||||
}
|
||||
>
|
||||
{line}
|
||||
{line.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<ul className="m-0 flex list-none flex-col justify-center gap-2 px-5 py-5 md:col-span-5 md:px-7">
|
||||
{(Object.keys(KIND_LABEL) as NodeKind[])
|
||||
.filter((kind) => flow.nodes.some((node) => node.kind === kind))
|
||||
.map((kind) => (
|
||||
<li
|
||||
key={kind}
|
||||
className="flex items-center gap-3 font-mono text-[11px] text-slate-400"
|
||||
>
|
||||
<svg width="22" height="12" aria-hidden="true">
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="20"
|
||||
height="10"
|
||||
rx="3"
|
||||
fill="none"
|
||||
stroke={KIND_STYLE[kind].stroke}
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray={KIND_STYLE[kind].dash}
|
||||
/>
|
||||
</svg>
|
||||
{KIND_LABEL[kind]}
|
||||
</li>
|
||||
))}
|
||||
{KINDS.filter((kind) =>
|
||||
flow.nodes.some((node) => node.kind === kind),
|
||||
).map((kind) => (
|
||||
<li
|
||||
key={kind}
|
||||
className="flex items-center gap-3 font-mono text-[11px] text-slate-400"
|
||||
>
|
||||
<svg width="22" height="12" aria-hidden="true">
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="20"
|
||||
height="10"
|
||||
rx="3"
|
||||
fill="none"
|
||||
stroke={KIND_STYLE[kind].stroke}
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray={KIND_STYLE[kind].dash}
|
||||
/>
|
||||
</svg>
|
||||
{automation.kinds[kind]}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -437,13 +444,13 @@ export const AutomationFlow: React.FC = () => {
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div className="rounded-3xl border border-slate-200 p-6 md:p-8">
|
||||
<p className={`${MONO} m-0 text-slate-400`}>By hand</p>
|
||||
<p className={`${MONO} m-0 text-slate-400`}>{automation.byHand}</p>
|
||||
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-500 md:text-xl">
|
||||
{flow.byHand}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-slate-900 p-6 md:p-8">
|
||||
<p className={`${MONO} m-0 text-slate-900`}>Automated</p>
|
||||
<p className={`${MONO} m-0 text-slate-900`}>{automation.automated}</p>
|
||||
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-900 md:text-xl">
|
||||
{flow.automated}
|
||||
</p>
|
||||
|
||||
@@ -3,12 +3,7 @@
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
|
||||
export type CareerStation = {
|
||||
readonly years: string;
|
||||
readonly role: string;
|
||||
readonly place: string;
|
||||
};
|
||||
import type { CareerStation } from "@/src/content/types";
|
||||
|
||||
/**
|
||||
* CareerLine: the path so far as a line of light that draws itself, one station
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
import { useNarrow } from "./useNarrow";
|
||||
|
||||
const INK = "#f8fafc";
|
||||
@@ -10,8 +11,6 @@ const MARK = "#fde68a";
|
||||
const NODE_W = 112;
|
||||
const NODE_H = 38;
|
||||
|
||||
const CHAIN = ["You", "Sales", "Manager", "Designer", "Developer"] as const;
|
||||
|
||||
const Node: React.FC<{
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -78,12 +77,13 @@ const Caption: React.FC<{
|
||||
*/
|
||||
export const ContactDiagram: React.FC = () => {
|
||||
const tall = useNarrow();
|
||||
const label =
|
||||
"Often a request passes through several people before it reaches the developer. With me there is one direct line between you and the person who does the work.";
|
||||
const copy = useContent().working.diagram;
|
||||
const chain = copy.chain;
|
||||
const label = copy.description;
|
||||
|
||||
if (tall) {
|
||||
// Two columns: the chain on the left, the direct line on the right.
|
||||
const ys = CHAIN.map((_, i) => 96 + i * 84);
|
||||
const ys = chain.map((_, i) => 96 + i * 84);
|
||||
const first = ys[0] ?? 96;
|
||||
const last = ys[ys.length - 1] ?? 432;
|
||||
const stops = ys.flatMap((y) => [y, y]);
|
||||
@@ -95,10 +95,10 @@ export const ContactDiagram: React.FC = () => {
|
||||
className="block h-auto w-full"
|
||||
>
|
||||
<Caption x={24} y={38} size={12}>
|
||||
often
|
||||
{copy.often}
|
||||
</Caption>
|
||||
<Caption x={196} y={38} size={12} strong>
|
||||
with me
|
||||
{copy.withMe}
|
||||
</Caption>
|
||||
<line
|
||||
x1={90}
|
||||
@@ -109,7 +109,7 @@ export const ContactDiagram: React.FC = () => {
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
{CHAIN.map((name, i) => (
|
||||
{chain.map((name, i) => (
|
||||
<Node
|
||||
key={name}
|
||||
x={90}
|
||||
@@ -143,8 +143,8 @@ export const ContactDiagram: React.FC = () => {
|
||||
stroke={INK}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Node x={250} y={first} label="You" size={12} />
|
||||
<Node x={250} y={last} label="Me" size={12} solid />
|
||||
<Node x={250} y={first} label={copy.you} size={12} />
|
||||
<Node x={250} y={last} label={copy.me} size={12} solid />
|
||||
<motion.circle
|
||||
r={5}
|
||||
cx={250}
|
||||
@@ -159,7 +159,7 @@ export const ContactDiagram: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const xs = CHAIN.map((_, i) => 76 + i * 132);
|
||||
const xs = chain.map((_, i) => 76 + i * 132);
|
||||
const first = xs[0] ?? 76;
|
||||
const last = xs[xs.length - 1] ?? 604;
|
||||
const stops = xs.flatMap((x) => [x, x]);
|
||||
@@ -183,7 +183,7 @@ export const ContactDiagram: React.FC = () => {
|
||||
<rect width="680" height="250" fill="url(#contact-grid)" />
|
||||
|
||||
<Caption x={20} y={30} size={10}>
|
||||
often: a request is passed along
|
||||
{copy.oftenLong}
|
||||
</Caption>
|
||||
<line
|
||||
x1={first}
|
||||
@@ -194,7 +194,7 @@ export const ContactDiagram: React.FC = () => {
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
{CHAIN.map((name, i) => (
|
||||
{chain.map((name, i) => (
|
||||
<Node
|
||||
key={name}
|
||||
x={xs[i] ?? 0}
|
||||
@@ -222,7 +222,7 @@ export const ContactDiagram: React.FC = () => {
|
||||
/>
|
||||
|
||||
<Caption x={20} y={160} size={10} strong>
|
||||
with me: one direct line
|
||||
{copy.withMeLong}
|
||||
</Caption>
|
||||
<line
|
||||
x1={first}
|
||||
@@ -232,8 +232,8 @@ export const ContactDiagram: React.FC = () => {
|
||||
stroke={INK}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Node x={first} y={204} label="You" size={10} />
|
||||
<Node x={last} y={204} label="Me" size={10} solid />
|
||||
<Node x={first} y={204} label={copy.you} size={10} />
|
||||
<Node x={last} y={204} label={copy.me} size={10} solid />
|
||||
<motion.circle
|
||||
r={5}
|
||||
cy={204}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
import type { SceneLabels } from "@/src/content/types";
|
||||
import { fill } from "@/src/domain/template";
|
||||
|
||||
/**
|
||||
* PathScenes: one small animated drawing per step of a service path.
|
||||
@@ -40,6 +43,9 @@ const appear = (delay: number, y = 6) => ({
|
||||
transition: { duration: 0.4, delay, ease: "easeOut" as const },
|
||||
});
|
||||
|
||||
/** A drawing of one step. `l` holds its labels in the language of the page. */
|
||||
type Scene = React.FC<{ l: SceneLabels }>;
|
||||
|
||||
type Pt = readonly [number, number];
|
||||
|
||||
/** Points along a cubic curve, for packets that travel on it. */
|
||||
@@ -133,10 +139,10 @@ const Person: React.FC<{ x: number; label: string; filled?: boolean }> = ({
|
||||
);
|
||||
|
||||
/** Step 1 of every path: a short conversation. */
|
||||
const Talk: React.FC = () => (
|
||||
const Talk: Scene = ({ l }) => (
|
||||
<g>
|
||||
<Person x={84} label="You" />
|
||||
<Person x={396} label="Me" filled />
|
||||
<Person x={84} label={l.you} />
|
||||
<Person x={396} label={l.me} filled />
|
||||
{[
|
||||
{ x: 150, y: 86, w: 120, side: "left", delay: 0.2 },
|
||||
{ x: 210, y: 124, w: 120, side: "right", delay: 0.7 },
|
||||
@@ -194,17 +200,15 @@ const Talk: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(1.6, 0)}
|
||||
>
|
||||
about 20 minutes
|
||||
{l.talkLength}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"] as const;
|
||||
|
||||
/** Developer, step 2: agreed days of the week. */
|
||||
const WeeklyHours: React.FC = () => (
|
||||
const WeeklyHours: Scene = ({ l }) => (
|
||||
<g>
|
||||
{DAYS.map((day, d) => (
|
||||
{l.days.map((day, d) => (
|
||||
<g key={day}>
|
||||
<text x={78 + d * 72} y={52} textAnchor="middle" style={MONO}>
|
||||
{day}
|
||||
@@ -241,13 +245,13 @@ const WeeklyHours: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(1.3, 0)}
|
||||
>
|
||||
for example three days a week
|
||||
{l.daysNote}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Developer, step 3: the list gets worked through and comes back for review. */
|
||||
const WorkList: React.FC = () => (
|
||||
const WorkList: Scene = ({ l }) => (
|
||||
<g>
|
||||
{[0, 1, 2, 3].map((row) => {
|
||||
const done = row < 3;
|
||||
@@ -326,7 +330,7 @@ const WorkList: React.FC = () => (
|
||||
fontSize: "calc(var(--scene-label, 10px) - 1px)",
|
||||
}}
|
||||
>
|
||||
you
|
||||
{l.you}
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
@@ -340,13 +344,13 @@ const WorkList: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(1.8, 0)}
|
||||
>
|
||||
finished parts go to you for review
|
||||
{l.review}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Developer, step 4: two weeks notice to the end of a month. */
|
||||
const Notice: React.FC = () => (
|
||||
const Notice: Scene = ({ l }) => (
|
||||
<g>
|
||||
<line x1={50} x2={430} y1={150} y2={150} stroke={LINE} strokeWidth={1.5} />
|
||||
{Array.from({ length: 5 }, (_, w) => (
|
||||
@@ -360,7 +364,7 @@ const Notice: React.FC = () => (
|
||||
strokeWidth={FINE}
|
||||
/>
|
||||
<text x={50 + w * 95} y={182} textAnchor="middle" style={MONO}>
|
||||
{w === 4 ? "end of month" : `week ${w + 1}`}
|
||||
{w === 4 ? l.monthEnd : fill(l.week, { n: w + 1 })}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
@@ -382,7 +386,7 @@ const Notice: React.FC = () => (
|
||||
strokeWidth={FINE}
|
||||
/>
|
||||
<text x={335} y={92} textAnchor="middle" style={{ ...MONO, fill: INK }}>
|
||||
two weeks notice
|
||||
{l.notice}
|
||||
</text>
|
||||
</motion.g>
|
||||
<motion.circle cx={430} cy={150} r={4.5} fill={INK} {...appear(1.4, 0)} />
|
||||
@@ -414,7 +418,7 @@ const EDGES: readonly (readonly [number, number])[] = [
|
||||
];
|
||||
|
||||
/** Rescue, step 2: fragile connections become solid over the week. */
|
||||
const RepairWeek: React.FC = () => (
|
||||
const RepairWeek: Scene = ({ l }) => (
|
||||
<g>
|
||||
{EDGES.map(([a, b], i) => {
|
||||
const from = NODES[a]!;
|
||||
@@ -462,13 +466,13 @@ const RepairWeek: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(2.6, 0)}
|
||||
>
|
||||
fragile parts get tests and fixes
|
||||
{l.repair}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Rescue, step 3: a written summary. */
|
||||
const Summary: React.FC = () => (
|
||||
const Summary: Scene = ({ l }) => (
|
||||
<g>
|
||||
<rect
|
||||
x={150}
|
||||
@@ -480,7 +484,7 @@ const Summary: React.FC = () => (
|
||||
stroke={INK}
|
||||
strokeWidth={FINE}
|
||||
/>
|
||||
{["found", "changed", "next"].map((label, s) => (
|
||||
{l.summaryParts.map((label, s) => (
|
||||
<g key={label}>
|
||||
<motion.text
|
||||
x={170}
|
||||
@@ -512,13 +516,13 @@ const Summary: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(2.2, 0)}
|
||||
>
|
||||
written so you can follow it
|
||||
{l.summaryNote}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Rescue, step 4: the client decides how to continue. */
|
||||
const Decide: React.FC = () => (
|
||||
const Decide: Scene = ({ l }) => (
|
||||
<g>
|
||||
<circle
|
||||
cx={90}
|
||||
@@ -538,56 +542,55 @@ const Decide: React.FC = () => (
|
||||
fontSize: "calc(var(--scene-label, 10px) - 1px)",
|
||||
}}
|
||||
>
|
||||
you
|
||||
{l.you}
|
||||
</text>
|
||||
{[
|
||||
{ y: 70, label: "continue with me" },
|
||||
{ y: 150, label: "your own team" },
|
||||
{ y: 230, label: "stop here" },
|
||||
].map((option, i) => (
|
||||
<g key={option.label}>
|
||||
<motion.path
|
||||
d={`M 116 150 C 190 150 180 ${option.y} 250 ${option.y}`}
|
||||
fill="none"
|
||||
stroke={INK}
|
||||
strokeWidth={FINE}
|
||||
{...draw(0.3 + i * 0.4)}
|
||||
/>
|
||||
<Packet
|
||||
{...along([116, 150], [190, 150], [180, option.y], [250, option.y])}
|
||||
delay={1.6 + i * 1.3}
|
||||
duration={1.1}
|
||||
pause={2.8}
|
||||
/>
|
||||
<motion.g {...appear(0.7 + i * 0.4, 0)}>
|
||||
<rect
|
||||
x={250}
|
||||
y={option.y - 16}
|
||||
width={180}
|
||||
height={32}
|
||||
rx={16}
|
||||
fill={PAPER}
|
||||
stroke={LINE}
|
||||
{[70, 150, 230]
|
||||
.map((y, i) => ({ y, label: l.options[i] ?? "" }))
|
||||
.map((option, i) => (
|
||||
<g key={option.label}>
|
||||
<motion.path
|
||||
d={`M 116 150 C 190 150 180 ${option.y} 250 ${option.y}`}
|
||||
fill="none"
|
||||
stroke={INK}
|
||||
strokeWidth={FINE}
|
||||
{...draw(0.3 + i * 0.4)}
|
||||
/>
|
||||
<text
|
||||
x={340}
|
||||
y={option.y + 4}
|
||||
textAnchor="middle"
|
||||
style={{ ...MONO, fill: INK }}
|
||||
>
|
||||
{option.label}
|
||||
</text>
|
||||
</motion.g>
|
||||
</g>
|
||||
))}
|
||||
<Packet
|
||||
{...along([116, 150], [190, 150], [180, option.y], [250, option.y])}
|
||||
delay={1.6 + i * 1.3}
|
||||
duration={1.1}
|
||||
pause={2.8}
|
||||
/>
|
||||
<motion.g {...appear(0.7 + i * 0.4, 0)}>
|
||||
<rect
|
||||
x={250}
|
||||
y={option.y - 16}
|
||||
width={180}
|
||||
height={32}
|
||||
rx={16}
|
||||
fill={PAPER}
|
||||
stroke={LINE}
|
||||
/>
|
||||
<text
|
||||
x={340}
|
||||
y={option.y + 4}
|
||||
textAnchor="middle"
|
||||
style={{ ...MONO, fill: INK }}
|
||||
>
|
||||
{option.label}
|
||||
</text>
|
||||
</motion.g>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
|
||||
const Browser: React.FC<{
|
||||
children: React.ReactNode;
|
||||
url?: string;
|
||||
live?: boolean;
|
||||
}> = ({ children, url, live = false }) => (
|
||||
/** Shown next to a pulsing dot once the page is live. */
|
||||
onlineLabel?: string;
|
||||
}> = ({ children, url, onlineLabel }) => (
|
||||
<g>
|
||||
<rect
|
||||
x={70}
|
||||
@@ -617,7 +620,7 @@ const Browser: React.FC<{
|
||||
{url}
|
||||
</text>
|
||||
)}
|
||||
{live && (
|
||||
{onlineLabel !== undefined && (
|
||||
<motion.g {...appear(1.6, 0)}>
|
||||
<motion.circle
|
||||
cx={352}
|
||||
@@ -636,7 +639,7 @@ const Browser: React.FC<{
|
||||
fill: LIVE,
|
||||
}}
|
||||
>
|
||||
online
|
||||
{onlineLabel}
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
@@ -704,7 +707,7 @@ const PageDetail: React.FC<{ delay: number }> = ({ delay }) => (
|
||||
);
|
||||
|
||||
/** Website, step 2: a first design as outlines. */
|
||||
const Wireframe: React.FC = () => (
|
||||
const Wireframe: Scene = ({ l }) => (
|
||||
<g>
|
||||
<Browser>
|
||||
{BLOCKS.map((b, i) => (
|
||||
@@ -730,15 +733,15 @@ const Wireframe: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(1.5, 0)}
|
||||
>
|
||||
a first version to click through
|
||||
{l.firstVersion}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Website, step 3: built and online. */
|
||||
const Built: React.FC = () => (
|
||||
const Built: Scene = ({ l }) => (
|
||||
<g>
|
||||
<Browser url="yourcompany.com" live>
|
||||
<Browser url={l.exampleDomain} onlineLabel={l.online}>
|
||||
{BLOCKS.map((b, i) => (
|
||||
<motion.rect
|
||||
key={i}
|
||||
@@ -764,15 +767,15 @@ const Built: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(1.8, 0)}
|
||||
>
|
||||
you approve it before it goes live
|
||||
{l.approve}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Website, step 4: a change request by email. */
|
||||
const ChangeByEmail: React.FC = () => (
|
||||
const ChangeByEmail: Scene = ({ l }) => (
|
||||
<g>
|
||||
<Browser url="yourcompany.com" live>
|
||||
<Browser url={l.exampleDomain} onlineLabel={l.online}>
|
||||
{BLOCKS.map((b, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
@@ -831,16 +834,17 @@ const ChangeByEmail: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(1.7, 0)}
|
||||
>
|
||||
you send an email, I make the change
|
||||
{l.changeByEmail}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
const TOOLS: readonly { x: number; y: number; label: string }[] = [
|
||||
{ x: 90, y: 80, label: "Shop" },
|
||||
{ x: 390, y: 80, label: "Accounting" },
|
||||
{ x: 90, y: 200, label: "Email" },
|
||||
{ x: 390, y: 200, label: "CRM" },
|
||||
/** Where the four tools stand; their names are `tools` in the labels, in this order. */
|
||||
const TOOLS: readonly { x: number; y: number }[] = [
|
||||
{ x: 90, y: 80 },
|
||||
{ x: 390, y: 80 },
|
||||
{ x: 90, y: 200 },
|
||||
{ x: 390, y: 200 },
|
||||
];
|
||||
|
||||
const Tool: React.FC<{ x: number; y: number; label: string }> = ({
|
||||
@@ -866,14 +870,14 @@ const Tool: React.FC<{ x: number; y: number; label: string }> = ({
|
||||
);
|
||||
|
||||
/** Automation, step 2: today a person carries the data from tool to tool. */
|
||||
const ByHand: React.FC = () => (
|
||||
const ByHand: Scene = ({ l }) => (
|
||||
<g>
|
||||
{TOOLS.map((tool) => (
|
||||
<Tool key={tool.label} {...tool} />
|
||||
{TOOLS.map((tool, i) => (
|
||||
<Tool key={i} {...tool} label={l.tools[i] ?? ""} />
|
||||
))}
|
||||
{TOOLS.map((tool, i) => (
|
||||
<motion.path
|
||||
key={tool.label}
|
||||
key={i}
|
||||
d={`M ${tool.x + (tool.x < 240 ? 56 : -56)} ${tool.y} L ${tool.x < 240 ? 214 : 266} ${140 + (tool.y < 140 ? -12 : 12)}`}
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
@@ -905,7 +909,7 @@ const ByHand: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(1.5, 0)}
|
||||
>
|
||||
today: copied by hand
|
||||
{l.byHand}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
@@ -918,7 +922,7 @@ const WIRES: readonly string[] = [
|
||||
];
|
||||
|
||||
/** Automation, step 3: the systems are connected one step at a time. */
|
||||
const Wiring: React.FC = () => (
|
||||
const Wiring: Scene = ({ l }) => (
|
||||
<g>
|
||||
{WIRES.map((d, i) => (
|
||||
<motion.path
|
||||
@@ -930,8 +934,8 @@ const Wiring: React.FC = () => (
|
||||
{...draw(0.3 + i * 0.4)}
|
||||
/>
|
||||
))}
|
||||
{TOOLS.map((tool) => (
|
||||
<Tool key={tool.label} {...tool} />
|
||||
{TOOLS.map((tool, i) => (
|
||||
<Tool key={i} {...tool} label={l.tools[i] ?? ""} />
|
||||
))}
|
||||
<motion.text
|
||||
x={240}
|
||||
@@ -940,13 +944,13 @@ const Wiring: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(2, 0)}
|
||||
>
|
||||
connected one step at a time
|
||||
{l.wiring}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Automation, step 4: the work travels between the systems on its own. */
|
||||
const Running: React.FC = () => (
|
||||
const Running: Scene = ({ l }) => (
|
||||
<g>
|
||||
{WIRES.map((d) => (
|
||||
<path key={d} d={d} fill="none" stroke={INK} strokeWidth={1.5} />
|
||||
@@ -979,8 +983,8 @@ const Running: React.FC = () => (
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{TOOLS.map((tool) => (
|
||||
<Tool key={tool.label} {...tool} />
|
||||
{TOOLS.map((tool, i) => (
|
||||
<Tool key={i} {...tool} label={l.tools[i] ?? ""} />
|
||||
))}
|
||||
<motion.text
|
||||
x={240}
|
||||
@@ -989,12 +993,12 @@ const Running: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(0.6, 0)}
|
||||
>
|
||||
the steps run without anyone copying
|
||||
{l.running}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
const SCENES: Readonly<Record<string, readonly React.FC[]>> = {
|
||||
const SCENES: Readonly<Record<string, readonly Scene[]>> = {
|
||||
developer: [Talk, WeeklyHours, WorkList, Notice],
|
||||
rescue: [Talk, RepairWeek, Summary, Decide],
|
||||
automation: [Talk, ByHand, Wiring, Running],
|
||||
@@ -1006,6 +1010,7 @@ export const PathScene: React.FC<{
|
||||
step: number;
|
||||
title: string;
|
||||
}> = ({ pathId, step, title }) => {
|
||||
const labels = useContent().services.scenes;
|
||||
const Scene = SCENES[pathId]?.[step];
|
||||
if (!Scene) return null;
|
||||
|
||||
@@ -1047,7 +1052,7 @@ export const PathScene: React.FC<{
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<Scene />
|
||||
<Scene l={labels} />
|
||||
</motion.g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
import * as React from "react";
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
import { fill } from "@/src/domain/template";
|
||||
|
||||
/**
|
||||
* PipelineRun: what happens between a change and its release, shown as the test run it is.
|
||||
@@ -24,56 +26,37 @@ const SUITES: readonly Suite[] = [
|
||||
{ file: "emails/invoice-mail.spec.ts", tests: 5, ms: 290 },
|
||||
];
|
||||
|
||||
/** A change and the test it breaks elsewhere. The texts are in en.ts and de.ts, by id. */
|
||||
type Scenario = {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly branch: string;
|
||||
readonly failingFile: string;
|
||||
readonly testName: string;
|
||||
readonly expected: string;
|
||||
readonly received: string;
|
||||
};
|
||||
|
||||
const SCENARIOS: readonly Scenario[] = [
|
||||
{
|
||||
id: "orders",
|
||||
label: "Orders",
|
||||
branch: "change/order-discounts",
|
||||
failingFile: "invoices/total.spec.ts",
|
||||
testName: "invoice total matches the discounted order",
|
||||
expected: "119.00",
|
||||
received: "125.00",
|
||||
},
|
||||
{
|
||||
id: "login",
|
||||
label: "Login",
|
||||
branch: "change/login-with-username",
|
||||
failingFile: "emails/confirmation.spec.ts",
|
||||
testName: "confirmation goes to the account's email address",
|
||||
expected: '"anna@example.com"',
|
||||
received: "undefined",
|
||||
},
|
||||
{
|
||||
id: "invoices",
|
||||
label: "Invoices",
|
||||
branch: "change/invoice-layout",
|
||||
failingFile: "emails/invoice-mail.spec.ts",
|
||||
testName: "invoice email attaches the PDF",
|
||||
expected: "1 attachment",
|
||||
received: "0 attachments",
|
||||
},
|
||||
{
|
||||
id: "emails",
|
||||
label: "Emails",
|
||||
branch: "change/email-queue",
|
||||
failingFile: "orders/status.spec.ts",
|
||||
testName: "order is confirmed once the email is queued",
|
||||
expected: '"confirmed"',
|
||||
received: '"pending"',
|
||||
},
|
||||
];
|
||||
|
||||
const STAGES = ["Lint", "Types", "Tests", "Deploy"] as const;
|
||||
const STAGE_COUNT = 4;
|
||||
const TOTAL_TESTS = SUITES.reduce((sum, suite) => sum + suite.tests, 0);
|
||||
|
||||
/**
|
||||
@@ -129,6 +112,7 @@ const STAGE_MARK: Readonly<Record<StageState, string>> = {
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
|
||||
|
||||
export const PipelineRun: React.FC = () => {
|
||||
const copy = useContent().technology.pipeline;
|
||||
const [scenarioId, setScenarioId] = React.useState(SCENARIOS[0]?.id ?? "");
|
||||
const [frameIndex, setFrameIndex] = React.useState(0);
|
||||
const [run, setRun] = React.useState(0);
|
||||
@@ -170,7 +154,8 @@ export const PipelineRun: React.FC = () => {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [inView, scenarioId, run]);
|
||||
|
||||
if (!scenario) return null;
|
||||
const scenarioCopy = scenario ? copy.scenarios[scenario.id] : undefined;
|
||||
if (!scenario || !scenarioCopy) return null;
|
||||
|
||||
const frame = FRAMES[frameIndex] ?? FRAMES[0];
|
||||
if (!frame) return null;
|
||||
@@ -178,7 +163,7 @@ export const PipelineRun: React.FC = () => {
|
||||
const firstAttempt = frame.attempt === 1;
|
||||
const failingIndex = SUITES.findIndex((s) => s.file === scenario.failingFile);
|
||||
const failureSeen = firstAttempt && frame.suitesDone > failingIndex;
|
||||
const finished = frame.stagesDone === STAGES.length;
|
||||
const finished = frame.stagesDone === STAGE_COUNT;
|
||||
|
||||
const stageState = (index: number): StageState => {
|
||||
if (index === 2 && firstAttempt && failureSeen && frame.stagesDone >= 3) {
|
||||
@@ -201,20 +186,10 @@ export const PipelineRun: React.FC = () => {
|
||||
// Which of the three explanations applies right now.
|
||||
const explain = firstAttempt ? (finished ? 1 : 0) : finished ? 2 : 1;
|
||||
|
||||
const EXPLANATIONS = [
|
||||
{
|
||||
title: "A change comes in",
|
||||
text: `Something is changed in ${scenario.label}. Before it can go live, the whole project is checked automatically.`,
|
||||
},
|
||||
{
|
||||
title: "A test fails, the release is blocked",
|
||||
text: "The change broke something in a different place. It is caught here, on my side, and nothing reaches your users.",
|
||||
},
|
||||
{
|
||||
title: "Fixed, checked again, released",
|
||||
text: "I repair what broke, the run passes, and only then does the change go live.",
|
||||
},
|
||||
] as const;
|
||||
const explanations = copy.explanations.map((item) => ({
|
||||
title: item.title,
|
||||
text: fill(item.text, { area: scenarioCopy.label }),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -222,7 +197,7 @@ export const PipelineRun: React.FC = () => {
|
||||
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12"
|
||||
>
|
||||
<div className="md:col-span-4">
|
||||
<p className={`${MONO} m-0 text-slate-500`}>Simulate a change in</p>
|
||||
<p className={`${MONO} m-0 text-slate-500`}>{copy.simulate}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{SCENARIOS.map((item) => {
|
||||
const selected = item.id === scenario.id;
|
||||
@@ -241,14 +216,14 @@ export const PipelineRun: React.FC = () => {
|
||||
: "border-slate-200 bg-white text-slate-600 hover:border-slate-400 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
{copy.scenarios[item.id]?.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ol className="m-0 mt-10 list-none p-0" aria-live="polite">
|
||||
{EXPLANATIONS.map((item, index) => {
|
||||
{explanations.map((item, index) => {
|
||||
const active = index === explain;
|
||||
return (
|
||||
<li
|
||||
@@ -281,7 +256,7 @@ export const PipelineRun: React.FC = () => {
|
||||
onClick={() => setRun((n) => n + 1)}
|
||||
className="mt-6 rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] text-slate-600 transition-colors hover:border-slate-400 hover:text-slate-900"
|
||||
>
|
||||
Run again
|
||||
{copy.runAgain}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -302,16 +277,16 @@ export const PipelineRun: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-slate-500">
|
||||
run #{frame.attempt === 1 ? 481 : 482}
|
||||
{copy.run} #{frame.attempt === 1 ? 481 : 482}
|
||||
{frame.attempt === 2 && (
|
||||
<span className="text-slate-400"> · fix pushed</span>
|
||||
<span className="text-slate-400"> · {copy.fixPushed}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stages */}
|
||||
<div className="grid grid-cols-2 gap-2 border-b border-slate-800 px-5 py-4 sm:grid-cols-4 md:px-7">
|
||||
{STAGES.map((stage, index) => {
|
||||
{copy.stages.map((stage, index) => {
|
||||
const state = stageState(index);
|
||||
return (
|
||||
<div
|
||||
@@ -329,9 +304,7 @@ export const PipelineRun: React.FC = () => {
|
||||
<div className="min-h-[21rem] px-5 py-5 md:px-7 md:py-6">
|
||||
{frame.stagesDone < 2 && (
|
||||
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
|
||||
{frame.stagesDone === 0
|
||||
? "$ lint: checking the code style…"
|
||||
: "$ types: checking that the parts fit together…"}
|
||||
{frame.stagesDone === 0 ? copy.lint : copy.types}
|
||||
</p>
|
||||
)}
|
||||
{SUITES.slice(0, frame.suitesDone).map((suite, index) => {
|
||||
@@ -348,7 +321,7 @@ export const PipelineRun: React.FC = () => {
|
||||
{suite.file}
|
||||
</span>
|
||||
<span className="hidden text-slate-500 sm:inline">
|
||||
{suite.tests} tests
|
||||
{suite.tests} {copy.tests}
|
||||
</span>
|
||||
<span className="w-14 shrink-0 text-right text-slate-600">
|
||||
{suite.ms} ms
|
||||
@@ -357,13 +330,13 @@ export const PipelineRun: React.FC = () => {
|
||||
{failed && (
|
||||
<div className="my-2 ml-[3.25rem] border-l border-rose-500/50 pl-4">
|
||||
<p className="m-0 text-[12px] leading-relaxed text-slate-200 md:text-[13px]">
|
||||
● {scenario.testName}
|
||||
● {scenarioCopy.testName}
|
||||
</p>
|
||||
<p className="m-0 text-[12px] leading-relaxed text-emerald-400 md:text-[13px]">
|
||||
expected {scenario.expected}
|
||||
{copy.expected} {scenarioCopy.expected}
|
||||
</p>
|
||||
<p className="m-0 text-[12px] leading-relaxed text-rose-400 md:text-[13px]">
|
||||
received {scenario.received}
|
||||
{copy.received} {scenarioCopy.received}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -372,7 +345,9 @@ export const PipelineRun: React.FC = () => {
|
||||
})}
|
||||
{frame.stagesDone === 2 && frame.suitesDone < SUITES.length && (
|
||||
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
|
||||
running {SUITES[frame.suitesDone]?.file}…
|
||||
{fill(copy.runningFile, {
|
||||
file: SUITES[frame.suitesDone]?.file ?? "",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -380,14 +355,19 @@ export const PipelineRun: React.FC = () => {
|
||||
{/* Summary */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-800 px-5 py-4 md:px-7">
|
||||
<span>
|
||||
<span className="text-slate-500">Tests </span>
|
||||
<span className="text-slate-500">{copy.summaryLabel} </span>
|
||||
{failedCount > 0 && (
|
||||
<span className="text-rose-400">{failedCount} failed, </span>
|
||||
<span className="text-rose-400">
|
||||
{fill(copy.failed, { n: failedCount })},{" "}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-emerald-400">
|
||||
{reportedTests - failedCount} passed
|
||||
{fill(copy.passed, { n: reportedTests - failedCount })}
|
||||
</span>
|
||||
<span className="text-slate-500">
|
||||
{" "}
|
||||
{fill(copy.of, { total: TOTAL_TESTS })}
|
||||
</span>
|
||||
<span className="text-slate-500"> of {TOTAL_TESTS}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 font-bold uppercase tracking-[0.15em] ${
|
||||
@@ -399,18 +379,16 @@ export const PipelineRun: React.FC = () => {
|
||||
}`}
|
||||
>
|
||||
{!finished
|
||||
? "release pending"
|
||||
? copy.pending
|
||||
: firstAttempt
|
||||
? "release blocked"
|
||||
: "released"}
|
||||
? copy.blocked
|
||||
: copy.released}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</BitAura>
|
||||
<p className="m-0 mt-4 font-sans text-sm leading-relaxed text-slate-400">
|
||||
An illustration with made-up file names and numbers, modelled on a
|
||||
real test run. Tests do not catch every mistake, but they do catch
|
||||
this kind.
|
||||
{copy.footnote}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,25 +4,9 @@ import * as React from "react";
|
||||
import Image from "next/image";
|
||||
import { motion, useMotionValue, useSpring } from "framer-motion";
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
|
||||
export type WorkItem = {
|
||||
readonly name: string;
|
||||
readonly href: string;
|
||||
readonly client: string;
|
||||
readonly text: string;
|
||||
readonly did: readonly string[];
|
||||
readonly tech: readonly string[];
|
||||
readonly desktop: {
|
||||
readonly src: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
};
|
||||
readonly mobile: {
|
||||
readonly src: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
};
|
||||
};
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
import { WORK_SITES, type WorkSite } from "@/src/content/work";
|
||||
import { fill } from "@/src/domain/template";
|
||||
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
|
||||
|
||||
@@ -70,7 +54,8 @@ const ScrollingShot: React.FC<{
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
||||
const WorkStage: React.FC<{ item: WorkSite }> = ({ item }) => {
|
||||
const { work } = useContent();
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const [inView, setInView] = React.useState(false);
|
||||
const [still, setStill] = React.useState(false);
|
||||
@@ -147,7 +132,7 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
||||
<ScrollingShot
|
||||
shot={item.desktop}
|
||||
frameAspect={16 / 10}
|
||||
alt={`${item.name} on a desktop screen`}
|
||||
alt={fill(work.desktopAlt, { name: item.name })}
|
||||
sizes="(min-width: 1024px) 900px, 100vw"
|
||||
active={active}
|
||||
seconds={22}
|
||||
@@ -167,7 +152,7 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
||||
<ScrollingShot
|
||||
shot={item.mobile}
|
||||
frameAspect={9 / 17}
|
||||
alt={`${item.name} on a phone`}
|
||||
alt={fill(work.phoneAlt, { name: item.name })}
|
||||
sizes="240px"
|
||||
active={active}
|
||||
seconds={26}
|
||||
@@ -183,68 +168,75 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
||||
* WorkShowcase: for a dark section. Each live site on a lit stage, as a desktop and a phone view
|
||||
* that scroll through the real pages.
|
||||
*/
|
||||
export const WorkShowcase: React.FC<{ items: readonly WorkItem[] }> = ({
|
||||
items,
|
||||
}) => (
|
||||
<div className="flex flex-col gap-20 md:gap-32">
|
||||
{items.map((item, index) => (
|
||||
<article
|
||||
key={item.name}
|
||||
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:items-center md:gap-12"
|
||||
>
|
||||
<div className={`md:col-span-8 ${index % 2 === 1 ? "md:order-2" : ""}`}>
|
||||
<BitAura
|
||||
source={index % 2 === 0 ? "bottom" : "top"}
|
||||
tone="dark"
|
||||
motion={index % 2 === 0 ? "sparks" : "rain"}
|
||||
export const WorkShowcase: React.FC = () => {
|
||||
const { work } = useContent();
|
||||
return (
|
||||
<div className="flex flex-col gap-20 md:gap-32">
|
||||
{WORK_SITES.map((item, index) => {
|
||||
const copy = work.items[item.name];
|
||||
if (copy === undefined) return null;
|
||||
return (
|
||||
<article
|
||||
key={item.name}
|
||||
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:items-center md:gap-12"
|
||||
>
|
||||
<WorkStage item={item} />
|
||||
</BitAura>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-4">
|
||||
<p className={`${MONO} m-0 text-slate-400`}>
|
||||
0{index + 1} · {item.client}
|
||||
</p>
|
||||
<h3 className="m-0 mt-4 font-sans text-3xl font-bold tracking-tighter text-white md:text-5xl">
|
||||
{item.name}
|
||||
</h3>
|
||||
<p className="m-0 mt-5 font-sans text-lg leading-relaxed text-slate-300 md:text-xl">
|
||||
{item.text}
|
||||
</p>
|
||||
|
||||
<ul className="m-0 mt-8 list-none p-0">
|
||||
{item.did.map((line) => (
|
||||
<li
|
||||
key={line}
|
||||
className="border-t border-slate-800 py-3 font-sans text-base text-slate-100 md:text-lg"
|
||||
<div
|
||||
className={`md:col-span-8 ${index % 2 === 1 ? "md:order-2" : ""}`}
|
||||
>
|
||||
<BitAura
|
||||
source={index % 2 === 0 ? "bottom" : "top"}
|
||||
tone="dark"
|
||||
motion={index % 2 === 0 ? "sparks" : "rain"}
|
||||
>
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<WorkStage item={item} />
|
||||
</BitAura>
|
||||
</div>
|
||||
|
||||
<ul className="m-0 mt-6 flex list-none flex-wrap gap-2 p-0">
|
||||
{item.tech.map((tech) => (
|
||||
<li
|
||||
key={tech}
|
||||
className="rounded-full border border-slate-700 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-slate-300"
|
||||
<div className="md:col-span-4">
|
||||
<p className={`${MONO} m-0 text-slate-400`}>
|
||||
0{index + 1} · {copy.client}
|
||||
</p>
|
||||
<h3 className="m-0 mt-4 font-sans text-3xl font-bold tracking-tighter text-white md:text-5xl">
|
||||
{item.name}
|
||||
</h3>
|
||||
<p className="m-0 mt-5 font-sans text-lg leading-relaxed text-slate-300 md:text-xl">
|
||||
{copy.text}
|
||||
</p>
|
||||
|
||||
<ul className="m-0 mt-8 list-none p-0">
|
||||
{copy.did.map((line) => (
|
||||
<li
|
||||
key={line}
|
||||
className="border-t border-slate-800 py-3 font-sans text-base text-slate-100 md:text-lg"
|
||||
>
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<ul className="m-0 mt-6 flex list-none flex-wrap gap-2 p-0">
|
||||
{copy.tech.map((tech) => (
|
||||
<li
|
||||
key={tech}
|
||||
className="rounded-full border border-slate-700 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-slate-300"
|
||||
>
|
||||
{tech}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<a
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bit-cta bit-cta-light mt-8 inline-flex items-center gap-3 rounded-full bg-white px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
|
||||
>
|
||||
{tech}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<a
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bit-cta bit-cta-light mt-8 inline-flex items-center gap-3 rounded-full bg-white px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
|
||||
>
|
||||
Open the live site <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
{work.openLive} <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import type { Locale } from "@/src/domain/locale";
|
||||
import type { ScrollAnchor } from "@/src/domain/scroll-anchor";
|
||||
|
||||
/**
|
||||
* The state of a running language switch. It lives outside React on purpose:
|
||||
* changing the language swaps the whole page below the root layout, and the
|
||||
* curtain that hides the swap has to carry on across it.
|
||||
*
|
||||
* cover: the curtain closes over the old page. hold: the page is swapped behind
|
||||
* it. reveal: the curtain opens on the new page.
|
||||
*/
|
||||
export type SwitchState =
|
||||
| { readonly phase: "idle" }
|
||||
| {
|
||||
readonly phase: "cover" | "hold" | "reveal";
|
||||
readonly from: Locale;
|
||||
readonly target: Locale;
|
||||
readonly href: string;
|
||||
/** When the switch began and when the current phase began (performance.now). */
|
||||
readonly startedAt: number;
|
||||
readonly phaseAt: number;
|
||||
/** Where the visitor was reading, to find the same place in the new page. */
|
||||
readonly anchor: ScrollAnchor | null;
|
||||
};
|
||||
|
||||
const IDLE: SwitchState = { phase: "idle" };
|
||||
|
||||
let state: SwitchState = IDLE;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const set = (next: SwitchState) => {
|
||||
state = next;
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
export const subscribe = (listener: () => void): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
|
||||
export const getSwitchState = (): SwitchState => state;
|
||||
export const getIdleState = (): SwitchState => IDLE;
|
||||
|
||||
export const startSwitch = (from: Locale, target: Locale, href: string) => {
|
||||
if (state.phase !== "idle") return;
|
||||
const now = performance.now();
|
||||
set({
|
||||
phase: "cover",
|
||||
from,
|
||||
target,
|
||||
href,
|
||||
startedAt: now,
|
||||
phaseAt: now,
|
||||
anchor: null,
|
||||
});
|
||||
};
|
||||
|
||||
export const enterPhase = (
|
||||
phase: "hold" | "reveal",
|
||||
anchor?: ScrollAnchor | null,
|
||||
) => {
|
||||
if (state.phase === "idle") return;
|
||||
set({
|
||||
...state,
|
||||
phase,
|
||||
phaseAt: performance.now(),
|
||||
anchor: anchor === undefined ? state.anchor : anchor,
|
||||
});
|
||||
};
|
||||
|
||||
export const endSwitch = () => set(IDLE);
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { CareerStation } from "@/src/components/illustrations/CareerLine";
|
||||
|
||||
/** The career in four stations. Facts from the owner's CV, deliberately summarised. */
|
||||
export const CAREER: readonly CareerStation[] = [
|
||||
{ years: "2011", role: "Started in the web", place: "Apprenticeship" },
|
||||
{
|
||||
years: "2014 – 2020",
|
||||
role: "Agencies",
|
||||
place: "Frontend developer",
|
||||
},
|
||||
{
|
||||
years: "2020 – 2023",
|
||||
role: "Product companies",
|
||||
place: "Senior software developer",
|
||||
},
|
||||
{
|
||||
years: "Today",
|
||||
role: "Independent",
|
||||
place: "Working directly with clients",
|
||||
},
|
||||
];
|
||||
|
||||
/** Which projects I am a good fit for, and which not. */
|
||||
export const FIT = [
|
||||
{
|
||||
title: "A good fit",
|
||||
items: [
|
||||
"Web apps: dashboards, portals, internal tools",
|
||||
"Interfaces where design and usability matter",
|
||||
"Company websites, also in several languages",
|
||||
"Web3 frontends: wallets, live data, dashboards",
|
||||
"Automation: connecting systems and their APIs",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Also possible",
|
||||
items: [
|
||||
"The backend an app needs: APIs, login, integrations",
|
||||
"Command-line tools",
|
||||
"Taking over and tidying up existing code",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Not my field",
|
||||
items: [
|
||||
"Mobile apps for the app stores",
|
||||
"Windows software",
|
||||
"Running servers, specialised database work",
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** Technologies I work with. Only what I have actually used in projects. */
|
||||
export const TECH = [
|
||||
{
|
||||
area: "Interface",
|
||||
items: ["TypeScript", "React", "Next.js", "Vue.js", "Tailwind CSS"],
|
||||
},
|
||||
{
|
||||
area: "Behind it",
|
||||
items: ["Node.js", "REST APIs", "PostgreSQL", "Headless CMS"],
|
||||
},
|
||||
{
|
||||
area: "Web3 and systems",
|
||||
items: ["Solidity", "Rust"],
|
||||
},
|
||||
{
|
||||
area: "Delivery",
|
||||
items: ["Automated tests", "Docker", "CI/CD"],
|
||||
},
|
||||
] as const;
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { FlowNodeCopy, NodeKind, WorkflowCopy } from "./types";
|
||||
|
||||
/**
|
||||
* Example workflows for the automation section. They are illustrations of typical
|
||||
* cases, not descriptions of specific client projects.
|
||||
* cases, not descriptions of specific client projects. This file holds what the
|
||||
* drawing needs (nodes, positions, runs); the texts are in en.ts and de.ts.
|
||||
*/
|
||||
|
||||
export type NodeKind = "trigger" | "rule" | "ai" | "action" | "human";
|
||||
|
||||
export type FlowNode = {
|
||||
type NodeLayout = {
|
||||
readonly id: string;
|
||||
readonly kind: NodeKind;
|
||||
/** Position of the node centre on the wide 920 x 400 canvas. */
|
||||
@@ -14,38 +15,28 @@ export type FlowNode = {
|
||||
/** Position of the node centre on the tall 400 x 540 canvas for phones. */
|
||||
readonly nx: number;
|
||||
readonly ny: number;
|
||||
readonly system: string;
|
||||
readonly label: string;
|
||||
/** What appears in the log when a run reaches this node. */
|
||||
readonly log: string;
|
||||
};
|
||||
|
||||
export type FlowNode = NodeLayout & FlowNodeCopy;
|
||||
|
||||
/** One step of a run: the connections travelled at the same time. */
|
||||
export type FlowStep = readonly (readonly [from: string, to: string])[];
|
||||
|
||||
export type Workflow = {
|
||||
type WorkflowLayout = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly summary: string;
|
||||
readonly byHand: string;
|
||||
readonly automated: string;
|
||||
readonly nodes: readonly FlowNode[];
|
||||
readonly nodes: readonly NodeLayout[];
|
||||
/** Every connection that exists, for drawing. */
|
||||
readonly edges: readonly (readonly [from: string, to: string])[];
|
||||
/** The runs that play in turn. Each run is a list of steps. */
|
||||
readonly runs: readonly (readonly FlowStep[])[];
|
||||
};
|
||||
|
||||
export const WORKFLOWS: readonly Workflow[] = [
|
||||
export type Workflow = Omit<WorkflowLayout, "nodes"> &
|
||||
Omit<WorkflowCopy, "nodes"> & { readonly nodes: readonly FlowNode[] };
|
||||
|
||||
const LAYOUTS: readonly WorkflowLayout[] = [
|
||||
{
|
||||
id: "order",
|
||||
name: "Order to invoice",
|
||||
summary:
|
||||
"A new order arrives in the shop. The invoice, the customer record and the confirmation follow without anyone typing.",
|
||||
byHand:
|
||||
"Someone opens the order, writes the invoice, updates the customer record and sends an email.",
|
||||
automated:
|
||||
"The order triggers everything. People only see the finished result.",
|
||||
nodes: [
|
||||
{
|
||||
id: "shop",
|
||||
@@ -54,9 +45,6 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
y: 200,
|
||||
nx: 200,
|
||||
ny: 60,
|
||||
system: "Shop",
|
||||
label: "New order",
|
||||
log: "order received",
|
||||
},
|
||||
{
|
||||
id: "stock",
|
||||
@@ -65,9 +53,6 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
y: 200,
|
||||
nx: 200,
|
||||
ny: 190,
|
||||
system: "Rule",
|
||||
label: "In stock?",
|
||||
log: "stock checked: available",
|
||||
},
|
||||
{
|
||||
id: "invoice",
|
||||
@@ -76,9 +61,6 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
y: 110,
|
||||
nx: 105,
|
||||
ny: 330,
|
||||
system: "Accounting",
|
||||
label: "Create invoice",
|
||||
log: "invoice created",
|
||||
},
|
||||
{
|
||||
id: "crm",
|
||||
@@ -87,9 +69,6 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
y: 290,
|
||||
nx: 295,
|
||||
ny: 330,
|
||||
system: "CRM",
|
||||
label: "Update customer",
|
||||
log: "customer record updated",
|
||||
},
|
||||
{
|
||||
id: "mail",
|
||||
@@ -98,9 +77,6 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
y: 200,
|
||||
nx: 200,
|
||||
ny: 470,
|
||||
system: "Email",
|
||||
label: "Send confirmation",
|
||||
log: "confirmation sent",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
@@ -126,13 +102,6 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
},
|
||||
{
|
||||
id: "documents",
|
||||
name: "Incoming documents",
|
||||
summary:
|
||||
"Invoices arrive as PDF by email. An AI step reads them, a rule checks the result, and a person only sees the unclear ones.",
|
||||
byHand:
|
||||
"Someone opens every PDF, reads supplier, amount and date, and types them into the accounting system.",
|
||||
automated:
|
||||
"Clear cases are booked directly. Unclear ones go to a person with the document attached.",
|
||||
nodes: [
|
||||
{
|
||||
id: "inbox",
|
||||
@@ -141,9 +110,6 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
y: 200,
|
||||
nx: 200,
|
||||
ny: 60,
|
||||
system: "Inbox",
|
||||
label: "Email with PDF",
|
||||
log: "document received",
|
||||
},
|
||||
{
|
||||
id: "read",
|
||||
@@ -152,9 +118,6 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
y: 200,
|
||||
nx: 200,
|
||||
ny: 190,
|
||||
system: "AI step",
|
||||
label: "Read the document",
|
||||
log: "supplier, amount and date extracted",
|
||||
},
|
||||
{
|
||||
id: "check",
|
||||
@@ -163,9 +126,6 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
y: 200,
|
||||
nx: 200,
|
||||
ny: 320,
|
||||
system: "Rule",
|
||||
label: "Plausible?",
|
||||
log: "values checked",
|
||||
},
|
||||
{
|
||||
id: "book",
|
||||
@@ -174,9 +134,6 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
y: 110,
|
||||
nx: 105,
|
||||
ny: 460,
|
||||
system: "Accounting",
|
||||
label: "Book it",
|
||||
log: "booked automatically",
|
||||
},
|
||||
{
|
||||
id: "ask",
|
||||
@@ -185,9 +142,6 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
y: 290,
|
||||
nx: 295,
|
||||
ny: 460,
|
||||
system: "A person",
|
||||
label: "Review and decide",
|
||||
log: "unclear: sent to a person for review",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
@@ -204,12 +158,6 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
},
|
||||
{
|
||||
id: "lead",
|
||||
name: "Enquiry to follow-up",
|
||||
summary:
|
||||
"Someone fills in the contact form. The contact is created, the right person is told, and a reminder makes sure nobody forgets.",
|
||||
byHand:
|
||||
"Someone copies the enquiry into the CRM, forwards it by email and hopes the follow-up is not forgotten.",
|
||||
automated: "The enquiry is filed, assigned and followed up on schedule.",
|
||||
nodes: [
|
||||
{
|
||||
id: "form",
|
||||
@@ -218,9 +166,6 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
y: 200,
|
||||
nx: 200,
|
||||
ny: 60,
|
||||
system: "Website",
|
||||
label: "Contact form",
|
||||
log: "enquiry received",
|
||||
},
|
||||
{
|
||||
id: "contact",
|
||||
@@ -229,9 +174,6 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
y: 200,
|
||||
nx: 200,
|
||||
ny: 190,
|
||||
system: "CRM",
|
||||
label: "Create contact",
|
||||
log: "contact created",
|
||||
},
|
||||
{
|
||||
id: "notify",
|
||||
@@ -240,9 +182,6 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
y: 110,
|
||||
nx: 105,
|
||||
ny: 330,
|
||||
system: "Team chat",
|
||||
label: "Notify sales",
|
||||
log: "sales notified",
|
||||
},
|
||||
{
|
||||
id: "wait",
|
||||
@@ -251,9 +190,6 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
y: 290,
|
||||
nx: 295,
|
||||
ny: 330,
|
||||
system: "Rule",
|
||||
label: "No reply in 3 days?",
|
||||
log: "follow-up scheduled",
|
||||
},
|
||||
{
|
||||
id: "remind",
|
||||
@@ -262,9 +198,6 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
y: 290,
|
||||
nx: 295,
|
||||
ny: 470,
|
||||
system: "Tasks",
|
||||
label: "Create reminder",
|
||||
log: "reminder created",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
@@ -286,37 +219,26 @@ export const WORKFLOWS: readonly Workflow[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const KIND_LABEL: Readonly<Record<NodeKind, string>> = {
|
||||
trigger: "Trigger: something happens",
|
||||
rule: "Rule: a fixed check",
|
||||
ai: "AI step: reads or sorts",
|
||||
action: "Action: a system does something",
|
||||
human: "Person: decides the unclear cases",
|
||||
};
|
||||
const NO_TEXT: FlowNodeCopy = { system: "", label: "", log: "" };
|
||||
|
||||
export const AUTOMATION_NOTES = [
|
||||
{
|
||||
title: "Worth automating when",
|
||||
items: [
|
||||
"The same steps repeat every day or every week",
|
||||
"Data is typed from one tool into another",
|
||||
"Mistakes happen because the work is monotonous",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "What stays with people",
|
||||
items: [
|
||||
"Decisions and exceptions",
|
||||
"Anything a customer should hear from a person",
|
||||
"The final say when the automation is unsure",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "What I need from you",
|
||||
items: [
|
||||
"Access to the tools and their interfaces (APIs)",
|
||||
"Someone who knows the process as it is done today",
|
||||
"Real examples to test with",
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
/** The example workflows with their texts in one language. */
|
||||
export const workflowsFor = (
|
||||
copy: Readonly<Record<string, WorkflowCopy>>,
|
||||
): readonly Workflow[] =>
|
||||
LAYOUTS.flatMap((layout) => {
|
||||
const text = copy[layout.id];
|
||||
if (text === undefined) return [];
|
||||
return [
|
||||
{
|
||||
...layout,
|
||||
name: text.name,
|
||||
summary: text.summary,
|
||||
byHand: text.byHand,
|
||||
automated: text.automated,
|
||||
nodes: layout.nodes.map((node) => ({
|
||||
...node,
|
||||
...(text.nodes[node.id] ?? NO_TEXT),
|
||||
})),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { MailDraft } from "@/src/domain/email";
|
||||
|
||||
/**
|
||||
* Message starters for the contact section. The visitor picks what they need and
|
||||
* gets a prepared email to finish in their own mail app. The square brackets are
|
||||
* blanks for the visitor to fill in.
|
||||
*/
|
||||
export type ContactTopic = {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly draft: MailDraft;
|
||||
};
|
||||
|
||||
const SIGN_OFF = "Best regards";
|
||||
|
||||
export const CONTACT_TOPICS: readonly ContactTopic[] = [
|
||||
{
|
||||
id: "developer",
|
||||
label: "A developer",
|
||||
draft: {
|
||||
subject: "A developer for our product",
|
||||
body: `Hi Marc,\n\nwe are working on [what you are building] and need help with [what is next on your list].\n\nA first call would suit us around [date or week].\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rescue",
|
||||
label: "A reliable app",
|
||||
draft: {
|
||||
subject: "Our app needs to become reliable",
|
||||
body: `Hi Marc,\n\nour app [name or short description] works, but [what keeps going wrong].\n\nCould you take a look?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "automation",
|
||||
label: "Automation",
|
||||
draft: {
|
||||
subject: "We want to automate a process",
|
||||
body: `Hi Marc,\n\nwe would like to automate [the process]. Today it is done by hand in [the tools involved].\n\nCan we talk about it?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "website",
|
||||
label: "A website",
|
||||
draft: {
|
||||
subject: "A website for my business",
|
||||
body: `Hi Marc,\n\nI need a website for [your business]. It should [what it is for].\n\nWhen could we talk?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
label: "Something else",
|
||||
draft: {
|
||||
subject: "A question",
|
||||
body: `Hi Marc,\n\n[what you have in mind]\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,731 @@
|
||||
import type { Content } from "./types";
|
||||
|
||||
const SIGN_OFF = "Viele Grüße";
|
||||
const GREETING = "Hallo Herr Mintel,";
|
||||
const FIRST_CALL = "Ein erstes Gespräch, etwa 20 Minuten";
|
||||
const GET_IN_TOUCH = "Kontakt aufnehmen";
|
||||
|
||||
/** The German texts of the site, in the formal "Sie". en.ts holds the same in English. */
|
||||
export const de: Content = {
|
||||
site: {
|
||||
title: "Freelance-Webentwickler für Web-Apps & Websites | Marc Mintel",
|
||||
description:
|
||||
"Freelance-Webentwickler mit 15 Jahren Erfahrung. Ich baue Web-Apps, Oberflächen und Websites mit React, Next.js und TypeScript und automatisiere Abläufe.",
|
||||
jobTitle: "Freelance-Webentwickler",
|
||||
ogLocale: "de_DE",
|
||||
shareImageAlt:
|
||||
"Web-Apps und Websites. Marc Mintel, Freelance-Webentwickler mit 15 Jahren Erfahrung.",
|
||||
knowsAbout: [
|
||||
"Webanwendungen",
|
||||
"Benutzeroberflächen",
|
||||
"Websites",
|
||||
"Prozessautomatisierung",
|
||||
"Web3-Frontends",
|
||||
],
|
||||
catalogName: "Leistungen",
|
||||
},
|
||||
nav: {
|
||||
contact: "Kontakt",
|
||||
contactLong: GET_IN_TOUCH,
|
||||
logoLabel: "Marc Mintel, nach oben",
|
||||
imprint: "Impressum",
|
||||
privacy: "Datenschutz",
|
||||
backToTop: "Nach oben",
|
||||
language: "Sprache",
|
||||
languageNames: { en: "English", de: "Deutsch" },
|
||||
},
|
||||
hero: {
|
||||
headline: [
|
||||
{ text: "WEB-APPS", tone: "solid" },
|
||||
{ text: "& WEBSITES.", tone: "bits" },
|
||||
],
|
||||
lead: "Freelance-Webentwickler mit 15 Jahren Erfahrung.",
|
||||
leadStrong: "Ich baue Web-Apps, Oberflächen und Websites.",
|
||||
primaryCta: GET_IN_TOUCH,
|
||||
secondaryCta: "Was ich mache",
|
||||
trust: ["15 Jahre im Web", "Remote, EU-Kernzeiten"],
|
||||
builtAndRunning: "Gebaut und in Betrieb:",
|
||||
},
|
||||
hello: {
|
||||
label: "Hallo",
|
||||
title: "Ich bin Marc.",
|
||||
lead: "Ich baue seit 15 Jahren für das Web: zuerst in Agenturen, später als Senior-Entwickler und heute selbstständig.",
|
||||
paragraphs: [
|
||||
"Meine Stärke ist der Teil, den Menschen sehen und benutzen: Design und Oberfläche. Ich kenne mich auch mit dem aus, was dahinter liegt, und kann deshalb eine vollständige Anwendung bauen und nicht nur ihre Oberfläche.",
|
||||
"Sie arbeiten direkt mit mir. Egal, ob Sie ein Produktteam leiten oder so etwas zum ersten Mal in Auftrag geben.",
|
||||
],
|
||||
portraitAlt: "Marc Mintel, Freelance-Webentwickler",
|
||||
careerLabel: "Der Weg bisher",
|
||||
career: [
|
||||
{ years: "2011", role: "Einstieg ins Web", place: "Ausbildung" },
|
||||
{ years: "2014 – 2020", role: "Agenturen", place: "Frontend-Entwickler" },
|
||||
{
|
||||
years: "2020 – 2023",
|
||||
role: "Produktunternehmen",
|
||||
place: "Senior-Softwareentwickler",
|
||||
},
|
||||
{
|
||||
years: "Heute",
|
||||
role: "Selbstständig",
|
||||
place: "Direkte Zusammenarbeit mit Kunden",
|
||||
},
|
||||
],
|
||||
},
|
||||
services: {
|
||||
label: "Leistungen",
|
||||
title: { lead: "Was brauchen Sie?", rest: "Vier typische Fälle." },
|
||||
tablistLabel: "Was brauchen Sie?",
|
||||
youGet: "Das bekommen Sie",
|
||||
howItRuns: "So läuft es ab",
|
||||
whatItCosts: "Das kostet es",
|
||||
paths: [
|
||||
{
|
||||
id: "developer",
|
||||
service: "Entwicklung von Web-Apps und Frontends",
|
||||
need: "Wir brauchen einen Entwickler für unser Produkt",
|
||||
forWhom: "Start-ups, Produktteams, Agenturen, Web3-Teams",
|
||||
headline: "Ein erfahrener Entwickler, der in Ihr Projekt einsteigt.",
|
||||
intro:
|
||||
"Sie haben ein Produkt und mehr Arbeit, als Ihr Team schafft. Ich arbeite an vereinbarten Wochentagen an dem, was als Nächstes auf Ihrer Liste steht: neue Funktionen, die Oberfläche oder die Teile, zu denen niemand kommt.",
|
||||
youGet: [
|
||||
"Web-Apps, Dashboards, Kundenportale und interne Werkzeuge",
|
||||
"Oberflächen mit Blick für Design und Bedienbarkeit",
|
||||
"Web3-Frontends: Wallet-Anbindung, Live-Daten, Dashboards",
|
||||
"Kleine Werkzeuge und Automatisierung für wiederkehrende Arbeit",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: FIRST_CALL,
|
||||
text: "Sie erzählen mir, was Sie bauen und wo Sie Unterstützung brauchen. Wenn ich dafür nicht der Richtige bin, sage ich das.",
|
||||
},
|
||||
{
|
||||
title: "Wir vereinbaren die Tage",
|
||||
text: "Wie viele Tage pro Woche ich für Sie arbeite und was zuerst drankommt.",
|
||||
},
|
||||
{
|
||||
title: "Ich arbeite die Liste ab",
|
||||
text: "Fertige Teile gehen zur Durchsicht an Sie, sobald sie fertig sind. Ich erfasse meine Stunden, und Sie sehen, wofür sie angefallen sind.",
|
||||
},
|
||||
{
|
||||
title: "Zwei Wochen Kündigungsfrist",
|
||||
text: "Die Vereinbarung kann mit zwei Wochen Frist zum Monatsende beendet werden.",
|
||||
},
|
||||
],
|
||||
pricingModel: "Nach Aufwand, solange Ihr Projekt läuft",
|
||||
pricingText:
|
||||
"Ich arbeite an vereinbarten Wochentagen an Ihrem Projekt und rechne die Stunden ab, die ich tatsächlich gearbeitet habe. Es gibt kein Paket zu kaufen und keine Mindestlaufzeit.",
|
||||
prices: [
|
||||
{ label: "Pro Stunde", amount: "100 EUR", note: "netto" },
|
||||
{ label: "Pro Tag", amount: "800 EUR", note: "8 Stunden, netto" },
|
||||
],
|
||||
smallPrint:
|
||||
"Eine Rechnung pro Monat, mit den erfassten Stunden. Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.",
|
||||
cta: GET_IN_TOUCH,
|
||||
},
|
||||
{
|
||||
id: "rescue",
|
||||
service: "Code-Review und Stabilisierung von Web-Apps",
|
||||
need: "Unsere App soll zuverlässig werden",
|
||||
forWhom:
|
||||
"Teams, deren App schnell gebaut wurde und sich jetzt schwer ändern lässt",
|
||||
headline:
|
||||
"Eine Woche, um Ihre App durchzusehen und das Dringendste zu beheben.",
|
||||
intro:
|
||||
"Die App läuft, aber Fehler kommen wieder, und kleine Änderungen dauern länger, als sie sollten. Ich nehme mir eine Woche, gehe den Code durch, behebe, was in dieser Zeit möglich ist, und schreibe auf, was ich als Nächstes tun würde.",
|
||||
youGet: [
|
||||
"Eine ehrliche Einschätzung: was solide ist und was wackelt",
|
||||
"Automatisierte Tests für die wichtigsten Teile",
|
||||
"Die dringendsten Probleme behoben, soweit eine Woche reicht",
|
||||
"Eine schriftliche Liste der nächsten Schritte, in verständlicher Sprache",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: FIRST_CALL,
|
||||
text: "Sie zeigen mir die App und erzählen, wo sie Ärger macht.",
|
||||
},
|
||||
{
|
||||
title: "Eine Woche Arbeit",
|
||||
text: "Ich lese den Code, ergänze Tests, wo sie am meisten bringen, und behebe die dringendsten Probleme.",
|
||||
},
|
||||
{
|
||||
title: "Eine schriftliche Zusammenfassung",
|
||||
text: "Was ich gefunden habe, was ich geändert habe und was ich empfehle. So geschrieben, dass Sie es nachvollziehen können, ohne selbst zu entwickeln.",
|
||||
},
|
||||
{
|
||||
title: "Sie entscheiden, wie es weitergeht",
|
||||
text: "Mit mir, mit Ihrem eigenen Team oder gar nicht.",
|
||||
},
|
||||
],
|
||||
pricingModel: "Eine feste Woche zum festen Preis",
|
||||
pricingText:
|
||||
"Der Umfang ist eine Woche meiner Zeit. Sie kennen die Kosten, bevor ich anfange.",
|
||||
prices: [
|
||||
{
|
||||
label: "Eine Woche, 35 Stunden",
|
||||
amount: "3.500 EUR",
|
||||
note: "netto",
|
||||
},
|
||||
],
|
||||
smallPrint:
|
||||
"Eine Woche behebt nicht alles. Wenn ich im ersten Gespräch sehe, dass sie nicht reicht, um Ihnen zu nützen, sage ich Ihnen das.",
|
||||
cta: GET_IN_TOUCH,
|
||||
},
|
||||
{
|
||||
id: "automation",
|
||||
service: "Prozessautomatisierung und Systemintegration",
|
||||
need: "Wir wollen einen Ablauf automatisieren",
|
||||
forWhom:
|
||||
"Unternehmen, in denen Menschen Daten zwischen Programmen kopieren oder dieselben Schritte wiederholen",
|
||||
headline:
|
||||
"Systeme, die Arbeit aneinander weitergeben, damit Menschen es nicht tun müssen.",
|
||||
intro:
|
||||
"Jemand exportiert hier eine Liste, tippt sie dort ein und schreibt eine E-Mail dazu. Ich verbinde die Programme, die Sie schon nutzen, sodass wiederkehrende Schritte von selbst laufen und Ihre Leute die Arbeit machen können, für die man sie braucht.",
|
||||
youGet: [
|
||||
"Verbindungen zwischen Ihren Systemen über deren Schnittstellen (APIs)",
|
||||
"Wiederkehrende Abläufe, die ohne Handarbeit laufen",
|
||||
"Wo es hilft, Schritte mit KI: sortieren, zusammenfassen, Dokumente lesen",
|
||||
"Eine kurze Beschreibung, wie es funktioniert, damit es gewartet werden kann",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: FIRST_CALL,
|
||||
text: "Sie beschreiben den Ablauf und welche Programme beteiligt sind.",
|
||||
},
|
||||
{
|
||||
title: "Ich sehe mir an, wie es heute läuft",
|
||||
text: "Schritt für Schritt, mit den Menschen, die es machen. Oft lässt sich ein Teil automatisieren, nicht alles.",
|
||||
},
|
||||
{
|
||||
title: "Ich verbinde die Systeme",
|
||||
text: "Einen Schritt nach dem anderen. Sie testen es mit echten Fällen, bevor es die Handarbeit ersetzt.",
|
||||
},
|
||||
{
|
||||
title: "Es läuft von selbst",
|
||||
text: "Sie bekommen eine Notiz dazu, was es tut und was zu prüfen ist, wenn sich eines der Programme ändert.",
|
||||
},
|
||||
],
|
||||
pricingModel: "Erst eine Schätzung, dann ein Budget",
|
||||
pricingText:
|
||||
"Sobald ich den Ablauf gesehen habe, schätze ich die Stunden. Diese Schätzung wird zum Budget. Ich rechne die Stunden ab, die ich tatsächlich gebraucht habe, und sage früh Bescheid, wenn es nicht reicht.",
|
||||
prices: [{ label: "Pro Stunde", amount: "100 EUR", note: "netto" }],
|
||||
smallPrint:
|
||||
"Wie lange es dauert, hängt vom Ablauf ab. Deshalb gibt es keinen Preis, bevor ich ihn gesehen habe. Gebühren für die Programme selbst zahlen Sie.",
|
||||
cta: GET_IN_TOUCH,
|
||||
},
|
||||
{
|
||||
id: "website",
|
||||
service: "Gestaltung und Entwicklung von Websites",
|
||||
need: "Ich brauche einfach eine Website",
|
||||
forWhom:
|
||||
"Unternehmen und Selbstständige, die sich nicht selbst darum kümmern wollen",
|
||||
headline: "Eine Website, für Sie gebaut und betreut.",
|
||||
intro:
|
||||
"Sie wollen keinen Website-Baukasten lernen und sich nicht mit Hosting beschäftigen. Sie sagen mir, was Ihr Unternehmen macht und was die Seite erreichen soll. Ich gestalte sie, baue sie und bringe sie online.",
|
||||
youGet: [
|
||||
"Ein Design für Ihr Unternehmen, keine Vorlage",
|
||||
"Funktioniert auf Handy, Tablet und Desktop",
|
||||
"Um das Hosting kümmere ich mich, Sie betreuen keinen Server",
|
||||
"Änderungen per E-Mail: Sie schreiben, was Sie brauchen, ich setze es um",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: FIRST_CALL,
|
||||
text: "Sie erzählen mir von Ihrem Unternehmen und wofür die Website da ist.",
|
||||
},
|
||||
{
|
||||
title: "Ein Entwurf zum Ansehen",
|
||||
text: "Sie bekommen eine erste Version zum Durchklicken. Wir passen sie an, bis sie passt.",
|
||||
},
|
||||
{
|
||||
title: "Ich baue sie und bringe sie online",
|
||||
text: "Mit Ihren Texten und Bildern und einem Kontaktformular. Sie geben sie frei, bevor sie live geht.",
|
||||
},
|
||||
{
|
||||
title: "Änderungen danach",
|
||||
text: "Ein neues Foto oder ein geänderter Preis: Sie schreiben mir eine E-Mail.",
|
||||
},
|
||||
],
|
||||
pricingModel: "Erst eine Schätzung, dann ein Budget",
|
||||
pricingText:
|
||||
"Nach dem ersten Gespräch bekommen Sie eine Schätzung. Sie wird Ihr Budget: Ich rechne die Stunden ab, die tatsächlich angefallen sind. Die Schätzung hält in der Regel, und wenn nicht, sage ich es Ihnen, bevor das Budget aufgebraucht ist.",
|
||||
prices: [
|
||||
{
|
||||
label: "Eine typische Unternehmenswebsite",
|
||||
amount: "ab 3.800 EUR",
|
||||
note: "einmalig, netto",
|
||||
},
|
||||
{
|
||||
label: "Alles Weitere, pro Stunde",
|
||||
amount: "100 EUR",
|
||||
note: "netto",
|
||||
},
|
||||
],
|
||||
smallPrint:
|
||||
"Hosting und kleine Änderungen danach kosten einen monatlichen Betrag, den wir vor dem Start vereinbaren. E-Mail-Postfächer verwalte ich nicht; sie bleiben bei Ihrem Anbieter.",
|
||||
cta: GET_IN_TOUCH,
|
||||
},
|
||||
],
|
||||
scenes: {
|
||||
you: "Sie",
|
||||
me: "Ich",
|
||||
talkLength: "etwa 20 Minuten",
|
||||
days: ["Mo", "Di", "Mi", "Do", "Fr"],
|
||||
daysNote: "zum Beispiel drei Tage pro Woche",
|
||||
review: "fertige Teile gehen an Sie zur Durchsicht",
|
||||
week: "Woche {n}",
|
||||
monthEnd: "Monatsende",
|
||||
notice: "zwei Wochen Frist",
|
||||
repair: "Wackliges wird getestet und behoben",
|
||||
summaryParts: ["gefunden", "geändert", "als Nächstes"],
|
||||
summaryNote: "so geschrieben, dass Sie folgen können",
|
||||
options: ["weiter mit mir", "Ihr eigenes Team", "hier aufhören"],
|
||||
online: "online",
|
||||
exampleDomain: "ihrefirma.de",
|
||||
firstVersion: "eine erste Version zum Durchklicken",
|
||||
approve: "Sie geben frei, bevor es live geht",
|
||||
changeByEmail: "Sie schreiben, ich setze es um",
|
||||
tools: ["Shop", "Buchhaltung", "E-Mail", "CRM"],
|
||||
byHand: "heute: von Hand kopiert",
|
||||
wiring: "Schritt für Schritt verbunden",
|
||||
running: "die Schritte laufen ohne Handarbeit",
|
||||
},
|
||||
},
|
||||
automation: {
|
||||
label: "Prozessautomatisierung",
|
||||
title: { lead: "Lassen Sie die Systeme", rest: "das Kopieren übernehmen." },
|
||||
intro:
|
||||
"In den meisten Unternehmen tragen Menschen Informationen von einem Programm ins andere: hier lesen, dort eintippen, eine E-Mail dazu schreiben. Automatisierung verbindet diese Programme, sodass die Arbeit von selbst vom einen zum nächsten wandert. Drei typische Beispiele:",
|
||||
examplesLabel: "Beispielabläufe",
|
||||
running: "läuft",
|
||||
runComplete: "Durchlauf fertig",
|
||||
log: "Protokoll",
|
||||
byHand: "Von Hand",
|
||||
automated: "Automatisiert",
|
||||
kinds: {
|
||||
trigger: "Auslöser: etwas passiert",
|
||||
rule: "Regel: eine feste Prüfung",
|
||||
ai: "KI-Schritt: liest oder sortiert",
|
||||
action: "Aktion: ein System tut etwas",
|
||||
human: "Mensch: entscheidet die unklaren Fälle",
|
||||
},
|
||||
workflows: {
|
||||
order: {
|
||||
name: "Bestellung bis Rechnung",
|
||||
summary:
|
||||
"Im Shop geht eine neue Bestellung ein. Rechnung, Kundendatensatz und Bestätigung folgen, ohne dass jemand tippt.",
|
||||
byHand:
|
||||
"Jemand öffnet die Bestellung, schreibt die Rechnung, aktualisiert den Kundendatensatz und verschickt eine E-Mail.",
|
||||
automated:
|
||||
"Die Bestellung stößt alles an. Menschen sehen nur das fertige Ergebnis.",
|
||||
nodes: {
|
||||
shop: {
|
||||
system: "Shop",
|
||||
label: "Neue Bestellung",
|
||||
log: "Bestellung eingegangen",
|
||||
},
|
||||
stock: {
|
||||
system: "Regel",
|
||||
label: "Auf Lager?",
|
||||
log: "Bestand geprüft: verfügbar",
|
||||
},
|
||||
invoice: {
|
||||
system: "Buchhaltung",
|
||||
label: "Rechnung erstellen",
|
||||
log: "Rechnung erstellt",
|
||||
},
|
||||
crm: {
|
||||
system: "CRM",
|
||||
label: "Kunde aktualisieren",
|
||||
log: "Kundendatensatz aktualisiert",
|
||||
},
|
||||
mail: {
|
||||
system: "E-Mail",
|
||||
label: "Bestätigung senden",
|
||||
log: "Bestätigung verschickt",
|
||||
},
|
||||
},
|
||||
},
|
||||
documents: {
|
||||
name: "Eingehende Dokumente",
|
||||
summary:
|
||||
"Rechnungen kommen als PDF per E-Mail. Ein KI-Schritt liest sie, eine Regel prüft das Ergebnis, und ein Mensch sieht nur die unklaren Fälle.",
|
||||
byHand:
|
||||
"Jemand öffnet jedes PDF, liest Lieferant, Betrag und Datum ab und tippt sie in die Buchhaltung.",
|
||||
automated:
|
||||
"Klare Fälle werden direkt gebucht. Unklare gehen mit dem Dokument im Anhang an einen Menschen.",
|
||||
nodes: {
|
||||
inbox: {
|
||||
system: "Posteingang",
|
||||
label: "E-Mail mit PDF",
|
||||
log: "Dokument eingegangen",
|
||||
},
|
||||
read: {
|
||||
system: "KI-Schritt",
|
||||
label: "Dokument lesen",
|
||||
log: "Lieferant, Betrag und Datum ausgelesen",
|
||||
},
|
||||
check: { system: "Regel", label: "Plausibel?", log: "Werte geprüft" },
|
||||
book: {
|
||||
system: "Buchhaltung",
|
||||
label: "Buchen",
|
||||
log: "automatisch gebucht",
|
||||
},
|
||||
ask: {
|
||||
system: "Ein Mensch",
|
||||
label: "Prüfen, entscheiden",
|
||||
log: "unklar: zur Prüfung an einen Menschen",
|
||||
},
|
||||
},
|
||||
},
|
||||
lead: {
|
||||
name: "Anfrage bis Nachfassen",
|
||||
summary:
|
||||
"Jemand füllt das Kontaktformular aus. Der Kontakt wird angelegt, die richtige Person erfährt davon, und eine Erinnerung sorgt dafür, dass niemand es vergisst.",
|
||||
byHand:
|
||||
"Jemand kopiert die Anfrage ins CRM, leitet sie per E-Mail weiter und hofft, dass das Nachfassen nicht vergessen wird.",
|
||||
automated:
|
||||
"Die Anfrage wird abgelegt, zugewiesen und zum geplanten Zeitpunkt nachverfolgt.",
|
||||
nodes: {
|
||||
form: {
|
||||
system: "Website",
|
||||
label: "Kontaktformular",
|
||||
log: "Anfrage eingegangen",
|
||||
},
|
||||
contact: {
|
||||
system: "CRM",
|
||||
label: "Kontakt anlegen",
|
||||
log: "Kontakt angelegt",
|
||||
},
|
||||
notify: {
|
||||
system: "Team-Chat",
|
||||
label: "Vertrieb informieren",
|
||||
log: "Vertrieb informiert",
|
||||
},
|
||||
wait: {
|
||||
system: "Regel",
|
||||
label: "Nach 3 Tagen offen?",
|
||||
log: "Nachfassen eingeplant",
|
||||
},
|
||||
remind: {
|
||||
system: "Aufgaben",
|
||||
label: "Erinnerung anlegen",
|
||||
log: "Erinnerung angelegt",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: [
|
||||
{
|
||||
title: "Wann es sich lohnt",
|
||||
items: [
|
||||
"Dieselben Schritte wiederholen sich täglich oder wöchentlich",
|
||||
"Daten werden von einem Programm ins andere getippt",
|
||||
"Fehler passieren, weil die Arbeit eintönig ist",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Was bei Menschen bleibt",
|
||||
items: [
|
||||
"Entscheidungen und Ausnahmen",
|
||||
"Alles, was ein Kunde von einem Menschen hören sollte",
|
||||
"Das letzte Wort, wenn die Automatisierung unsicher ist",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Was ich von Ihnen brauche",
|
||||
items: [
|
||||
"Zugang zu den Programmen und ihren Schnittstellen (APIs)",
|
||||
"Jemanden, der den Ablauf so kennt, wie er heute gemacht wird",
|
||||
"Echte Beispiele zum Testen",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
technology: {
|
||||
label: "Technik",
|
||||
title: { lead: "Für welche Projekte", rest: "ich der Richtige bin." },
|
||||
fit: [
|
||||
{
|
||||
title: "Passt gut",
|
||||
items: [
|
||||
"Web-Apps: Dashboards, Portale, interne Werkzeuge",
|
||||
"Oberflächen, bei denen Design und Bedienbarkeit zählen",
|
||||
"Unternehmenswebsites, auch in mehreren Sprachen",
|
||||
"Web3-Frontends: Wallets, Live-Daten, Dashboards",
|
||||
"Automatisierung: Systeme und ihre APIs verbinden",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Auch möglich",
|
||||
items: [
|
||||
"Das Backend, das eine App braucht: APIs, Login, Integrationen",
|
||||
"Werkzeuge für die Kommandozeile",
|
||||
"Bestehenden Code übernehmen und aufräumen",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Nicht mein Gebiet",
|
||||
items: [
|
||||
"Mobile Apps für die App-Stores",
|
||||
"Windows-Software",
|
||||
"Serverbetrieb, spezialisierte Datenbankarbeit",
|
||||
],
|
||||
},
|
||||
],
|
||||
techLabel: "Womit ich arbeite",
|
||||
tech: [
|
||||
{
|
||||
area: "Oberfläche",
|
||||
items: ["TypeScript", "React", "Next.js", "Vue.js", "Tailwind CSS"],
|
||||
},
|
||||
{
|
||||
area: "Dahinter",
|
||||
items: ["Node.js", "REST APIs", "PostgreSQL", "Headless CMS"],
|
||||
},
|
||||
{ area: "Web3 und Systeme", items: ["Solidity", "Rust"] },
|
||||
{
|
||||
area: "Auslieferung",
|
||||
items: ["Automatisierte Tests", "Docker", "CI/CD"],
|
||||
},
|
||||
],
|
||||
pipelineTitle: "Was passiert, bevor eine Änderung live geht.",
|
||||
pipelineIntro:
|
||||
"Software hängt zusammen: Eine Änderung an einer Stelle kann eine andere kaputt machen. Ich sichere mit automatisierten Tests ab, was ich baue, und jede Änderung muss sie bestehen, bevor sie veröffentlicht wird. So sieht ein solcher Durchlauf aus.",
|
||||
pipeline: {
|
||||
simulate: "Änderung simulieren in",
|
||||
scenarios: {
|
||||
orders: {
|
||||
label: "Bestellungen",
|
||||
testName: "Rechnungssumme passt zur rabattierten Bestellung",
|
||||
expected: "119.00",
|
||||
received: "125.00",
|
||||
},
|
||||
login: {
|
||||
label: "Login",
|
||||
testName: "Bestätigung geht an die E-Mail-Adresse des Kontos",
|
||||
expected: '"anna@example.com"',
|
||||
received: "undefined",
|
||||
},
|
||||
invoices: {
|
||||
label: "Rechnungen",
|
||||
testName: "Rechnungs-E-Mail hat das PDF im Anhang",
|
||||
expected: "1 Anhang",
|
||||
received: "0 Anhänge",
|
||||
},
|
||||
emails: {
|
||||
label: "E-Mails",
|
||||
testName:
|
||||
"Bestellung ist bestätigt, sobald die E-Mail eingereiht ist",
|
||||
expected: '"bestätigt"',
|
||||
received: '"offen"',
|
||||
},
|
||||
},
|
||||
stages: ["Lint", "Typen", "Tests", "Deploy"],
|
||||
explanations: [
|
||||
{
|
||||
title: "Eine Änderung kommt herein",
|
||||
text: "Im Bereich {area} wird etwas geändert. Bevor es live gehen kann, wird das ganze Projekt automatisch geprüft.",
|
||||
},
|
||||
{
|
||||
title: "Ein Test schlägt fehl, die Freigabe ist gesperrt",
|
||||
text: "Die Änderung hat an einer anderen Stelle etwas kaputt gemacht. Das fällt hier auf, bei mir, und nichts davon erreicht Ihre Nutzer.",
|
||||
},
|
||||
{
|
||||
title: "Behoben, erneut geprüft, veröffentlicht",
|
||||
text: "Ich repariere, was kaputtgegangen ist, der Durchlauf besteht, und erst dann geht die Änderung live.",
|
||||
},
|
||||
],
|
||||
runAgain: "Erneut starten",
|
||||
run: "Lauf",
|
||||
fixPushed: "Korrektur eingespielt",
|
||||
lint: "$ lint: prüft den Code-Stil…",
|
||||
types: "$ types: prüft, ob die Teile zusammenpassen…",
|
||||
tests: "Tests",
|
||||
expected: "erwartet",
|
||||
received: "erhalten",
|
||||
runningFile: "läuft: {file}…",
|
||||
summaryLabel: "Tests",
|
||||
failed: "{n} fehlgeschlagen",
|
||||
passed: "{n} bestanden",
|
||||
of: "von {total}",
|
||||
pending: "Freigabe offen",
|
||||
blocked: "Freigabe gesperrt",
|
||||
released: "veröffentlicht",
|
||||
footnote:
|
||||
"Eine Illustration mit erfundenen Dateinamen und Zahlen, einem echten Testlauf nachempfunden. Tests finden nicht jeden Fehler, aber diese Art schon.",
|
||||
},
|
||||
},
|
||||
working: {
|
||||
label: "Zusammenarbeit",
|
||||
title: { lead: "So arbeite ich.", rest: "Nichts Ungewöhnliches." },
|
||||
principles: [
|
||||
{
|
||||
title: "Ein Ansprechpartner",
|
||||
text: "Sie sprechen mit mir, und ich bin derjenige, der die Arbeit macht.",
|
||||
},
|
||||
{
|
||||
title: "Stunden, die Sie nachprüfen können",
|
||||
text: "Ich erfasse meine Zeit. Die Monatsrechnung zeigt, wofür die Stunden angefallen sind.",
|
||||
},
|
||||
{
|
||||
title: "Sorgfältige Arbeit",
|
||||
text: "Ich teste, was ich baue, bevor ich es übergebe, und sage es, wenn etwas noch nicht fertig ist oder ich mir nicht sicher bin.",
|
||||
},
|
||||
{
|
||||
title: "Was ich baue, gehört Ihnen",
|
||||
text: "Code und Design gehören Ihnen. Sie können beides an einen anderen Entwickler übergeben.",
|
||||
},
|
||||
],
|
||||
diagram: {
|
||||
description:
|
||||
"Oft geht eine Anfrage durch mehrere Hände, bevor sie beim Entwickler ankommt. Bei mir gibt es eine direkte Leitung zwischen Ihnen und dem, der die Arbeit macht.",
|
||||
chain: ["Sie", "Vertrieb", "Manager", "Designer", "Entwickler"],
|
||||
you: "Sie",
|
||||
me: "Ich",
|
||||
often: "oft",
|
||||
withMe: "bei mir",
|
||||
oftenLong: "oft: eine Anfrage wird weitergereicht",
|
||||
withMeLong: "bei mir: eine direkte Leitung",
|
||||
},
|
||||
},
|
||||
work: {
|
||||
label: "Meine Arbeit",
|
||||
title: {
|
||||
lead: "Zwei Websites, die ich gebaut habe.",
|
||||
rest: "Beide sind live.",
|
||||
},
|
||||
items: {
|
||||
"klz-cables.com": {
|
||||
client: "Kabellieferant",
|
||||
text: "Die Unternehmenswebsite eines Lieferanten für Energiekabel, auf Deutsch und Englisch.",
|
||||
did: [
|
||||
"Produktkatalog mit Kategorien und Detailseiten",
|
||||
"Datenblätter, erzeugt aus den Produktdaten",
|
||||
"Inhalte, die der Kunde in einem CMS pflegen kann",
|
||||
],
|
||||
tech: ["Next.js", "TypeScript", "Headless CMS", "Zwei Sprachen"],
|
||||
},
|
||||
"e-tib.com": {
|
||||
client: "Kabelnetzbau",
|
||||
text: "Die Unternehmenswebsite einer Gruppe im Kabelnetzbau, auf Deutsch und Englisch.",
|
||||
did: [
|
||||
"Seiten für Leistungen, Referenzen, Karriere und Veranstaltungen",
|
||||
"Dieselben Inhalte in zwei Sprachen",
|
||||
"Funktioniert auf Handy, Tablet und Desktop",
|
||||
],
|
||||
tech: ["Next.js", "TypeScript", "Headless CMS", "Zwei Sprachen"],
|
||||
},
|
||||
},
|
||||
openLive: "Live-Website öffnen",
|
||||
desktopAlt: "{name} auf einem Desktop-Bildschirm",
|
||||
phoneAlt: "{name} auf einem Handy",
|
||||
note: "Neben der Arbeit für Kunden baue ich eigene Software, unter anderem ein System in Rust und Solidity, das auf sieben Blockchains läuft.",
|
||||
},
|
||||
faq: {
|
||||
label: "Fragen",
|
||||
title: { lead: "Gut zu wissen,", rest: "bevor wir sprechen." },
|
||||
items: [
|
||||
{
|
||||
question: "Ich bin kein Technikmensch. Ist das ein Problem?",
|
||||
answer:
|
||||
"Nein. Ich erkläre in verständlicher Sprache, was ich tue, und Sie können jederzeit nachfragen. Code müssen Sie nicht lesen.",
|
||||
},
|
||||
{
|
||||
question: "Wie lange bin ich gebunden?",
|
||||
answer:
|
||||
"Projektarbeit wird nach Aufwand abgerechnet. Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.",
|
||||
},
|
||||
{
|
||||
question: "Wem gehört, was Sie bauen?",
|
||||
answer:
|
||||
"Ihnen. Code und Design gehören Ihnen, und Sie können beides an einen anderen Entwickler übergeben.",
|
||||
},
|
||||
{
|
||||
question: "Können Sie mit unserem bestehenden Team und Code arbeiten?",
|
||||
answer:
|
||||
"Ja. Ich habe jahrelang in Agenturen und Produktteams gearbeitet, der Einstieg in ein bestehendes Projekt ist mir vertraut.",
|
||||
},
|
||||
{
|
||||
question: "Wie erreiche ich Sie?",
|
||||
answer:
|
||||
"Per E-Mail oder Videocall. Ich arbeite remote und bin zu europäischen Geschäftszeiten erreichbar.",
|
||||
},
|
||||
{
|
||||
question: "Was machen Sie nicht?",
|
||||
answer:
|
||||
"Mobile Apps für die App-Stores, Windows-Software und den Betrieb Ihrer Server. Wenn Ihr Projekt das braucht, sage ich das im ersten Gespräch.",
|
||||
},
|
||||
],
|
||||
},
|
||||
contact: {
|
||||
headline: [
|
||||
{ text: "REDEN", tone: "bits" },
|
||||
{ text: "WIR.", tone: "solid" },
|
||||
],
|
||||
lead: "Wählen Sie aus, was Sie brauchen, und die E-Mail schreibt sich von selbst. Oder schreiben Sie mir direkt:",
|
||||
newMessage: "Neue Nachricht",
|
||||
draft: "Entwurf",
|
||||
need: "Ich brauche",
|
||||
to: "an",
|
||||
subject: "Betreff",
|
||||
open: "Im E-Mail-Programm öffnen",
|
||||
copy: "Adresse kopieren",
|
||||
copied: "Kopiert",
|
||||
// The square brackets are blanks for the visitor to fill in.
|
||||
topics: [
|
||||
{
|
||||
id: "developer",
|
||||
label: "Einen Entwickler",
|
||||
draft: {
|
||||
subject: "Ein Entwickler für unser Produkt",
|
||||
body: `${GREETING}\n\nwir arbeiten an [was Sie bauen] und brauchen Unterstützung bei [was als Nächstes ansteht].\n\nEin erstes Gespräch würde uns um den [Datum oder Woche] passen.\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rescue",
|
||||
label: "Eine zuverlässige App",
|
||||
draft: {
|
||||
subject: "Unsere App soll zuverlässig werden",
|
||||
body: `${GREETING}\n\nunsere App [Name oder kurze Beschreibung] läuft, aber [was immer wieder schiefgeht].\n\nKönnten Sie sich das ansehen?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "automation",
|
||||
label: "Automatisierung",
|
||||
draft: {
|
||||
subject: "Wir möchten einen Ablauf automatisieren",
|
||||
body: `${GREETING}\n\nwir möchten [den Ablauf] automatisieren. Heute wird er von Hand in [beteiligte Programme] erledigt.\n\nKönnen wir darüber sprechen?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "website",
|
||||
label: "Eine Website",
|
||||
draft: {
|
||||
subject: "Eine Website für mein Unternehmen",
|
||||
body: `${GREETING}\n\nich brauche eine Website für [Ihr Unternehmen]. Sie soll [wofür sie da ist].\n\nWann könnten wir sprechen?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
label: "Etwas anderes",
|
||||
draft: {
|
||||
subject: "Eine Frage",
|
||||
body: `${GREETING}\n\n[was Sie vorhaben]\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
notFound: {
|
||||
kicker: "Fehler 404",
|
||||
title: "Seite nicht gefunden.",
|
||||
text: "Diese Adresse gibt es nicht. Die Seite wurde vielleicht verschoben oder entfernt.",
|
||||
home: "Zur Startseite",
|
||||
},
|
||||
error: {
|
||||
kicker: "Fehler 500",
|
||||
title: "Etwas ist schiefgelaufen.",
|
||||
text: "Ein unerwartetes Problem ist aufgetreten. Es wurde protokolliert.",
|
||||
retry: "Erneut versuchen",
|
||||
home: "Zur Startseite",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,708 @@
|
||||
import type { Content } from "./types";
|
||||
|
||||
const SIGN_OFF = "Best regards";
|
||||
const FIRST_CALL = "A first call, about 20 minutes";
|
||||
const GET_IN_TOUCH = "Get in touch";
|
||||
|
||||
/** The English texts of the site. de.ts holds the same in German. */
|
||||
export const en: Content = {
|
||||
site: {
|
||||
title: "Freelance web developer for web apps & websites | Marc Mintel",
|
||||
description:
|
||||
"Freelance web developer with 15 years of experience. I build web apps, interfaces and websites with React, Next.js and TypeScript, and automate processes.",
|
||||
jobTitle: "Freelance web developer",
|
||||
ogLocale: "en_US",
|
||||
shareImageAlt:
|
||||
"Web apps and websites. Marc Mintel, freelance web developer with 15 years of experience.",
|
||||
knowsAbout: [
|
||||
"Web applications",
|
||||
"User interfaces",
|
||||
"Websites",
|
||||
"Process automation",
|
||||
"Web3 frontends",
|
||||
],
|
||||
catalogName: "Services",
|
||||
},
|
||||
nav: {
|
||||
contact: GET_IN_TOUCH,
|
||||
contactLong: GET_IN_TOUCH,
|
||||
logoLabel: "Marc Mintel, back to top",
|
||||
imprint: "Imprint",
|
||||
privacy: "Privacy",
|
||||
backToTop: "Back to top",
|
||||
language: "Language",
|
||||
languageNames: { en: "English", de: "Deutsch" },
|
||||
},
|
||||
hero: {
|
||||
headline: [
|
||||
{ text: "WEB APPS", tone: "solid" },
|
||||
{ text: "& WEBSITES.", tone: "bits" },
|
||||
],
|
||||
lead: "Freelance web developer with 15 years of experience.",
|
||||
leadStrong: "I build web apps, interfaces and websites.",
|
||||
primaryCta: GET_IN_TOUCH,
|
||||
secondaryCta: "What I do",
|
||||
trust: ["15 years in web", "Remote, EU core hours"],
|
||||
builtAndRunning: "Built and running:",
|
||||
},
|
||||
hello: {
|
||||
label: "Hello",
|
||||
title: "I am Marc.",
|
||||
lead: "I have been building for the web for 15 years: first in agencies, later as a senior developer, and now on my own.",
|
||||
paragraphs: [
|
||||
"My strength is the part people see and use: design and interface. I am also at home with what lies behind it, so I can build a complete application and not just its surface.",
|
||||
"You work with me directly. Whether you run a product team or are commissioning something like this for the first time.",
|
||||
],
|
||||
portraitAlt: "Marc Mintel, freelance web developer",
|
||||
careerLabel: "The path so far",
|
||||
career: [
|
||||
{ years: "2011", role: "Started in the web", place: "Apprenticeship" },
|
||||
{ years: "2014 – 2020", role: "Agencies", place: "Frontend developer" },
|
||||
{
|
||||
years: "2020 – 2023",
|
||||
role: "Product companies",
|
||||
place: "Senior software developer",
|
||||
},
|
||||
{
|
||||
years: "Today",
|
||||
role: "Independent",
|
||||
place: "Working directly with clients",
|
||||
},
|
||||
],
|
||||
},
|
||||
services: {
|
||||
label: "Services",
|
||||
title: { lead: "What do you need?", rest: "Four typical cases." },
|
||||
tablistLabel: "What do you need?",
|
||||
youGet: "What you get",
|
||||
howItRuns: "How it runs",
|
||||
whatItCosts: "What it costs",
|
||||
paths: [
|
||||
{
|
||||
id: "developer",
|
||||
service: "Web app and frontend development",
|
||||
need: "We need a developer for our product",
|
||||
forWhom: "Startups, product teams, agencies, Web3 teams",
|
||||
headline: "An experienced developer who joins your project.",
|
||||
intro:
|
||||
"You have a product and more work than your team can handle. I work on agreed days of the week on what is next on your list: new features, the interface, or the parts nobody gets around to.",
|
||||
youGet: [
|
||||
"Web apps, dashboards, customer portals and internal tools",
|
||||
"Interfaces with attention to design and usability",
|
||||
"Web3 frontends: wallet connection, live data, dashboards",
|
||||
"Small tools and automation for recurring work",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: FIRST_CALL,
|
||||
text: "You tell me what you are building and where you need help. If I am not the right person for it, I say so.",
|
||||
},
|
||||
{
|
||||
title: "We agree on the days",
|
||||
text: "How many days a week I work for you, and what should come first.",
|
||||
},
|
||||
{
|
||||
title: "I work through the list",
|
||||
text: "Finished parts go to you for review as they are done. I track my hours and you can see what they were spent on.",
|
||||
},
|
||||
{
|
||||
title: "Two weeks notice",
|
||||
text: "The agreement can be ended with two weeks notice to the end of a month.",
|
||||
},
|
||||
],
|
||||
pricingModel: "By effort, for as long as your project runs",
|
||||
pricingText:
|
||||
"I work on your project on agreed days of the week and bill the hours I actually worked. There is no package to buy and no minimum term.",
|
||||
prices: [
|
||||
{ label: "Per hour", amount: "100 EUR", note: "net" },
|
||||
{ label: "Per day", amount: "800 EUR", note: "8 hours, net" },
|
||||
],
|
||||
smallPrint:
|
||||
"One invoice per month, with the tracked hours. Either side can end it with two weeks notice to the end of a month.",
|
||||
cta: GET_IN_TOUCH,
|
||||
},
|
||||
{
|
||||
id: "rescue",
|
||||
service: "Code review and stabilisation of web apps",
|
||||
need: "Our app needs to become reliable",
|
||||
forWhom: "Teams whose app was built quickly and is now hard to change",
|
||||
headline: "A week to look at your app and fix what is most urgent.",
|
||||
intro:
|
||||
"The app works, but bugs come back and small changes take longer than they should. I take a week to go through the code, fix what I can in that time and write down what I would do next.",
|
||||
youGet: [
|
||||
"An honest assessment: what is solid and what is fragile",
|
||||
"Automated tests for the most important parts",
|
||||
"The most urgent problems fixed, as far as a week allows",
|
||||
"A written list of next steps, in plain language",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: FIRST_CALL,
|
||||
text: "You show me the app and tell me where it causes trouble.",
|
||||
},
|
||||
{
|
||||
title: "One week of work",
|
||||
text: "I read the code, add tests where they matter most and fix the most urgent problems.",
|
||||
},
|
||||
{
|
||||
title: "A written summary",
|
||||
text: "What I found, what I changed and what I recommend. Written so that you can follow it without being a developer.",
|
||||
},
|
||||
{
|
||||
title: "You decide how to continue",
|
||||
text: "With me, with your own team, or not at all.",
|
||||
},
|
||||
],
|
||||
pricingModel: "A fixed week at a fixed price",
|
||||
pricingText:
|
||||
"The scope is one week of my time. You know the cost before I start.",
|
||||
prices: [
|
||||
{ label: "One week, 35 hours", amount: "3,500 EUR", note: "net" },
|
||||
],
|
||||
smallPrint:
|
||||
"A week does not fix everything. If I see in the first call that it will not be enough to be useful, I tell you.",
|
||||
cta: GET_IN_TOUCH,
|
||||
},
|
||||
{
|
||||
id: "automation",
|
||||
service: "Process automation and system integration",
|
||||
need: "We want to automate a process",
|
||||
forWhom:
|
||||
"Companies where people copy data between tools or repeat the same steps",
|
||||
headline:
|
||||
"Systems that pass work on to each other, so people do not have to.",
|
||||
intro:
|
||||
"Someone exports a list here, types it in there and sends an email about it. I connect the tools you already use, so that recurring steps run on their own and your people can do the work that needs them.",
|
||||
youGet: [
|
||||
"Connections between your systems through their interfaces (APIs)",
|
||||
"Recurring workflows that run without manual steps",
|
||||
"Where it helps, steps handled with AI: sorting, summarising, reading documents",
|
||||
"A short description of how it works, so it can be maintained",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: FIRST_CALL,
|
||||
text: "You describe the process and which tools are involved.",
|
||||
},
|
||||
{
|
||||
title: "I look at how it is done today",
|
||||
text: "Step by step, with the people who do it. Often a part can be automated, not everything.",
|
||||
},
|
||||
{
|
||||
title: "I connect the systems",
|
||||
text: "One step at a time. You test it with real cases before it replaces the manual work.",
|
||||
},
|
||||
{
|
||||
title: "It runs on its own",
|
||||
text: "You get a note on what it does and what to check when one of the tools changes.",
|
||||
},
|
||||
],
|
||||
pricingModel: "An estimate first, then a budget",
|
||||
pricingText:
|
||||
"Once I have seen the process I estimate the hours. That estimate becomes the budget. I bill the hours I actually needed and tell you early if it will not be enough.",
|
||||
prices: [{ label: "Per hour", amount: "100 EUR", note: "net" }],
|
||||
smallPrint:
|
||||
"How long it takes depends on the process, so there is no price before I have seen it. Fees for the tools themselves are paid by you.",
|
||||
cta: GET_IN_TOUCH,
|
||||
},
|
||||
{
|
||||
id: "website",
|
||||
service: "Website design and development",
|
||||
need: "I just need a website",
|
||||
forWhom: "Companies and self-employed people who want it taken care of",
|
||||
headline: "A website, built and looked after for you.",
|
||||
intro:
|
||||
"You do not want to learn a website builder or deal with hosting. You tell me what your business does and what the site should achieve. I design it, build it and put it online.",
|
||||
youGet: [
|
||||
"A design made for your business, not a template",
|
||||
"Works on phone, tablet and desktop",
|
||||
"Hosting taken care of, no server for you to manage",
|
||||
"Changes by email: you write what you need, I make the change",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: FIRST_CALL,
|
||||
text: "You tell me about your business and what the website is for.",
|
||||
},
|
||||
{
|
||||
title: "A design to look at",
|
||||
text: "You get a first version to click through. We adjust it until it fits.",
|
||||
},
|
||||
{
|
||||
title: "I build it and put it online",
|
||||
text: "With your texts and images and a contact form. You approve it before it goes live.",
|
||||
},
|
||||
{
|
||||
title: "Changes afterwards",
|
||||
text: "A new photo or a changed price: you send me an email.",
|
||||
},
|
||||
],
|
||||
pricingModel: "An estimate first, then a budget",
|
||||
pricingText:
|
||||
"After the first call you get an estimate. It becomes your budget: I bill the hours actually used. The estimate usually holds, and if it will not, I tell you before the budget is used up.",
|
||||
prices: [
|
||||
{
|
||||
label: "A typical company website",
|
||||
amount: "from 3,800 EUR",
|
||||
note: "one-off, net",
|
||||
},
|
||||
{ label: "Anything extra, per hour", amount: "100 EUR", note: "net" },
|
||||
],
|
||||
smallPrint:
|
||||
"Hosting and small changes afterwards cost a monthly fee that we agree on before we start. I do not manage email inboxes; they stay with your provider.",
|
||||
cta: GET_IN_TOUCH,
|
||||
},
|
||||
],
|
||||
scenes: {
|
||||
you: "You",
|
||||
me: "Me",
|
||||
talkLength: "about 20 minutes",
|
||||
days: ["Mon", "Tue", "Wed", "Thu", "Fri"],
|
||||
daysNote: "for example three days a week",
|
||||
review: "finished parts go to you for review",
|
||||
week: "week {n}",
|
||||
monthEnd: "end of month",
|
||||
notice: "two weeks notice",
|
||||
repair: "fragile parts get tests and fixes",
|
||||
summaryParts: ["found", "changed", "next"],
|
||||
summaryNote: "written so you can follow it",
|
||||
options: ["continue with me", "your own team", "stop here"],
|
||||
online: "online",
|
||||
exampleDomain: "yourcompany.com",
|
||||
firstVersion: "a first version to click through",
|
||||
approve: "you approve it before it goes live",
|
||||
changeByEmail: "you send an email, I make the change",
|
||||
tools: ["Shop", "Accounting", "Email", "CRM"],
|
||||
byHand: "today: copied by hand",
|
||||
wiring: "connected one step at a time",
|
||||
running: "the steps run without anyone copying",
|
||||
},
|
||||
},
|
||||
automation: {
|
||||
label: "Process automation",
|
||||
title: { lead: "Let the systems", rest: "do the copying." },
|
||||
intro:
|
||||
"In most companies, people move information from one tool to another: read it here, type it in there, send an email about it. Automation connects those tools so that the work passes from one to the next on its own. Three typical examples:",
|
||||
examplesLabel: "Example workflows",
|
||||
running: "running",
|
||||
runComplete: "run complete",
|
||||
log: "Log",
|
||||
byHand: "By hand",
|
||||
automated: "Automated",
|
||||
kinds: {
|
||||
trigger: "Trigger: something happens",
|
||||
rule: "Rule: a fixed check",
|
||||
ai: "AI step: reads or sorts",
|
||||
action: "Action: a system does something",
|
||||
human: "Person: decides the unclear cases",
|
||||
},
|
||||
workflows: {
|
||||
order: {
|
||||
name: "Order to invoice",
|
||||
summary:
|
||||
"A new order arrives in the shop. The invoice, the customer record and the confirmation follow without anyone typing.",
|
||||
byHand:
|
||||
"Someone opens the order, writes the invoice, updates the customer record and sends an email.",
|
||||
automated:
|
||||
"The order triggers everything. People only see the finished result.",
|
||||
nodes: {
|
||||
shop: { system: "Shop", label: "New order", log: "order received" },
|
||||
stock: {
|
||||
system: "Rule",
|
||||
label: "In stock?",
|
||||
log: "stock checked: available",
|
||||
},
|
||||
invoice: {
|
||||
system: "Accounting",
|
||||
label: "Create invoice",
|
||||
log: "invoice created",
|
||||
},
|
||||
crm: {
|
||||
system: "CRM",
|
||||
label: "Update customer",
|
||||
log: "customer record updated",
|
||||
},
|
||||
mail: {
|
||||
system: "Email",
|
||||
label: "Send confirmation",
|
||||
log: "confirmation sent",
|
||||
},
|
||||
},
|
||||
},
|
||||
documents: {
|
||||
name: "Incoming documents",
|
||||
summary:
|
||||
"Invoices arrive as PDF by email. An AI step reads them, a rule checks the result, and a person only sees the unclear ones.",
|
||||
byHand:
|
||||
"Someone opens every PDF, reads supplier, amount and date, and types them into the accounting system.",
|
||||
automated:
|
||||
"Clear cases are booked directly. Unclear ones go to a person with the document attached.",
|
||||
nodes: {
|
||||
inbox: {
|
||||
system: "Inbox",
|
||||
label: "Email with PDF",
|
||||
log: "document received",
|
||||
},
|
||||
read: {
|
||||
system: "AI step",
|
||||
label: "Read the document",
|
||||
log: "supplier, amount and date extracted",
|
||||
},
|
||||
check: { system: "Rule", label: "Plausible?", log: "values checked" },
|
||||
book: {
|
||||
system: "Accounting",
|
||||
label: "Book it",
|
||||
log: "booked automatically",
|
||||
},
|
||||
ask: {
|
||||
system: "A person",
|
||||
label: "Review and decide",
|
||||
log: "unclear: sent to a person for review",
|
||||
},
|
||||
},
|
||||
},
|
||||
lead: {
|
||||
name: "Enquiry to follow-up",
|
||||
summary:
|
||||
"Someone fills in the contact form. The contact is created, the right person is told, and a reminder makes sure nobody forgets.",
|
||||
byHand:
|
||||
"Someone copies the enquiry into the CRM, forwards it by email and hopes the follow-up is not forgotten.",
|
||||
automated:
|
||||
"The enquiry is filed, assigned and followed up on schedule.",
|
||||
nodes: {
|
||||
form: {
|
||||
system: "Website",
|
||||
label: "Contact form",
|
||||
log: "enquiry received",
|
||||
},
|
||||
contact: {
|
||||
system: "CRM",
|
||||
label: "Create contact",
|
||||
log: "contact created",
|
||||
},
|
||||
notify: {
|
||||
system: "Team chat",
|
||||
label: "Notify sales",
|
||||
log: "sales notified",
|
||||
},
|
||||
wait: {
|
||||
system: "Rule",
|
||||
label: "No reply in 3 days?",
|
||||
log: "follow-up scheduled",
|
||||
},
|
||||
remind: {
|
||||
system: "Tasks",
|
||||
label: "Create reminder",
|
||||
log: "reminder created",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: [
|
||||
{
|
||||
title: "Worth automating when",
|
||||
items: [
|
||||
"The same steps repeat every day or every week",
|
||||
"Data is typed from one tool into another",
|
||||
"Mistakes happen because the work is monotonous",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "What stays with people",
|
||||
items: [
|
||||
"Decisions and exceptions",
|
||||
"Anything a customer should hear from a person",
|
||||
"The final say when the automation is unsure",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "What I need from you",
|
||||
items: [
|
||||
"Access to the tools and their interfaces (APIs)",
|
||||
"Someone who knows the process as it is done today",
|
||||
"Real examples to test with",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
technology: {
|
||||
label: "Technology",
|
||||
title: { lead: "Which projects", rest: "I am right for." },
|
||||
fit: [
|
||||
{
|
||||
title: "A good fit",
|
||||
items: [
|
||||
"Web apps: dashboards, portals, internal tools",
|
||||
"Interfaces where design and usability matter",
|
||||
"Company websites, also in several languages",
|
||||
"Web3 frontends: wallets, live data, dashboards",
|
||||
"Automation: connecting systems and their APIs",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Also possible",
|
||||
items: [
|
||||
"The backend an app needs: APIs, login, integrations",
|
||||
"Command-line tools",
|
||||
"Taking over and tidying up existing code",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Not my field",
|
||||
items: [
|
||||
"Mobile apps for the app stores",
|
||||
"Windows software",
|
||||
"Running servers, specialised database work",
|
||||
],
|
||||
},
|
||||
],
|
||||
techLabel: "What I work with",
|
||||
tech: [
|
||||
{
|
||||
area: "Interface",
|
||||
items: ["TypeScript", "React", "Next.js", "Vue.js", "Tailwind CSS"],
|
||||
},
|
||||
{
|
||||
area: "Behind it",
|
||||
items: ["Node.js", "REST APIs", "PostgreSQL", "Headless CMS"],
|
||||
},
|
||||
{ area: "Web3 and systems", items: ["Solidity", "Rust"] },
|
||||
{ area: "Delivery", items: ["Automated tests", "Docker", "CI/CD"] },
|
||||
],
|
||||
pipelineTitle: "What happens before a change goes live.",
|
||||
pipelineIntro:
|
||||
"Software is connected: a change in one place can break another. I secure what I build with automated tests, and every change has to pass them before it is released. This is what such a run looks like.",
|
||||
pipeline: {
|
||||
simulate: "Simulate a change in",
|
||||
scenarios: {
|
||||
orders: {
|
||||
label: "Orders",
|
||||
testName: "invoice total matches the discounted order",
|
||||
expected: "119.00",
|
||||
received: "125.00",
|
||||
},
|
||||
login: {
|
||||
label: "Login",
|
||||
testName: "confirmation goes to the account's email address",
|
||||
expected: '"anna@example.com"',
|
||||
received: "undefined",
|
||||
},
|
||||
invoices: {
|
||||
label: "Invoices",
|
||||
testName: "invoice email attaches the PDF",
|
||||
expected: "1 attachment",
|
||||
received: "0 attachments",
|
||||
},
|
||||
emails: {
|
||||
label: "Emails",
|
||||
testName: "order is confirmed once the email is queued",
|
||||
expected: '"confirmed"',
|
||||
received: '"pending"',
|
||||
},
|
||||
},
|
||||
stages: ["Lint", "Types", "Tests", "Deploy"],
|
||||
explanations: [
|
||||
{
|
||||
title: "A change comes in",
|
||||
text: "Something is changed in {area}. Before it can go live, the whole project is checked automatically.",
|
||||
},
|
||||
{
|
||||
title: "A test fails, the release is blocked",
|
||||
text: "The change broke something in a different place. It is caught here, on my side, and nothing reaches your users.",
|
||||
},
|
||||
{
|
||||
title: "Fixed, checked again, released",
|
||||
text: "I repair what broke, the run passes, and only then does the change go live.",
|
||||
},
|
||||
],
|
||||
runAgain: "Run again",
|
||||
run: "run",
|
||||
fixPushed: "fix pushed",
|
||||
lint: "$ lint: checking the code style…",
|
||||
types: "$ types: checking that the parts fit together…",
|
||||
tests: "tests",
|
||||
expected: "expected",
|
||||
received: "received",
|
||||
runningFile: "running {file}…",
|
||||
summaryLabel: "Tests",
|
||||
failed: "{n} failed",
|
||||
passed: "{n} passed",
|
||||
of: "of {total}",
|
||||
pending: "release pending",
|
||||
blocked: "release blocked",
|
||||
released: "released",
|
||||
footnote:
|
||||
"An illustration with made-up file names and numbers, modelled on a real test run. Tests do not catch every mistake, but they do catch this kind.",
|
||||
},
|
||||
},
|
||||
working: {
|
||||
label: "Working with me",
|
||||
title: { lead: "How I work.", rest: "Nothing unusual." },
|
||||
principles: [
|
||||
{
|
||||
title: "One contact",
|
||||
text: "You talk to me, and I am the one who does the work.",
|
||||
},
|
||||
{
|
||||
title: "Hours you can check",
|
||||
text: "I track my time. The monthly invoice shows what the hours were spent on.",
|
||||
},
|
||||
{
|
||||
title: "Careful work",
|
||||
text: "I test what I build before I hand it over, and I say so when something is not finished or I am not sure.",
|
||||
},
|
||||
{
|
||||
title: "What I build is yours",
|
||||
text: "Code and design belong to you. You can hand them to another developer.",
|
||||
},
|
||||
],
|
||||
diagram: {
|
||||
description:
|
||||
"Often a request passes through several people before it reaches the developer. With me there is one direct line between you and the person who does the work.",
|
||||
chain: ["You", "Sales", "Manager", "Designer", "Developer"],
|
||||
you: "You",
|
||||
me: "Me",
|
||||
often: "often",
|
||||
withMe: "with me",
|
||||
oftenLong: "often: a request is passed along",
|
||||
withMeLong: "with me: one direct line",
|
||||
},
|
||||
},
|
||||
work: {
|
||||
label: "My work",
|
||||
title: { lead: "Two sites I built.", rest: "Both are live." },
|
||||
items: {
|
||||
"klz-cables.com": {
|
||||
client: "Cable supplier",
|
||||
text: "The company website of a supplier of energy cables, in German and English.",
|
||||
did: [
|
||||
"Product catalogue with categories and detail pages",
|
||||
"Datasheets generated from the product data",
|
||||
"Content the client can edit in a CMS",
|
||||
],
|
||||
tech: ["Next.js", "TypeScript", "Headless CMS", "Two languages"],
|
||||
},
|
||||
"e-tib.com": {
|
||||
client: "Cable network construction",
|
||||
text: "The company website of a group in cable network construction, in German and English.",
|
||||
did: [
|
||||
"Pages for services, references, careers and events",
|
||||
"The same content in two languages",
|
||||
"Works on phone, tablet and desktop",
|
||||
],
|
||||
tech: ["Next.js", "TypeScript", "Headless CMS", "Two languages"],
|
||||
},
|
||||
},
|
||||
openLive: "Open the live site",
|
||||
desktopAlt: "{name} on a desktop screen",
|
||||
phoneAlt: "{name} on a phone",
|
||||
note: "Besides client work I build my own software, among other things a system in Rust and Solidity that runs on seven blockchains.",
|
||||
},
|
||||
faq: {
|
||||
label: "Questions",
|
||||
title: { lead: "Good to know", rest: "before we talk." },
|
||||
items: [
|
||||
{
|
||||
question: "I am not technical. Is that a problem?",
|
||||
answer:
|
||||
"No. I explain what I am doing in plain language, and you can ask at any time. You do not need to read code.",
|
||||
},
|
||||
{
|
||||
question: "How long am I committed?",
|
||||
answer:
|
||||
"Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.",
|
||||
},
|
||||
{
|
||||
question: "Who owns what you build?",
|
||||
answer:
|
||||
"You do. Code and design belong to you, and you can hand them to another developer.",
|
||||
},
|
||||
{
|
||||
question: "Can you work with our existing team and code?",
|
||||
answer:
|
||||
"Yes. I worked in agencies and product teams for years, so joining an existing project is familiar to me.",
|
||||
},
|
||||
{
|
||||
question: "How do I reach you?",
|
||||
answer:
|
||||
"By email or video call. I work remotely and am available during European business hours.",
|
||||
},
|
||||
{
|
||||
question: "What do you not do?",
|
||||
answer:
|
||||
"Mobile apps for the app stores, Windows software and running your servers. If your project needs those, I say so in the first call.",
|
||||
},
|
||||
],
|
||||
},
|
||||
contact: {
|
||||
headline: [
|
||||
{ text: "LET'S", tone: "bits" },
|
||||
{ text: "TALK.", tone: "solid" },
|
||||
],
|
||||
lead: "Pick what you need and the email writes itself. Or write to me directly:",
|
||||
newMessage: "New message",
|
||||
draft: "draft",
|
||||
need: "I need",
|
||||
to: "to",
|
||||
subject: "subject",
|
||||
open: "Open in my mail app",
|
||||
copy: "Copy address",
|
||||
copied: "Copied",
|
||||
// The square brackets are blanks for the visitor to fill in.
|
||||
topics: [
|
||||
{
|
||||
id: "developer",
|
||||
label: "A developer",
|
||||
draft: {
|
||||
subject: "A developer for our product",
|
||||
body: `Hi Marc,\n\nwe are working on [what you are building] and need help with [what is next on your list].\n\nA first call would suit us around [date or week].\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rescue",
|
||||
label: "A reliable app",
|
||||
draft: {
|
||||
subject: "Our app needs to become reliable",
|
||||
body: `Hi Marc,\n\nour app [name or short description] works, but [what keeps going wrong].\n\nCould you take a look?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "automation",
|
||||
label: "Automation",
|
||||
draft: {
|
||||
subject: "We want to automate a process",
|
||||
body: `Hi Marc,\n\nwe would like to automate [the process]. Today it is done by hand in [the tools involved].\n\nCan we talk about it?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "website",
|
||||
label: "A website",
|
||||
draft: {
|
||||
subject: "A website for my business",
|
||||
body: `Hi Marc,\n\nI need a website for [your business]. It should [what it is for].\n\nWhen could we talk?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
label: "Something else",
|
||||
draft: {
|
||||
subject: "A question",
|
||||
body: `Hi Marc,\n\n[what you have in mind]\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
notFound: {
|
||||
kicker: "Error 404",
|
||||
title: "Page not found.",
|
||||
text: "This address does not exist. The page may have been moved or removed.",
|
||||
home: "Back to the homepage",
|
||||
},
|
||||
error: {
|
||||
kicker: "Error 500",
|
||||
title: "Something went wrong.",
|
||||
text: "An unexpected problem occurred. It has been logged.",
|
||||
retry: "Try again",
|
||||
home: "Back to the homepage",
|
||||
},
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
export type FaqItem = {
|
||||
readonly question: string;
|
||||
readonly answer: string;
|
||||
};
|
||||
|
||||
/** Shown in the questions section and passed to search engines as structured data. */
|
||||
export const FAQ: readonly FaqItem[] = [
|
||||
{
|
||||
question: "I am not technical. Is that a problem?",
|
||||
answer:
|
||||
"No. I explain what I am doing in plain language, and you can ask at any time. You do not need to read code.",
|
||||
},
|
||||
{
|
||||
question: "How long am I committed?",
|
||||
answer:
|
||||
"Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.",
|
||||
},
|
||||
{
|
||||
question: "Who owns what you build?",
|
||||
answer:
|
||||
"You do. Code and design belong to you, and you can hand them to another developer.",
|
||||
},
|
||||
{
|
||||
question: "Can you work with our existing team and code?",
|
||||
answer:
|
||||
"Yes. I worked in agencies and product teams for years, so joining an existing project is familiar to me.",
|
||||
},
|
||||
{
|
||||
question: "How do I reach you?",
|
||||
answer:
|
||||
"By email or video call. I work remotely and am available during European business hours.",
|
||||
},
|
||||
{
|
||||
question: "What do you not do?",
|
||||
answer:
|
||||
"Mobile apps for the app stores, Windows software and running your servers. If your project needs those, I say so in the first call.",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Locale } from "@/src/domain/locale";
|
||||
import { de } from "./de";
|
||||
import { en } from "./en";
|
||||
import type { Content } from "./types";
|
||||
|
||||
const CONTENT: Readonly<Record<Locale, Content>> = { en, de };
|
||||
|
||||
/** The texts of the site in one language. */
|
||||
export const contentFor = (locale: Locale): Content => CONTENT[locale];
|
||||
@@ -0,0 +1,93 @@
|
||||
import { EmailLink } from "@/src/components/EmailLink";
|
||||
import { LegalSection } from "@/src/components/LegalPage";
|
||||
import { OWNER } from "@/src/content/site";
|
||||
import type { Locale } from "@/src/domain/locale";
|
||||
import type { LegalText } from "./types";
|
||||
|
||||
const LINK = "font-bold text-slate-900 underline-offset-4 hover:underline";
|
||||
|
||||
const Address: React.FC<{ country: string }> = ({ country }) => (
|
||||
<p>
|
||||
{OWNER.name}
|
||||
<br />
|
||||
{OWNER.street}
|
||||
<br />
|
||||
{OWNER.postalCode} {OWNER.locality}
|
||||
<br />
|
||||
{country}
|
||||
</p>
|
||||
);
|
||||
|
||||
/** The imprint (section 5 DDG) in both languages. */
|
||||
export const IMPRINT: Readonly<Record<Locale, LegalText>> = {
|
||||
de: {
|
||||
title: "Impressum",
|
||||
description: "Impressum von mintel.me.",
|
||||
Body: () => (
|
||||
<>
|
||||
<LegalSection heading="Angaben gemäß § 5 DDG">
|
||||
<Address country="Deutschland" />
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Kontakt">
|
||||
<p>
|
||||
E-Mail: <EmailLink className={LINK} />
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Umsatzsteuer-Identifikationsnummer">
|
||||
<p>
|
||||
Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz:{" "}
|
||||
{OWNER.vatId}
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Verantwortlich für den Inhalt">
|
||||
<p>{OWNER.name}, Anschrift wie oben (§ 18 Abs. 2 MStV).</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Verbraucherstreitbeilegung">
|
||||
<p>
|
||||
Ich bin weder bereit noch verpflichtet, an Streitbeilegungsverfahren
|
||||
vor einer Verbraucherschlichtungsstelle teilzunehmen.
|
||||
</p>
|
||||
</LegalSection>
|
||||
</>
|
||||
),
|
||||
},
|
||||
en: {
|
||||
title: "Imprint",
|
||||
description: "Legal notice (Impressum) for mintel.me.",
|
||||
Body: () => (
|
||||
<>
|
||||
<LegalSection heading="Information according to section 5 DDG">
|
||||
<Address country="Germany" />
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Contact">
|
||||
<p>
|
||||
Email: <EmailLink className={LINK} />
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="VAT identification number">
|
||||
<p>
|
||||
VAT identification number according to section 27a of the German VAT
|
||||
Act (Umsatzsteuergesetz): {OWNER.vatId}
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Responsible for the content">
|
||||
<p>{OWNER.name}, address as above (section 18 paragraph 2 MStV).</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Consumer dispute resolution">
|
||||
<p>
|
||||
I am neither willing nor obliged to take part in dispute resolution
|
||||
proceedings before a consumer arbitration board.
|
||||
</p>
|
||||
</LegalSection>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
import { EmailLink } from "@/src/components/EmailLink";
|
||||
import { LegalSection } from "@/src/components/LegalPage";
|
||||
import { OWNER } from "@/src/content/site";
|
||||
import { LOCALE_COOKIE, type Locale } from "@/src/domain/locale";
|
||||
import type { LegalText } from "./types";
|
||||
|
||||
const LINK = "font-bold text-slate-900 underline-offset-4 hover:underline";
|
||||
|
||||
const address = (country: string): string =>
|
||||
`${OWNER.name}, ${OWNER.street}, ${OWNER.postalCode} ${OWNER.locality}, ${country}.`;
|
||||
|
||||
/**
|
||||
* The privacy notice in both languages. It describes what the code actually
|
||||
* processes; keep it in step with the code (the language cookie is set by
|
||||
* LanguageSwitch, the detection happens in proxy.ts).
|
||||
*/
|
||||
export const PRIVACY: Readonly<Record<Locale, LegalText>> = {
|
||||
de: {
|
||||
title: "Datenschutz",
|
||||
description: "Wie mintel.me mit personenbezogenen Daten umgeht.",
|
||||
Body: () => (
|
||||
<>
|
||||
<LegalSection heading="Verantwortlicher">
|
||||
<p>
|
||||
{address("Deutschland")}
|
||||
<br />
|
||||
E-Mail: <EmailLink className={LINK} />
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Kurz gesagt">
|
||||
<p>
|
||||
Diese Website zeigt keine Werbung und bindet keine Inhalte von
|
||||
Dritten ein. Schriften und Bilder werden von dieser Website selbst
|
||||
ausgeliefert. Ein Cookie wird nur gesetzt, wenn Sie die Sprache
|
||||
umschalten (siehe „Sprache“).
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Server-Protokolle">
|
||||
<p>
|
||||
Wenn Sie eine Seite öffnen, verarbeitet der Server technische Daten,
|
||||
die Ihr Browser sendet: Ihre IP-Adresse, den Zeitpunkt, die
|
||||
angeforderte Seite und den Browsertyp. Das ist nötig, um die Website
|
||||
auszuliefern und sicher zu betreiben. Rechtsgrundlage ist mein
|
||||
berechtigtes Interesse am Betrieb der Website (Art. 6 Abs. 1 lit. f
|
||||
DSGVO).
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Sprache">
|
||||
<p>
|
||||
Die Website gibt es auf Deutsch und Englisch. Welche Sprache Sie
|
||||
zuerst sehen, richtet sich nach der Spracheinstellung, die Ihr
|
||||
Browser bei jedem Seitenaufruf mitsendet. Sie wird dafür nicht
|
||||
gespeichert.
|
||||
</p>
|
||||
<p>
|
||||
Wenn Sie die Sprache selbst umschalten, speichert die Website Ihre
|
||||
Wahl in einem Cookie namens „{LOCALE_COOKIE}“, damit Sie nicht
|
||||
wieder zurückgeleitet werden. Es enthält nur das Sprachkürzel („de“
|
||||
oder „en“), gilt ein Jahr und wird für nichts anderes verwendet. Es
|
||||
ist für die von Ihnen gewünschte Funktion erforderlich (§ 25 Abs. 2
|
||||
Nr. 2 TDDDG).
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Besucherstatistik">
|
||||
<p>
|
||||
Ich zähle Seitenaufrufe mit Umami, das ich auf meinem eigenen Server
|
||||
betreibe. Es arbeitet ohne Cookies und erstellt keine Profile
|
||||
einzelner Besucher. Die Daten werden nicht an Dritte weitergegeben.
|
||||
Rechtsgrundlage ist mein berechtigtes Interesse zu verstehen, wie
|
||||
die Website genutzt wird (Art. 6 Abs. 1 lit. f DSGVO).
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Fehlerberichte">
|
||||
<p>
|
||||
Wenn auf dieser Website technisch etwas fehlschlägt, wird ein
|
||||
Fehlerbericht an einen Dienst zur Fehlererfassung gesendet, den ich
|
||||
auf meinem eigenen Server betreibe. Er enthält technische Angaben
|
||||
wie die Seite, den Browser und die Fehlermeldung. Sitzungen werden
|
||||
nicht aufgezeichnet. Rechtsgrundlage ist mein berechtigtes Interesse
|
||||
an einer funktionierenden Website (Art. 6 Abs. 1 lit. f DSGVO).
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Speicherung in Ihrem Browser">
|
||||
<p>
|
||||
Wenn das Laden einer Seite fehlschlägt, speichert die Website einen
|
||||
Zeitstempel im Session Storage Ihres Browsers, damit sie nur einmal
|
||||
neu lädt. Er wird gelöscht, wenn Sie den Tab schließen, und
|
||||
nirgendwohin übertragen.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Wenn Sie mir schreiben">
|
||||
<p>
|
||||
Wenn Sie mir eine E-Mail senden, verarbeite ich Ihre Nachricht und
|
||||
Ihre Adresse, um Ihnen zu antworten und gegebenenfalls einen Vertrag
|
||||
vorzubereiten (Art. 6 Abs. 1 lit. b DSGVO). Ich bewahre die
|
||||
Korrespondenz so lange auf, wie es dafür nötig ist oder das Gesetz
|
||||
es verlangt.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Ihre Rechte">
|
||||
<p>Sie haben das Recht,</p>
|
||||
<ul>
|
||||
<li>
|
||||
Auskunft darüber zu verlangen, welche Daten ich über Sie habe
|
||||
</li>
|
||||
<li>unrichtige Daten berichtigen zu lassen</li>
|
||||
<li>
|
||||
Ihre Daten löschen oder deren Verarbeitung einschränken zu lassen
|
||||
</li>
|
||||
<li>
|
||||
einer Verarbeitung zu widersprechen, die auf berechtigtem
|
||||
Interesse beruht
|
||||
</li>
|
||||
<li>Ihre Daten in einem übertragbaren Format zu erhalten</li>
|
||||
<li>sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren</li>
|
||||
</ul>
|
||||
<p>
|
||||
Um eines dieser Rechte auszuüben, schreiben Sie an{" "}
|
||||
<EmailLink className={LINK} />.
|
||||
</p>
|
||||
</LegalSection>
|
||||
</>
|
||||
),
|
||||
},
|
||||
en: {
|
||||
title: "Privacy",
|
||||
description: "How mintel.me handles personal data.",
|
||||
Body: () => (
|
||||
<>
|
||||
<LegalSection heading="Who is responsible">
|
||||
<p>
|
||||
{address("Germany")}
|
||||
<br />
|
||||
Email: <EmailLink className={LINK} />
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="In short">
|
||||
<p>
|
||||
This website shows no advertising and includes no content from third
|
||||
parties. Fonts and images are served from this website itself. A
|
||||
cookie is only set when you switch the language (see
|
||||
"Language").
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Server logs">
|
||||
<p>
|
||||
When you open a page, the server processes technical data that your
|
||||
browser sends: your IP address, the time, the page requested and the
|
||||
browser type. This is necessary to deliver the website and to keep
|
||||
it secure. The legal basis is my legitimate interest in running the
|
||||
website (Article 6(1)(f) GDPR).
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Language">
|
||||
<p>
|
||||
The website exists in German and English. Which language you see
|
||||
first depends on the language setting your browser sends with every
|
||||
page request. It is not stored for that.
|
||||
</p>
|
||||
<p>
|
||||
If you switch the language yourself, the website stores your choice
|
||||
in a cookie named "{LOCALE_COOKIE}" so that you are not
|
||||
sent back. It contains only the language code ("de" or
|
||||
"en"), lasts one year and is used for nothing else. It is
|
||||
necessary for the function you asked for (section 25(2) no. 2
|
||||
TDDDG).
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Visitor statistics">
|
||||
<p>
|
||||
I count page views with Umami, which I run on my own server. It
|
||||
works without cookies and does not create profiles of individual
|
||||
visitors. The data is not passed on to third parties. The legal
|
||||
basis is my legitimate interest in understanding how the website is
|
||||
used (Article 6(1)(f) GDPR).
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Error reports">
|
||||
<p>
|
||||
If something on this website fails technically, an error report is
|
||||
sent to an error tracking service that I run on my own server. It
|
||||
contains technical details such as the page, the browser and the
|
||||
error message. Sessions are not recorded. The legal basis is my
|
||||
legitimate interest in a working website (Article 6(1)(f) GDPR).
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Storage in your browser">
|
||||
<p>
|
||||
If loading a page fails, the website stores a timestamp in your
|
||||
browser's session storage so that it reloads only once. It is
|
||||
deleted when you close the tab and is not sent anywhere.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="When you write to me">
|
||||
<p>
|
||||
If you send me an email, I process what you write and your address
|
||||
in order to answer you and, if it comes to that, to prepare a
|
||||
contract (Article 6(1)(b) GDPR). I keep the correspondence for as
|
||||
long as it is needed for that purpose or as the law requires.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection heading="Your rights">
|
||||
<p>You have the right to:</p>
|
||||
<ul>
|
||||
<li>ask what data I hold about you</li>
|
||||
<li>have incorrect data corrected</li>
|
||||
<li>have your data deleted or its processing restricted</li>
|
||||
<li>object to processing that is based on legitimate interest</li>
|
||||
<li>receive your data in a portable format</li>
|
||||
<li>complain to a data protection supervisory authority</li>
|
||||
</ul>
|
||||
<p>
|
||||
To use any of these rights, write to <EmailLink className={LINK} />.
|
||||
</p>
|
||||
</LegalSection>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
/** A legal page in one language: its title, its description and its text. */
|
||||
export type LegalText = {
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly Body: React.FC;
|
||||
};
|
||||
@@ -1,209 +0,0 @@
|
||||
/**
|
||||
* The three ways a visitor can work with me, written for the visitor, not for engineers.
|
||||
* Each path answers the same four questions: what you get, how it runs, what it costs, what to do next.
|
||||
*/
|
||||
|
||||
export type PathStep = {
|
||||
readonly title: string;
|
||||
readonly text: string;
|
||||
};
|
||||
|
||||
export type PathPrice = {
|
||||
readonly label: string;
|
||||
readonly amount: string;
|
||||
readonly note: string;
|
||||
};
|
||||
|
||||
export type ServicePath = {
|
||||
readonly id: string;
|
||||
/** The service in neutral words, as search engines list it. */
|
||||
readonly service: string;
|
||||
/** How the visitor would say it. */
|
||||
readonly need: string;
|
||||
readonly forWhom: string;
|
||||
readonly headline: string;
|
||||
readonly intro: string;
|
||||
readonly youGet: readonly string[];
|
||||
readonly steps: readonly PathStep[];
|
||||
/** How this kind of work is priced, in one line, and why. */
|
||||
readonly pricingModel: string;
|
||||
readonly pricingText: string;
|
||||
readonly prices: readonly PathPrice[];
|
||||
readonly smallPrint: string;
|
||||
readonly cta: string;
|
||||
};
|
||||
|
||||
export const SERVICE_PATHS: readonly ServicePath[] = [
|
||||
{
|
||||
id: "developer",
|
||||
service: "Web app and frontend development",
|
||||
need: "We need a developer for our product",
|
||||
forWhom: "Startups, product teams, agencies, Web3 teams",
|
||||
headline: "An experienced developer who joins your project.",
|
||||
intro:
|
||||
"You have a product and more work than your team can handle. I work on agreed days of the week on what is next on your list: new features, the interface, or the parts nobody gets around to.",
|
||||
youGet: [
|
||||
"Web apps, dashboards, customer portals and internal tools",
|
||||
"Interfaces with attention to design and usability",
|
||||
"Web3 frontends: wallet connection, live data, dashboards",
|
||||
"Small tools and automation for recurring work",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: "A first call, about 20 minutes",
|
||||
text: "You tell me what you are building and where you need help. If I am not the right person for it, I say so.",
|
||||
},
|
||||
{
|
||||
title: "We agree on the days",
|
||||
text: "How many days a week I work for you, and what should come first.",
|
||||
},
|
||||
{
|
||||
title: "I work through the list",
|
||||
text: "Finished parts go to you for review as they are done. I track my hours and you can see what they were spent on.",
|
||||
},
|
||||
{
|
||||
title: "Two weeks notice",
|
||||
text: "The agreement can be ended with two weeks notice to the end of a month.",
|
||||
},
|
||||
],
|
||||
pricingModel: "By effort, for as long as your project runs",
|
||||
pricingText:
|
||||
"I work on your project on agreed days of the week and bill the hours I actually worked. There is no package to buy and no minimum term.",
|
||||
prices: [
|
||||
{ label: "Per hour", amount: "100 EUR", note: "net" },
|
||||
{ label: "Per day", amount: "800 EUR", note: "8 hours, net" },
|
||||
],
|
||||
smallPrint:
|
||||
"One invoice per month, with the tracked hours. Either side can end it with two weeks notice to the end of a month.",
|
||||
cta: "Get in touch",
|
||||
},
|
||||
{
|
||||
id: "rescue",
|
||||
service: "Code review and stabilisation of web apps",
|
||||
need: "Our app needs to become reliable",
|
||||
forWhom: "Teams whose app was built quickly and is now hard to change",
|
||||
headline: "A week to look at your app and fix what is most urgent.",
|
||||
intro:
|
||||
"The app works, but bugs come back and small changes take longer than they should. I take a week to go through the code, fix what I can in that time and write down what I would do next.",
|
||||
youGet: [
|
||||
"An honest assessment: what is solid and what is fragile",
|
||||
"Automated tests for the most important parts",
|
||||
"The most urgent problems fixed, as far as a week allows",
|
||||
"A written list of next steps, in plain language",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: "A first call, about 20 minutes",
|
||||
text: "You show me the app and tell me where it causes trouble.",
|
||||
},
|
||||
{
|
||||
title: "One week of work",
|
||||
text: "I read the code, add tests where they matter most and fix the most urgent problems.",
|
||||
},
|
||||
{
|
||||
title: "A written summary",
|
||||
text: "What I found, what I changed and what I recommend. Written so that you can follow it without being a developer.",
|
||||
},
|
||||
{
|
||||
title: "You decide how to continue",
|
||||
text: "With me, with your own team, or not at all.",
|
||||
},
|
||||
],
|
||||
pricingModel: "A fixed week at a fixed price",
|
||||
pricingText:
|
||||
"The scope is one week of my time. You know the cost before I start.",
|
||||
prices: [{ label: "One week, 35 hours", amount: "3,500 EUR", note: "net" }],
|
||||
smallPrint:
|
||||
"A week does not fix everything. If I see in the first call that it will not be enough to be useful, I tell you.",
|
||||
cta: "Get in touch",
|
||||
},
|
||||
{
|
||||
id: "automation",
|
||||
service: "Process automation and system integration",
|
||||
need: "We want to automate a process",
|
||||
forWhom:
|
||||
"Companies where people copy data between tools or repeat the same steps",
|
||||
headline:
|
||||
"Systems that pass work on to each other, so people do not have to.",
|
||||
intro:
|
||||
"Someone exports a list here, types it in there and sends an email about it. I connect the tools you already use, so that recurring steps run on their own and your people can do the work that needs them.",
|
||||
youGet: [
|
||||
"Connections between your systems through their interfaces (APIs)",
|
||||
"Recurring workflows that run without manual steps",
|
||||
"Where it helps, steps handled with AI: sorting, summarising, reading documents",
|
||||
"A short description of how it works, so it can be maintained",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: "A first call, about 20 minutes",
|
||||
text: "You describe the process and which tools are involved.",
|
||||
},
|
||||
{
|
||||
title: "I look at how it is done today",
|
||||
text: "Step by step, with the people who do it. Often a part can be automated, not everything.",
|
||||
},
|
||||
{
|
||||
title: "I connect the systems",
|
||||
text: "One step at a time. You test it with real cases before it replaces the manual work.",
|
||||
},
|
||||
{
|
||||
title: "It runs on its own",
|
||||
text: "You get a note on what it does and what to check when one of the tools changes.",
|
||||
},
|
||||
],
|
||||
pricingModel: "An estimate first, then a budget",
|
||||
pricingText:
|
||||
"Once I have seen the process I estimate the hours. That estimate becomes the budget. I bill the hours I actually needed and tell you early if it will not be enough.",
|
||||
prices: [{ label: "Per hour", amount: "100 EUR", note: "net" }],
|
||||
smallPrint:
|
||||
"How long it takes depends on the process, so there is no price before I have seen it. Fees for the tools themselves are paid by you.",
|
||||
cta: "Get in touch",
|
||||
},
|
||||
{
|
||||
id: "website",
|
||||
service: "Website design and development",
|
||||
need: "I just need a website",
|
||||
forWhom: "Companies and self-employed people who want it taken care of",
|
||||
headline: "A website, built and looked after for you.",
|
||||
intro:
|
||||
"You do not want to learn a website builder or deal with hosting. You tell me what your business does and what the site should achieve. I design it, build it and put it online.",
|
||||
youGet: [
|
||||
"A design made for your business, not a template",
|
||||
"Works on phone, tablet and desktop",
|
||||
"Hosting taken care of, no server for you to manage",
|
||||
"Changes by email: you write what you need, I make the change",
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
title: "A first call, about 20 minutes",
|
||||
text: "You tell me about your business and what the website is for.",
|
||||
},
|
||||
{
|
||||
title: "A design to look at",
|
||||
text: "You get a first version to click through. We adjust it until it fits.",
|
||||
},
|
||||
{
|
||||
title: "I build it and put it online",
|
||||
text: "With your texts and images and a contact form. You approve it before it goes live.",
|
||||
},
|
||||
{
|
||||
title: "Changes afterwards",
|
||||
text: "A new photo or a changed price: you send me an email.",
|
||||
},
|
||||
],
|
||||
pricingModel: "An estimate first, then a budget",
|
||||
pricingText:
|
||||
"After the first call you get an estimate. It becomes your budget: I bill the hours actually used. The estimate usually holds, and if it will not, I tell you before the budget is used up.",
|
||||
prices: [
|
||||
{
|
||||
label: "A typical company website",
|
||||
amount: "from 3,800 EUR",
|
||||
note: "one-off, net",
|
||||
},
|
||||
{ label: "Anything extra, per hour", amount: "100 EUR", note: "net" },
|
||||
],
|
||||
smallPrint:
|
||||
"Hosting and small changes afterwards cost a monthly fee that we agree on before we start. I do not manage email inboxes; they stay with your provider.",
|
||||
cta: "Get in touch",
|
||||
},
|
||||
];
|
||||
@@ -1,17 +1,12 @@
|
||||
import { parseEmailParts, type EmailParts } from "@/src/domain/email";
|
||||
import { indexingFor } from "@/src/domain/indexing";
|
||||
|
||||
// What is the same in every language. Titles, descriptions and all other texts
|
||||
// live in en.ts and de.ts.
|
||||
|
||||
export const SITE_NAME = "Marc Mintel";
|
||||
export const SITE_URL = "https://mintel.me";
|
||||
|
||||
/** One wording for the browser title, search results and link previews. */
|
||||
export const SITE_TITLE =
|
||||
"Freelance web developer for web apps & websites | Marc Mintel";
|
||||
export const SITE_DESCRIPTION =
|
||||
"Freelance web developer with 15 years of experience. I build web apps, interfaces and websites with React, Next.js and TypeScript, and automate processes.";
|
||||
export const SITE_LANGUAGE = "en";
|
||||
export const JOB_TITLE = "Freelance web developer";
|
||||
|
||||
/**
|
||||
* Public profiles of the owner (LinkedIn, GitHub, ...). Search engines use them to
|
||||
* connect this site with the same person elsewhere. Only add profiles the owner confirmed.
|
||||
@@ -39,7 +34,8 @@ export const CONTACT_EMAIL_PARTS: EmailParts = parsedEmail.value;
|
||||
export const OWNER = {
|
||||
name: "Marc Mintel",
|
||||
street: "Georg-Meistermann-Straße 7",
|
||||
city: "54586 Schüller",
|
||||
country: "Germany",
|
||||
postalCode: "54586",
|
||||
locality: "Schüller",
|
||||
countryCode: "DE",
|
||||
vatId: "DE367588065",
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import type { MailDraft } from "@/src/domain/email";
|
||||
import type { Locale } from "@/src/domain/locale";
|
||||
|
||||
/**
|
||||
* Everything a visitor can read, as plain data. There is one `Content` per
|
||||
* language (see en.ts and de.ts); components take their texts from it and hold
|
||||
* none of their own. Blanks such as "{name}" are filled with `fill` from
|
||||
* src/domain/template.ts.
|
||||
*/
|
||||
|
||||
/** A headline in two tones: the lead in full ink, the rest muted. */
|
||||
export type SplitTitle = {
|
||||
readonly lead: string;
|
||||
readonly rest: string;
|
||||
};
|
||||
|
||||
export type HeadlineLine = {
|
||||
readonly text: string;
|
||||
readonly tone: "solid" | "bits";
|
||||
};
|
||||
|
||||
export type TitledList = {
|
||||
readonly title: string;
|
||||
readonly items: readonly string[];
|
||||
};
|
||||
|
||||
export type TitledText = {
|
||||
readonly title: string;
|
||||
readonly text: string;
|
||||
};
|
||||
|
||||
export type CareerStation = {
|
||||
readonly years: string;
|
||||
readonly role: string;
|
||||
readonly place: string;
|
||||
};
|
||||
|
||||
export type PathPrice = {
|
||||
readonly label: string;
|
||||
readonly amount: string;
|
||||
readonly note: string;
|
||||
};
|
||||
|
||||
export type ServicePathId = "developer" | "rescue" | "automation" | "website";
|
||||
|
||||
export type ServicePath = {
|
||||
readonly id: ServicePathId;
|
||||
/** The service in neutral words, as search engines list it. */
|
||||
readonly service: string;
|
||||
/** How the visitor would say it. */
|
||||
readonly need: string;
|
||||
readonly forWhom: string;
|
||||
readonly headline: string;
|
||||
readonly intro: string;
|
||||
readonly youGet: readonly string[];
|
||||
readonly steps: readonly TitledText[];
|
||||
/** How this kind of work is priced, in one line, and why. */
|
||||
readonly pricingModel: string;
|
||||
readonly pricingText: string;
|
||||
readonly prices: readonly PathPrice[];
|
||||
readonly smallPrint: string;
|
||||
readonly cta: string;
|
||||
};
|
||||
|
||||
/** The short labels inside the drawings that explain the steps of a path. */
|
||||
export type SceneLabels = {
|
||||
readonly you: string;
|
||||
readonly me: string;
|
||||
readonly talkLength: string;
|
||||
readonly days: readonly string[];
|
||||
readonly daysNote: string;
|
||||
readonly review: string;
|
||||
/** Has the blank {n}. */
|
||||
readonly week: string;
|
||||
readonly monthEnd: string;
|
||||
readonly notice: string;
|
||||
readonly repair: string;
|
||||
readonly summaryParts: readonly string[];
|
||||
readonly summaryNote: string;
|
||||
readonly options: readonly string[];
|
||||
readonly online: string;
|
||||
readonly exampleDomain: string;
|
||||
readonly firstVersion: string;
|
||||
readonly approve: string;
|
||||
readonly changeByEmail: string;
|
||||
readonly tools: readonly string[];
|
||||
readonly byHand: string;
|
||||
readonly wiring: string;
|
||||
readonly running: string;
|
||||
};
|
||||
|
||||
export type NodeKind = "trigger" | "rule" | "ai" | "action" | "human";
|
||||
|
||||
export type FlowNodeCopy = {
|
||||
readonly system: string;
|
||||
readonly label: string;
|
||||
/** What appears in the log when a run reaches this node. */
|
||||
readonly log: string;
|
||||
};
|
||||
|
||||
export type WorkflowCopy = {
|
||||
readonly name: string;
|
||||
readonly summary: string;
|
||||
readonly byHand: string;
|
||||
readonly automated: string;
|
||||
/** Texts of the nodes, by node id (see src/content/automation.ts). */
|
||||
readonly nodes: Readonly<Record<string, FlowNodeCopy>>;
|
||||
};
|
||||
|
||||
export type PipelineScenarioCopy = {
|
||||
readonly label: string;
|
||||
readonly testName: string;
|
||||
readonly expected: string;
|
||||
readonly received: string;
|
||||
};
|
||||
|
||||
export type WorkCopy = {
|
||||
readonly client: string;
|
||||
readonly text: string;
|
||||
readonly did: readonly string[];
|
||||
readonly tech: readonly string[];
|
||||
};
|
||||
|
||||
export type FaqItem = {
|
||||
readonly question: string;
|
||||
readonly answer: string;
|
||||
};
|
||||
|
||||
export type ContactTopic = {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly draft: MailDraft;
|
||||
};
|
||||
|
||||
export type Content = {
|
||||
readonly site: {
|
||||
/** One wording for the browser title, search results and link previews. */
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly jobTitle: string;
|
||||
/** Open Graph form of the language, such as en_US. */
|
||||
readonly ogLocale: string;
|
||||
readonly shareImageAlt: string;
|
||||
readonly knowsAbout: readonly string[];
|
||||
readonly catalogName: string;
|
||||
};
|
||||
readonly nav: {
|
||||
readonly contact: string;
|
||||
readonly contactLong: string;
|
||||
readonly logoLabel: string;
|
||||
readonly imprint: string;
|
||||
readonly privacy: string;
|
||||
readonly backToTop: string;
|
||||
readonly language: string;
|
||||
/** Each language in its own words, whatever the page language is. */
|
||||
readonly languageNames: Readonly<Record<Locale, string>>;
|
||||
};
|
||||
readonly hero: {
|
||||
readonly headline: readonly HeadlineLine[];
|
||||
readonly lead: string;
|
||||
readonly leadStrong: string;
|
||||
readonly primaryCta: string;
|
||||
readonly secondaryCta: string;
|
||||
readonly trust: readonly string[];
|
||||
readonly builtAndRunning: string;
|
||||
};
|
||||
readonly hello: {
|
||||
readonly label: string;
|
||||
readonly title: string;
|
||||
readonly lead: string;
|
||||
readonly paragraphs: readonly string[];
|
||||
readonly portraitAlt: string;
|
||||
readonly careerLabel: string;
|
||||
readonly career: readonly CareerStation[];
|
||||
};
|
||||
readonly services: {
|
||||
readonly label: string;
|
||||
readonly title: SplitTitle;
|
||||
readonly tablistLabel: string;
|
||||
readonly youGet: string;
|
||||
readonly howItRuns: string;
|
||||
readonly whatItCosts: string;
|
||||
readonly paths: readonly ServicePath[];
|
||||
readonly scenes: SceneLabels;
|
||||
};
|
||||
readonly automation: {
|
||||
readonly label: string;
|
||||
readonly title: SplitTitle;
|
||||
readonly intro: string;
|
||||
readonly examplesLabel: string;
|
||||
readonly running: string;
|
||||
readonly runComplete: string;
|
||||
readonly log: string;
|
||||
readonly byHand: string;
|
||||
readonly automated: string;
|
||||
readonly kinds: Readonly<Record<NodeKind, string>>;
|
||||
/** Texts of the example workflows, by workflow id. */
|
||||
readonly workflows: Readonly<Record<string, WorkflowCopy>>;
|
||||
readonly notes: readonly TitledList[];
|
||||
};
|
||||
readonly technology: {
|
||||
readonly label: string;
|
||||
readonly title: SplitTitle;
|
||||
/** A good fit, also possible, not my field: in this order. */
|
||||
readonly fit: readonly TitledList[];
|
||||
readonly techLabel: string;
|
||||
readonly tech: readonly {
|
||||
readonly area: string;
|
||||
readonly items: readonly string[];
|
||||
}[];
|
||||
readonly pipelineTitle: string;
|
||||
readonly pipelineIntro: string;
|
||||
readonly pipeline: {
|
||||
readonly simulate: string;
|
||||
/** Texts of the scenarios, by scenario id (see PipelineRun). */
|
||||
readonly scenarios: Readonly<Record<string, PipelineScenarioCopy>>;
|
||||
readonly stages: readonly string[];
|
||||
/** Three explanations; the first has the blank {area}. */
|
||||
readonly explanations: readonly TitledText[];
|
||||
readonly runAgain: string;
|
||||
readonly run: string;
|
||||
readonly fixPushed: string;
|
||||
readonly lint: string;
|
||||
readonly types: string;
|
||||
readonly tests: string;
|
||||
readonly expected: string;
|
||||
readonly received: string;
|
||||
/** Has the blank {file}. */
|
||||
readonly runningFile: string;
|
||||
readonly summaryLabel: string;
|
||||
/** Has the blank {n}. */
|
||||
readonly failed: string;
|
||||
/** Has the blank {n}. */
|
||||
readonly passed: string;
|
||||
/** Has the blank {total}. */
|
||||
readonly of: string;
|
||||
readonly pending: string;
|
||||
readonly blocked: string;
|
||||
readonly released: string;
|
||||
readonly footnote: string;
|
||||
};
|
||||
};
|
||||
readonly working: {
|
||||
readonly label: string;
|
||||
readonly title: SplitTitle;
|
||||
readonly principles: readonly TitledText[];
|
||||
readonly diagram: {
|
||||
readonly description: string;
|
||||
/** The hands a request passes through, starting with the client. */
|
||||
readonly chain: readonly string[];
|
||||
readonly you: string;
|
||||
readonly me: string;
|
||||
readonly often: string;
|
||||
readonly withMe: string;
|
||||
readonly oftenLong: string;
|
||||
readonly withMeLong: string;
|
||||
};
|
||||
};
|
||||
readonly work: {
|
||||
readonly label: string;
|
||||
readonly title: SplitTitle;
|
||||
/** Texts of the shown sites, by their name (see src/content/work.ts). */
|
||||
readonly items: Readonly<Record<string, WorkCopy>>;
|
||||
readonly openLive: string;
|
||||
/** Has the blank {name}. */
|
||||
readonly desktopAlt: string;
|
||||
/** Has the blank {name}. */
|
||||
readonly phoneAlt: string;
|
||||
readonly note: string;
|
||||
};
|
||||
readonly faq: {
|
||||
readonly label: string;
|
||||
readonly title: SplitTitle;
|
||||
readonly items: readonly FaqItem[];
|
||||
};
|
||||
readonly contact: {
|
||||
readonly headline: readonly HeadlineLine[];
|
||||
readonly lead: string;
|
||||
readonly newMessage: string;
|
||||
readonly draft: string;
|
||||
readonly need: string;
|
||||
readonly to: string;
|
||||
readonly subject: string;
|
||||
readonly open: string;
|
||||
readonly copy: string;
|
||||
readonly copied: string;
|
||||
readonly topics: readonly ContactTopic[];
|
||||
};
|
||||
readonly notFound: {
|
||||
readonly kicker: string;
|
||||
readonly title: string;
|
||||
readonly text: string;
|
||||
readonly home: string;
|
||||
};
|
||||
readonly error: {
|
||||
readonly kicker: string;
|
||||
readonly title: string;
|
||||
readonly text: string;
|
||||
readonly retry: string;
|
||||
readonly home: string;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/** The sites shown in the work section. Their texts are in en.ts and de.ts, by name. */
|
||||
export type WorkSite = {
|
||||
readonly name: string;
|
||||
readonly href: string;
|
||||
readonly desktop: {
|
||||
readonly src: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
};
|
||||
readonly mobile: {
|
||||
readonly src: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const WORK_SITES: readonly WorkSite[] = [
|
||||
{
|
||||
name: "klz-cables.com",
|
||||
href: "https://klz-cables.com",
|
||||
desktop: { src: "/work/klz-long.webp", width: 1200, height: 1680 },
|
||||
mobile: { src: "/work/klz-mobile.webp", width: 390, height: 3600 },
|
||||
},
|
||||
{
|
||||
name: "e-tib.com",
|
||||
href: "https://e-tib.com",
|
||||
desktop: { src: "/work/etib-long.webp", width: 1200, height: 4333 },
|
||||
mobile: { src: "/work/etib-mobile.webp", width: 390, height: 3600 },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
LOCALES,
|
||||
LOCALE_COOKIE,
|
||||
localeCookie,
|
||||
negotiateLocale,
|
||||
parseLocale,
|
||||
preferredLocale,
|
||||
} from "./locale";
|
||||
|
||||
describe("the languages of the site", () => {
|
||||
it("are German and English, with German as the default", () => {
|
||||
expect(LOCALES).toEqual(["de", "en"]);
|
||||
expect(DEFAULT_LOCALE).toBe("de");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLocale", () => {
|
||||
it.each(["en", "de"] as const)("accepts %s", (locale) => {
|
||||
expect(parseLocale(locale)).toBe(locale);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["another language", "fr"],
|
||||
["a regional form", "de-DE"],
|
||||
["another case", "DE"],
|
||||
["an empty value", ""],
|
||||
["a missing value", undefined],
|
||||
["null", null],
|
||||
["a value that is not a string", 1],
|
||||
["a property every object has", "toString"],
|
||||
])("rejects %s", (_case, raw) => {
|
||||
expect(parseLocale(raw)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("negotiateLocale", () => {
|
||||
it.each([
|
||||
["German", "de", "de"],
|
||||
["English", "en", "en"],
|
||||
["German of a region", "de-AT", "de"],
|
||||
["English of a region", "en-GB", "en"],
|
||||
["a language tag in capitals", "DE-de", "de"],
|
||||
["a longer tag", "de-Latn-DE", "de"],
|
||||
] as const)("understands %s", (_case, header, expected) => {
|
||||
expect(negotiateLocale(header)).toBe(expected);
|
||||
});
|
||||
|
||||
it("takes the language the browser lists first when no weights are given", () => {
|
||||
expect(negotiateLocale("de-DE,de,en-US,en")).toBe("de");
|
||||
expect(negotiateLocale("en-US,en,de")).toBe("en");
|
||||
});
|
||||
|
||||
it("takes the language with the highest weight, wherever it stands", () => {
|
||||
expect(negotiateLocale("en;q=0.7,de;q=0.9")).toBe("de");
|
||||
expect(negotiateLocale("de;q=0.7,en;q=0.9")).toBe("en");
|
||||
expect(negotiateLocale("de;q=0.8,en")).toBe("en");
|
||||
});
|
||||
|
||||
it("reads weights with several decimals and the full weight written out", () => {
|
||||
expect(negotiateLocale("en;q=0.8,de;q=0.85")).toBe("de");
|
||||
expect(negotiateLocale("en;q=0.9,de;q=1")).toBe("de");
|
||||
expect(negotiateLocale("en;q=0.9,de;q=1.0")).toBe("de");
|
||||
expect(negotiateLocale("en;q=0.9,de;q=1.000")).toBe("de");
|
||||
expect(negotiateLocale("en;q=0.9,de;q= 0.95 ")).toBe("de");
|
||||
});
|
||||
|
||||
it("keeps the earlier language when two weights are equal", () => {
|
||||
expect(negotiateLocale("de;q=0.8,en;q=0.8")).toBe("de");
|
||||
expect(negotiateLocale("en;q=0.8,de;q=0.8")).toBe("en");
|
||||
});
|
||||
|
||||
it("skips languages the site does not have", () => {
|
||||
expect(negotiateLocale("fr-FR,fr;q=0.9,de;q=0.6,en;q=0.5")).toBe("de");
|
||||
expect(negotiateLocale("fr,es;q=0.9,en;q=0.2")).toBe("en");
|
||||
});
|
||||
|
||||
it("tolerates spaces and a typical browser header", () => {
|
||||
expect(negotiateLocale(" de-DE , de;q=0.9 , en-US;q=0.8 , en;q=0.7 ")).toBe(
|
||||
"de",
|
||||
);
|
||||
expect(negotiateLocale("en ; q=0.4, de ; q=0.6")).toBe("de");
|
||||
});
|
||||
|
||||
it("does not offer a language the visitor refuses with weight zero", () => {
|
||||
expect(negotiateLocale("de;q=0,en;q=0.5")).toBe("en");
|
||||
expect(negotiateLocale("de;q=0")).toBeNull();
|
||||
expect(negotiateLocale("de;q=0.0")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores entries whose weight cannot be read", () => {
|
||||
expect(negotiateLocale("de;q=high,en;q=0.1")).toBe("en");
|
||||
expect(negotiateLocale("de;q=1.5,en;q=0.1")).toBe("en");
|
||||
expect(negotiateLocale("de;q=-1,en;q=0.1")).toBe("en");
|
||||
expect(negotiateLocale("de;q=,en;q=0.1")).toBe("en");
|
||||
expect(negotiateLocale("de;q=0.5x")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the weight even when another parameter comes first", () => {
|
||||
expect(negotiateLocale("de;level=1;q=0.2,en;q=0.5")).toBe("en");
|
||||
expect(negotiateLocale("de;level=1,en;q=0.5")).toBe("de");
|
||||
});
|
||||
|
||||
it("reads a weight written in capitals", () => {
|
||||
expect(negotiateLocale("de;Q=0.2,en;Q=0.5")).toBe("en");
|
||||
});
|
||||
|
||||
it("matches the whole first part of a tag only", () => {
|
||||
expect(negotiateLocale("den")).toBeNull();
|
||||
expect(negotiateLocale("x-de")).toBeNull();
|
||||
expect(negotiateLocale("nde-DE")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["no header", undefined],
|
||||
["null", null],
|
||||
["an empty header", ""],
|
||||
["only a wildcard", "*"],
|
||||
["only languages the site does not have", "fr,es;q=0.8"],
|
||||
["only separators", " , ,"],
|
||||
])("has no answer for %s", (_case, header) => {
|
||||
expect(negotiateLocale(header)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("preferredLocale", () => {
|
||||
it("follows the choice the visitor made with the language switch", () => {
|
||||
expect(preferredLocale({ cookie: "en", acceptLanguage: "de" })).toBe("en");
|
||||
expect(preferredLocale({ cookie: "de", acceptLanguage: "en" })).toBe("de");
|
||||
});
|
||||
|
||||
it("follows the browser language when no choice was made", () => {
|
||||
expect(preferredLocale({ cookie: undefined, acceptLanguage: "de" })).toBe(
|
||||
"de",
|
||||
);
|
||||
expect(preferredLocale({ acceptLanguage: "en-US,de;q=0.5" })).toBe("en");
|
||||
});
|
||||
|
||||
it("ignores a stored choice that is not a language of the site", () => {
|
||||
expect(preferredLocale({ cookie: "fr", acceptLanguage: "en" })).toBe("en");
|
||||
expect(preferredLocale({ cookie: "", acceptLanguage: "en" })).toBe("en");
|
||||
});
|
||||
|
||||
it("falls back to German when nothing is known", () => {
|
||||
expect(preferredLocale({})).toBe("de");
|
||||
expect(preferredLocale({ cookie: null, acceptLanguage: null })).toBe("de");
|
||||
expect(preferredLocale({ acceptLanguage: "fr" })).toBe("de");
|
||||
});
|
||||
});
|
||||
|
||||
describe("localeCookie", () => {
|
||||
it("is named lang", () => {
|
||||
expect(LOCALE_COOKIE).toBe("lang");
|
||||
});
|
||||
|
||||
it("stores the choice for a year, for the whole site, and is not sent to other sites", () => {
|
||||
expect(localeCookie("de")).toBe(
|
||||
"lang=de; Path=/; Max-Age=31536000; SameSite=Lax",
|
||||
);
|
||||
expect(localeCookie("en")).toBe(
|
||||
"lang=en; Path=/; Max-Age=31536000; SameSite=Lax",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* The languages of the site, and how the language for a visitor is found: an
|
||||
* explicit choice (stored in a cookie by the language switch) wins over what the
|
||||
* browser asks for in its Accept-Language header.
|
||||
*/
|
||||
export const LOCALES = ["de", "en"] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = "de";
|
||||
|
||||
/** Holds the language the visitor picked with the language switch. */
|
||||
export const LOCALE_COOKIE = "lang";
|
||||
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
|
||||
|
||||
export function parseLocale(raw: unknown): Locale | null {
|
||||
return LOCALES.find((locale) => locale === raw) ?? null;
|
||||
}
|
||||
|
||||
type Weighted = { readonly locale: Locale; readonly weight: number };
|
||||
|
||||
/**
|
||||
* The weight of one header entry: 1 when none is given, null when it cannot be
|
||||
* read or is zero, which means the visitor refuses the language.
|
||||
*/
|
||||
const weightOf = (parameters: readonly string[]): number | null => {
|
||||
const quality = parameters
|
||||
.map((parameter) => parameter.trim().toLowerCase())
|
||||
.find((parameter) => parameter.startsWith("q="));
|
||||
if (quality === undefined) return 1;
|
||||
const weight = Number(quality.slice(2));
|
||||
return weight > 0 && weight <= 1 ? weight : null;
|
||||
};
|
||||
|
||||
const parseEntry = (entry: string): Weighted | null => {
|
||||
const [tag, ...parameters] = entry.split(";");
|
||||
const [primary] = String(tag).trim().toLowerCase().split("-");
|
||||
const locale = parseLocale(primary);
|
||||
const weight = weightOf(parameters);
|
||||
return locale === null || weight === null ? null : { locale, weight };
|
||||
};
|
||||
|
||||
/**
|
||||
* The language of the site that an Accept-Language header asks for most, or null
|
||||
* when it asks for none of them. Regional forms count as their language.
|
||||
*/
|
||||
export function negotiateLocale(
|
||||
acceptLanguage: string | null | undefined,
|
||||
): Locale | null {
|
||||
if (acceptLanguage == null) return null;
|
||||
const best = acceptLanguage
|
||||
.split(",")
|
||||
.map(parseEntry)
|
||||
.reduce<Weighted | null>(
|
||||
(winner, entry) =>
|
||||
entry !== null && (winner === null || entry.weight > winner.weight)
|
||||
? entry
|
||||
: winner,
|
||||
null,
|
||||
);
|
||||
return best === null ? null : best.locale;
|
||||
}
|
||||
|
||||
export function preferredLocale(signals: {
|
||||
readonly cookie?: string | null;
|
||||
readonly acceptLanguage?: string | null;
|
||||
}): Locale {
|
||||
return (
|
||||
parseLocale(signals.cookie) ??
|
||||
negotiateLocale(signals.acceptLanguage) ??
|
||||
DEFAULT_LOCALE
|
||||
);
|
||||
}
|
||||
|
||||
/** The cookie that stores the visitor's choice, as written to document.cookie. */
|
||||
export function localeCookie(locale: Locale): string {
|
||||
return `${LOCALE_COOKIE}=${locale}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
languageAlternates,
|
||||
localeOfPath,
|
||||
pathFor,
|
||||
routeRequest,
|
||||
sitemapEntries,
|
||||
switchPath,
|
||||
} from "./routes";
|
||||
|
||||
describe("pathFor", () => {
|
||||
it("serves German at the root, with German names for the legal pages", () => {
|
||||
expect(pathFor("home", "de")).toBe("/");
|
||||
expect(pathFor("imprint", "de")).toBe("/impressum");
|
||||
expect(pathFor("privacy", "de")).toBe("/datenschutz");
|
||||
});
|
||||
|
||||
it("serves English under /en", () => {
|
||||
expect(pathFor("home", "en")).toBe("/en");
|
||||
expect(pathFor("imprint", "en")).toBe("/en/imprint");
|
||||
expect(pathFor("privacy", "en")).toBe("/en/privacy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("languageAlternates", () => {
|
||||
it("names both languages of a page and German as the fallback", () => {
|
||||
expect(languageAlternates("home")).toEqual({
|
||||
de: "/",
|
||||
en: "/en",
|
||||
"x-default": "/",
|
||||
});
|
||||
expect(languageAlternates("privacy")).toEqual({
|
||||
de: "/datenschutz",
|
||||
en: "/en/privacy",
|
||||
"x-default": "/datenschutz",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("localeOfPath", () => {
|
||||
it.each([
|
||||
["/", "de"],
|
||||
["/impressum", "de"],
|
||||
["/en", "en"],
|
||||
["/en/", "en"],
|
||||
["/en/imprint", "en"],
|
||||
["/en/anything/else", "en"],
|
||||
// Only a whole first segment counts.
|
||||
["/energy", "de"],
|
||||
["/dev/en", "de"],
|
||||
["", "de"],
|
||||
] as const)("%s is %s", (pathname, locale) => {
|
||||
expect(localeOfPath(pathname)).toBe(locale);
|
||||
});
|
||||
});
|
||||
|
||||
describe("switchPath", () => {
|
||||
it("leads to the same page in the other language", () => {
|
||||
expect(switchPath("/", "en")).toBe("/en");
|
||||
expect(switchPath("/en", "de")).toBe("/");
|
||||
expect(switchPath("/impressum", "en")).toBe("/en/imprint");
|
||||
expect(switchPath("/en/imprint", "de")).toBe("/impressum");
|
||||
expect(switchPath("/datenschutz", "en")).toBe("/en/privacy");
|
||||
expect(switchPath("/en/privacy", "de")).toBe("/datenschutz");
|
||||
});
|
||||
|
||||
it("stays on the page when the language is already the requested one", () => {
|
||||
expect(switchPath("/en/imprint", "en")).toBe("/en/imprint");
|
||||
expect(switchPath("/datenschutz", "de")).toBe("/datenschutz");
|
||||
});
|
||||
|
||||
it("ignores a trailing slash", () => {
|
||||
expect(switchPath("/en/", "de")).toBe("/");
|
||||
expect(switchPath("/impressum/", "en")).toBe("/en/imprint");
|
||||
});
|
||||
|
||||
it("leads to the homepage from an address that is not a page", () => {
|
||||
expect(switchPath("/nothing-here", "en")).toBe("/en");
|
||||
expect(switchPath("/en/nothing-here", "de")).toBe("/");
|
||||
// A German page name under /en is not an English page.
|
||||
expect(switchPath("/en/impressum", "de")).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
const german = { cookie: undefined, acceptLanguage: "de-DE,de;q=0.9,en;q=0.8" };
|
||||
const english = { cookie: undefined, acceptLanguage: "en-US,en;q=0.9" };
|
||||
const unknown = { cookie: undefined, acceptLanguage: undefined };
|
||||
|
||||
describe("routeRequest", () => {
|
||||
describe("addresses without a language", () => {
|
||||
it.each([
|
||||
["/", "/de"],
|
||||
["/impressum", "/de/imprint"],
|
||||
["/datenschutz", "/de/privacy"],
|
||||
])("shows %s in German to a German browser", (pathname, to) => {
|
||||
expect(routeRequest({ pathname, ...german })).toEqual({
|
||||
kind: "rewrite",
|
||||
to,
|
||||
negotiated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows German when nothing is known about the visitor, as for a search engine", () => {
|
||||
expect(routeRequest({ pathname: "/", ...unknown })).toEqual({
|
||||
kind: "rewrite",
|
||||
to: "/de",
|
||||
negotiated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/", "/en"],
|
||||
["/impressum", "/en/imprint"],
|
||||
["/datenschutz", "/en/privacy"],
|
||||
])(
|
||||
"sends an English browser from %s to %s, not permanently",
|
||||
(pathname, to) => {
|
||||
expect(routeRequest({ pathname, ...english })).toEqual({
|
||||
kind: "redirect",
|
||||
to,
|
||||
permanent: false,
|
||||
negotiated: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves an English browser on German once German was chosen", () => {
|
||||
expect(routeRequest({ pathname: "/", ...english, cookie: "de" })).toEqual(
|
||||
{ kind: "rewrite", to: "/de", negotiated: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a German browser to English once English was chosen", () => {
|
||||
expect(routeRequest({ pathname: "/", ...german, cookie: "en" })).toEqual({
|
||||
kind: "redirect",
|
||||
to: "/en",
|
||||
permanent: false,
|
||||
negotiated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a trailing slash like the page itself", () => {
|
||||
expect(routeRequest({ pathname: "/impressum/", ...english })).toEqual({
|
||||
kind: "redirect",
|
||||
to: "/en/imprint",
|
||||
permanent: false,
|
||||
negotiated: true,
|
||||
});
|
||||
expect(routeRequest({ pathname: "/impressum/", ...german })).toEqual({
|
||||
kind: "rewrite",
|
||||
to: "/de/imprint",
|
||||
negotiated: true,
|
||||
});
|
||||
expect(routeRequest({ pathname: "/impressum//", ...german })).toEqual({
|
||||
kind: "rewrite",
|
||||
to: "/de/imprint",
|
||||
negotiated: true,
|
||||
});
|
||||
});
|
||||
|
||||
// The legal pages used to live here in English; search engines know those addresses.
|
||||
it.each([
|
||||
["/imprint", "/en/imprint"],
|
||||
["/privacy", "/en/privacy"],
|
||||
["/privacy/", "/en/privacy"],
|
||||
])(
|
||||
"moves the English page name %s to %s for good, whatever the browser says",
|
||||
(pathname, to) => {
|
||||
for (const visitor of [german, english, unknown]) {
|
||||
expect(routeRequest({ pathname, ...visitor })).toEqual({
|
||||
kind: "redirect",
|
||||
to,
|
||||
permanent: true,
|
||||
negotiated: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("shows the German error page for an unknown address, whatever the browser says", () => {
|
||||
expect(routeRequest({ pathname: "/nothing-here", ...english })).toEqual({
|
||||
kind: "rewrite",
|
||||
to: "/de/nothing-here",
|
||||
negotiated: false,
|
||||
});
|
||||
expect(routeRequest({ pathname: "/a/b", ...german })).toEqual({
|
||||
kind: "rewrite",
|
||||
to: "/de/a/b",
|
||||
negotiated: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("English addresses", () => {
|
||||
it.each([
|
||||
["/en", "/en"],
|
||||
["/en/", "/en"],
|
||||
["/en/imprint", "/en/imprint"],
|
||||
["/en/privacy", "/en/privacy"],
|
||||
])("shows %s in English to everyone", (pathname, to) => {
|
||||
for (const visitor of [german, english, { ...english, cookie: "de" }]) {
|
||||
expect(routeRequest({ pathname, ...visitor })).toEqual({
|
||||
kind: "rewrite",
|
||||
to,
|
||||
negotiated: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/en/impressum", "/en/imprint"],
|
||||
["/en/datenschutz", "/en/privacy"],
|
||||
["/en/datenschutz/", "/en/privacy"],
|
||||
])(
|
||||
"moves the German page name %s to the English one for good",
|
||||
(pathname, to) => {
|
||||
expect(routeRequest({ pathname, ...german })).toEqual({
|
||||
kind: "redirect",
|
||||
to,
|
||||
permanent: true,
|
||||
negotiated: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("shows the English error page for an unknown English address", () => {
|
||||
expect(routeRequest({ pathname: "/en/nothing-here", ...german })).toEqual(
|
||||
{ kind: "rewrite", to: "/en/nothing-here", negotiated: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addresses with the internal German prefix", () => {
|
||||
it.each([
|
||||
["/de", "/"],
|
||||
["/de/", "/"],
|
||||
["/de/impressum", "/impressum"],
|
||||
["/de/datenschutz", "/datenschutz"],
|
||||
["/de/imprint", "/impressum"],
|
||||
["/de/privacy", "/datenschutz"],
|
||||
["/de/nothing-here", "/nothing-here"],
|
||||
])(
|
||||
"moves %s to %s for good, so that each page has one address",
|
||||
(pathname, to) => {
|
||||
expect(routeRequest({ pathname, ...english })).toEqual({
|
||||
kind: "redirect",
|
||||
to,
|
||||
permanent: true,
|
||||
negotiated: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("only treats a whole segment as a prefix", () => {
|
||||
expect(routeRequest({ pathname: "/design", ...german })).toEqual({
|
||||
kind: "rewrite",
|
||||
to: "/de/design",
|
||||
negotiated: false,
|
||||
});
|
||||
expect(routeRequest({ pathname: "/energy", ...german })).toEqual({
|
||||
kind: "rewrite",
|
||||
to: "/de/energy",
|
||||
negotiated: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sitemapEntries", () => {
|
||||
it("lists every language of the given pages, each with all its alternates", () => {
|
||||
const languages = {
|
||||
de: "https://example.com/",
|
||||
en: "https://example.com/en",
|
||||
"x-default": "https://example.com/",
|
||||
};
|
||||
expect(sitemapEntries("https://example.com", ["home"])).toEqual([
|
||||
{ url: "https://example.com/", alternates: { languages } },
|
||||
{ url: "https://example.com/en", alternates: { languages } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the order of the pages", () => {
|
||||
expect(
|
||||
sitemapEntries("https://example.com", ["privacy", "home"]).map(
|
||||
(entry) => entry.url,
|
||||
),
|
||||
).toEqual([
|
||||
"https://example.com/datenschutz",
|
||||
"https://example.com/en/privacy",
|
||||
"https://example.com/",
|
||||
"https://example.com/en",
|
||||
]);
|
||||
});
|
||||
|
||||
it("is empty without pages", () => {
|
||||
expect(sitemapEntries("https://example.com", [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { LOCALES, preferredLocale, type Locale } from "./locale";
|
||||
|
||||
/**
|
||||
* The public addresses of the site. German is the main language and lives at the
|
||||
* root, with German names for the legal pages; English lives under /en.
|
||||
* Internally every page is rendered under its language (/de/..., /en/...);
|
||||
* `routeRequest` maps one onto the other.
|
||||
*/
|
||||
export type PageId = "home" | "imprint" | "privacy";
|
||||
|
||||
type LanguageAlternates = Readonly<Record<Locale | "x-default", string>>;
|
||||
|
||||
export type RouteDecision =
|
||||
| {
|
||||
readonly kind: "redirect";
|
||||
readonly to: string;
|
||||
readonly permanent: boolean;
|
||||
/** The answer depends on the visitor's language, so caches must not share it. */
|
||||
readonly negotiated: boolean;
|
||||
}
|
||||
| {
|
||||
readonly kind: "rewrite";
|
||||
readonly to: string;
|
||||
readonly negotiated: boolean;
|
||||
};
|
||||
|
||||
export type SitemapEntry = {
|
||||
readonly url: string;
|
||||
readonly alternates: { readonly languages: LanguageAlternates };
|
||||
};
|
||||
|
||||
type PagePaths = Readonly<Record<PageId, string>>;
|
||||
|
||||
const PAGES: readonly PageId[] = ["home", "imprint", "privacy"];
|
||||
|
||||
/** The path of a page below its language, as the app directory names it. */
|
||||
const INTERNAL: PagePaths = {
|
||||
home: "",
|
||||
imprint: "/imprint",
|
||||
privacy: "/privacy",
|
||||
};
|
||||
|
||||
/** The path of a page below its language, as visitors see it. */
|
||||
const PUBLIC: Readonly<Record<Locale, PagePaths>> = {
|
||||
de: { home: "", imprint: "/impressum", privacy: "/datenschutz" },
|
||||
en: INTERNAL,
|
||||
};
|
||||
|
||||
const PREFIX: Readonly<Record<Locale, string>> = { de: "", en: "/en" };
|
||||
|
||||
const withoutTrailingSlash = (pathname: string): string =>
|
||||
pathname.replace(/\/+$/, "");
|
||||
|
||||
/** What follows a prefix such as "/en", or null when the path is not below it. */
|
||||
const restAfter = (pathname: string, prefix: string): string | null => {
|
||||
const path = withoutTrailingSlash(pathname);
|
||||
if (path === prefix) return "";
|
||||
return path.startsWith(`${prefix}/`) ? path.slice(prefix.length) : null;
|
||||
};
|
||||
|
||||
const pageAt = (rest: string, paths: PagePaths): PageId | null =>
|
||||
PAGES.find((page) => paths[page] === rest) ?? null;
|
||||
|
||||
const redirect = (to: string, permanent: boolean): RouteDecision => ({
|
||||
kind: "redirect",
|
||||
to,
|
||||
permanent,
|
||||
negotiated: !permanent,
|
||||
});
|
||||
|
||||
export function pathFor(page: PageId, locale: Locale): string {
|
||||
return `${PREFIX[locale]}${PUBLIC[locale][page]}` || "/";
|
||||
}
|
||||
|
||||
/** The addresses of one page in every language, for hreflang links. */
|
||||
export function languageAlternates(page: PageId): LanguageAlternates {
|
||||
return {
|
||||
de: pathFor(page, "de"),
|
||||
en: pathFor(page, "en"),
|
||||
"x-default": pathFor(page, "de"),
|
||||
};
|
||||
}
|
||||
|
||||
export function localeOfPath(pathname: string): Locale {
|
||||
return restAfter(pathname, PREFIX.en) === null ? "de" : "en";
|
||||
}
|
||||
|
||||
/** Where the language switch leads from a given address. */
|
||||
export function switchPath(pathname: string, target: Locale): string {
|
||||
const locale = localeOfPath(pathname);
|
||||
const rest = withoutTrailingSlash(pathname).slice(PREFIX[locale].length);
|
||||
return pathFor(pageAt(rest, PUBLIC[locale]) ?? "home", target);
|
||||
}
|
||||
|
||||
/**
|
||||
* What to do with a request. Addresses without a language are German and follow
|
||||
* the visitor's preference; English addresses are shown to everyone; every page
|
||||
* has exactly one public address, so all other forms are moved to it.
|
||||
*/
|
||||
export function routeRequest(request: {
|
||||
readonly pathname: string;
|
||||
readonly cookie?: string | null;
|
||||
readonly acceptLanguage?: string | null;
|
||||
}): RouteDecision {
|
||||
const internalGerman = restAfter(request.pathname, "/de");
|
||||
if (internalGerman !== null) {
|
||||
const page =
|
||||
pageAt(internalGerman, PUBLIC.de) ?? pageAt(internalGerman, INTERNAL);
|
||||
return redirect(page === null ? internalGerman : pathFor(page, "de"), true);
|
||||
}
|
||||
|
||||
const english = restAfter(request.pathname, PREFIX.en);
|
||||
if (english !== null) {
|
||||
const misnamed = pageAt(english, PUBLIC.de);
|
||||
return misnamed === null || misnamed === "home"
|
||||
? { kind: "rewrite", to: `/en${english}`, negotiated: false }
|
||||
: redirect(pathFor(misnamed, "en"), true);
|
||||
}
|
||||
|
||||
const rest = withoutTrailingSlash(request.pathname);
|
||||
const page = pageAt(rest, PUBLIC.de);
|
||||
if (page === null) {
|
||||
const formerEnglish = pageAt(rest, INTERNAL);
|
||||
return formerEnglish === null
|
||||
? { kind: "rewrite", to: `/de${rest}`, negotiated: false }
|
||||
: redirect(pathFor(formerEnglish, "en"), true);
|
||||
}
|
||||
const locale = preferredLocale(request);
|
||||
return locale === "de"
|
||||
? { kind: "rewrite", to: `/de${INTERNAL[page]}`, negotiated: true }
|
||||
: redirect(pathFor(page, locale), false);
|
||||
}
|
||||
|
||||
/** Sitemap entries for the given pages: one per language, each naming all of them. */
|
||||
export function sitemapEntries(
|
||||
siteUrl: string,
|
||||
pages: readonly PageId[],
|
||||
): readonly SitemapEntry[] {
|
||||
return pages.flatMap((page) => {
|
||||
const alternates = languageAlternates(page);
|
||||
const languages: LanguageAlternates = {
|
||||
de: `${siteUrl}${alternates.de}`,
|
||||
en: `${siteUrl}${alternates.en}`,
|
||||
"x-default": `${siteUrl}${alternates["x-default"]}`,
|
||||
};
|
||||
return LOCALES.map((locale) => ({
|
||||
url: languages[locale],
|
||||
alternates: { languages },
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { captureAnchor, restoreAnchor } from "./scroll-anchor";
|
||||
|
||||
// Three sections stacked on a page: 0-1000, 1000-1500, 1500-3500.
|
||||
const english = [
|
||||
{ top: 0, height: 1000 },
|
||||
{ top: 1000, height: 500 },
|
||||
{ top: 1500, height: 2000 },
|
||||
];
|
||||
// The same sections in a language with longer texts.
|
||||
const german = [
|
||||
{ top: 0, height: 1200 },
|
||||
{ top: 1200, height: 800 },
|
||||
{ top: 2000, height: 2400 },
|
||||
];
|
||||
|
||||
describe("captureAnchor", () => {
|
||||
it("names the section the reading position is in and how far into it", () => {
|
||||
expect(captureAnchor(english, 0)).toEqual({ index: 0, ratio: 0 });
|
||||
expect(captureAnchor(english, 250)).toEqual({ index: 0, ratio: 0.25 });
|
||||
expect(captureAnchor(english, 1250)).toEqual({ index: 1, ratio: 0.5 });
|
||||
expect(captureAnchor(english, 3000)).toEqual({ index: 2, ratio: 0.75 });
|
||||
});
|
||||
|
||||
it("counts a section boundary as the start of the next section", () => {
|
||||
expect(captureAnchor(english, 1000)).toEqual({ index: 1, ratio: 0 });
|
||||
expect(captureAnchor(english, 1500)).toEqual({ index: 2, ratio: 0 });
|
||||
});
|
||||
|
||||
it("holds on to the last section below the end of the page", () => {
|
||||
expect(captureAnchor(english, 3500)).toEqual({ index: 2, ratio: 1 });
|
||||
expect(captureAnchor(english, 9000)).toEqual({ index: 2, ratio: 1 });
|
||||
});
|
||||
|
||||
// Above the first section there is only the header: the new page starts at its top too.
|
||||
it("has no anchor above the first section", () => {
|
||||
const offset = [{ top: 80, height: 600 }];
|
||||
expect(captureAnchor(offset, 0)).toBeNull();
|
||||
expect(captureAnchor(offset, 79)).toBeNull();
|
||||
expect(captureAnchor(offset, 80)).toEqual({ index: 0, ratio: 0 });
|
||||
expect(restoreAnchor(captureAnchor(offset, 0), offset)).toBe(0);
|
||||
});
|
||||
|
||||
it("takes the nearest section above when the position falls into a gap", () => {
|
||||
const gapped = [
|
||||
{ top: 0, height: 100 },
|
||||
{ top: 300, height: 100 },
|
||||
];
|
||||
expect(captureAnchor(gapped, 200)).toEqual({ index: 0, ratio: 1 });
|
||||
});
|
||||
|
||||
it("does not divide by the height of an empty section", () => {
|
||||
const empty = [
|
||||
{ top: 0, height: 0 },
|
||||
{ top: 0, height: 0 },
|
||||
];
|
||||
expect(captureAnchor(empty, 50)).toEqual({ index: 1, ratio: 0 });
|
||||
});
|
||||
|
||||
it("has no anchor on a page without sections", () => {
|
||||
expect(captureAnchor([], 120)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreAnchor", () => {
|
||||
it("finds the same place in a page whose sections have other heights", () => {
|
||||
expect(restoreAnchor({ index: 0, ratio: 0.25 }, german)).toBe(300);
|
||||
expect(restoreAnchor({ index: 1, ratio: 0.5 }, german)).toBe(1600);
|
||||
expect(restoreAnchor({ index: 2, ratio: 0.75 }, german)).toBe(3800);
|
||||
});
|
||||
|
||||
it("returns a whole pixel", () => {
|
||||
expect(restoreAnchor({ index: 1, ratio: 1 / 3 }, german)).toBe(1467);
|
||||
});
|
||||
|
||||
it("goes to the top when there is no anchor or the section is gone", () => {
|
||||
expect(restoreAnchor(null, german)).toBe(0);
|
||||
expect(restoreAnchor({ index: 3, ratio: 0.5 }, german)).toBe(0);
|
||||
expect(restoreAnchor({ index: 0, ratio: 0.5 }, [])).toBe(0);
|
||||
});
|
||||
|
||||
it("round-trips within the same page", () => {
|
||||
for (const y of [0, 10, 999, 1000, 1499, 2750]) {
|
||||
expect(restoreAnchor(captureAnchor(english, y), english)).toBe(y);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Keeps the reading position when the page is swapped for its other language.
|
||||
* The same sections have other heights there, so the position is remembered as
|
||||
* "this far into that section" and found again in the new page.
|
||||
*/
|
||||
export type SectionBox = { readonly top: number; readonly height: number };
|
||||
|
||||
export type ScrollAnchor = {
|
||||
/** Which section, counted from the top of the page. */
|
||||
readonly index: number;
|
||||
/** How far into the section: 0 at its top, 1 at its bottom. */
|
||||
readonly ratio: number;
|
||||
};
|
||||
|
||||
export function captureAnchor(
|
||||
sections: readonly SectionBox[],
|
||||
y: number,
|
||||
): ScrollAnchor | null {
|
||||
const index = sections.findLastIndex((section) => section.top <= y);
|
||||
const section = sections[index];
|
||||
if (section === undefined) return null;
|
||||
const { top, height } = section;
|
||||
return { index, ratio: height > 0 ? Math.min(1, (y - top) / height) : 0 };
|
||||
}
|
||||
|
||||
/** The scroll position of the anchor in a page with these sections. */
|
||||
export function restoreAnchor(
|
||||
anchor: ScrollAnchor | null,
|
||||
sections: readonly SectionBox[],
|
||||
): number {
|
||||
const section = anchor === null ? undefined : sections[anchor.index];
|
||||
if (anchor === null || section === undefined) return 0;
|
||||
return Math.round(section.top + anchor.ratio * section.height);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
|
||||
const input: StructuredDataInput = {
|
||||
siteUrl: "https://example.com",
|
||||
pageUrl: "https://example.com/",
|
||||
siteName: "Jane Doe",
|
||||
language: "en",
|
||||
description: "Freelance web developer.",
|
||||
@@ -23,6 +24,7 @@ const input: StructuredDataInput = {
|
||||
locality: "Town",
|
||||
countryCode: "DE",
|
||||
},
|
||||
catalogName: "Services",
|
||||
services: [
|
||||
{ name: "Web app development", description: "I join your project." },
|
||||
{ name: "Website development", description: "A website, built for you." },
|
||||
@@ -128,6 +130,7 @@ describe("buildStructuredData", () => {
|
||||
expect(node(buildStructuredData(input), "FAQPage")).toEqual({
|
||||
"@type": "FAQPage",
|
||||
"@id": "https://example.com/#faq",
|
||||
inLanguage: "en",
|
||||
mainEntity: [
|
||||
{
|
||||
"@type": "Question",
|
||||
@@ -143,6 +146,43 @@ describe("buildStructuredData", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("describes the page in its own language: questions, catalogue and website", () => {
|
||||
const data = buildStructuredData({
|
||||
...input,
|
||||
pageUrl: "https://example.com/de",
|
||||
language: "de",
|
||||
catalogName: "Leistungen",
|
||||
});
|
||||
expect(node(data, "FAQPage")).toMatchObject({
|
||||
"@id": "https://example.com/de#faq",
|
||||
inLanguage: "de",
|
||||
});
|
||||
expect(node(data, "ProfessionalService")).toMatchObject({
|
||||
hasOfferCatalog: { name: "Leistungen" },
|
||||
});
|
||||
expect(node(data, "WebSite")).toMatchObject({ inLanguage: "de" });
|
||||
});
|
||||
|
||||
it("names the same person, business and website on the page of every language", () => {
|
||||
const data = buildStructuredData({
|
||||
...input,
|
||||
pageUrl: "https://example.com/de",
|
||||
language: "de",
|
||||
});
|
||||
expect(node(data, "WebSite")).toMatchObject({
|
||||
"@id": "https://example.com/#website",
|
||||
url: "https://example.com",
|
||||
});
|
||||
expect(node(data, "Person")).toMatchObject({
|
||||
"@id": "https://example.com/#person",
|
||||
url: "https://example.com",
|
||||
});
|
||||
expect(node(data, "ProfessionalService")).toMatchObject({
|
||||
"@id": "https://example.com/#business",
|
||||
url: "https://example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves out the questions node when there are no questions", () => {
|
||||
const data = buildStructuredData({ ...input, faq: [] });
|
||||
expect(data["@graph"].map((entry) => entry["@type"])).toEqual([
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
export type StructuredDataInput = {
|
||||
readonly siteUrl: string;
|
||||
/** The address of the page in this language. */
|
||||
readonly pageUrl: string;
|
||||
readonly siteName: string;
|
||||
readonly language: string;
|
||||
readonly description: string;
|
||||
@@ -22,6 +24,8 @@ export type StructuredDataInput = {
|
||||
readonly locality: string;
|
||||
readonly countryCode: string;
|
||||
};
|
||||
/** The heading of the list of services, in the language of the page. */
|
||||
readonly catalogName: string;
|
||||
readonly services: readonly {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
@@ -87,7 +91,7 @@ export function buildStructuredData(
|
||||
},
|
||||
hasOfferCatalog: {
|
||||
"@type": "OfferCatalog",
|
||||
name: "Services",
|
||||
name: input.catalogName,
|
||||
itemListElement: input.services.map((service) => ({
|
||||
"@type": "Offer",
|
||||
itemOffered: {
|
||||
@@ -105,7 +109,8 @@ export function buildStructuredData(
|
||||
? [
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
"@id": `${siteUrl}/#faq`,
|
||||
"@id": `${input.pageUrl}#faq`,
|
||||
inLanguage: input.language,
|
||||
mainEntity: input.faq.map((item) => ({
|
||||
"@type": "Question",
|
||||
name: item.question,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fill } from "./template";
|
||||
|
||||
describe("fill", () => {
|
||||
it("puts the values into their named blanks", () => {
|
||||
expect(fill("{name} on a phone", { name: "example.com" })).toBe(
|
||||
"example.com on a phone",
|
||||
);
|
||||
expect(fill("{done} of {total}", { done: 3, total: 53 })).toBe("3 of 53");
|
||||
});
|
||||
|
||||
it("fills a blank every time it occurs", () => {
|
||||
expect(fill("{n} + {n}", { n: 2 })).toBe("2 + 2");
|
||||
});
|
||||
|
||||
it("leaves text without blanks as it is", () => {
|
||||
expect(fill("Nothing to fill.", { name: "unused" })).toBe(
|
||||
"Nothing to fill.",
|
||||
);
|
||||
expect(fill("", {})).toBe("");
|
||||
});
|
||||
|
||||
it("keeps a blank that has no value, so the gap stays visible", () => {
|
||||
expect(fill("{name} and {other}", { name: "A" })).toBe("A and {other}");
|
||||
});
|
||||
|
||||
it("does not look up names every object has", () => {
|
||||
expect(fill("{toString} {constructor}", {})).toBe(
|
||||
"{toString} {constructor}",
|
||||
);
|
||||
});
|
||||
|
||||
it("fills in a zero and an empty text", () => {
|
||||
expect(fill("{n} failed{suffix}", { n: 0, suffix: "" })).toBe("0 failed");
|
||||
});
|
||||
|
||||
it("does not fill blanks inside the values again", () => {
|
||||
expect(fill("{a}", { a: "{b}", b: "x" })).toBe("{b}");
|
||||
});
|
||||
|
||||
it("only treats plain names as blanks", () => {
|
||||
expect(fill("{} { name } {na me}", { name: "A", "": "B" })).toBe(
|
||||
"{} { name } {na me}",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Texts with named blanks, such as "{name} on a phone". The page texts are plain
|
||||
* data in two languages, so a sentence with a variable part keeps a blank for it.
|
||||
*/
|
||||
export type TemplateValues = Readonly<Record<string, string | number>>;
|
||||
|
||||
const BLANK = /\{(\w+)\}/g;
|
||||
|
||||
export function fill(template: string, values: TemplateValues): string {
|
||||
return template.replace(BLANK, (blank, name: string) =>
|
||||
Object.hasOwn(values, name) ? String(values[name]) : blank,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user