Compare commits

...
6 Commits
Author SHA1 Message Date
mmintelandClaude Opus 5.5 83616433e4 ci: smoke test also checks the freelance page
Build & Deploy / 🔔 Notify (push) Successful in 2s
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m58s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🩺 Smoke Test (push) Successful in 4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:48:57 +02:00
mmintelandClaude Opus 5.5 c70833a725 feat: freelance page, a story on the homepage, and design fixes
The homepage was too long and read as a list of sections. The technical
ones (project fit, tools, how a change is secured) move to a page of their
own, /freelance and /en/freelance, linked from the header, the footer and a
pointer on the homepage. The rest is reordered into one story (who, what you
need, examples, how working together goes, results, questions, contact) and
each section opens with a sentence that leads over from the one before.

routes.ts: a page may now have the same name in both languages. Before,
/en/<german name> was always moved to the English name, which for such a
page would have redirected the address onto itself.

Design: list titles such as "Passt gut" are real headings instead of small
grey labels. The automation examples, their description and the run are one
box, so it is clear they belong together. Digits on light backgrounds are
the blue of the panels instead of black. The square corner marks on the
rounded service cards are gone. The years in the career line are readable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:34:38 +02:00
mmintelandClaude Opus 5.5 11e991e54a fix: German copy says Interfaces, not Oberflächen
"Oberflächen" reads like kitchen worktops, not like user interfaces. The
German texts now use "Interface" throughout, and the German share image is
rendered again because it shows the hero lead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:07:14 +02:00
mmintelandClaude Opus 5.5 c67c13d82f feat: share image in the look of the site's dark sections
Build & Deploy / 🔔 Notify (push) Successful in 2s
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m48s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🩺 Smoke Test (push) Successful in 3s
The link preview was a plain white screenshot of the hero. It is still
rendered from the real hero, so it cannot drift from the site, but now as
light type on the dark surface with a lit frame and binary digits coming
off its edge. The digits are seeded, so every run gives the same picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:53:01 +02:00
mmintelandClaude Opus 5.5 ca0eddfde8 feat: a shorter page on phones
On a phone the homepage was about 29 screens long; it is now about 22.
The desktop layout is unchanged to the pixel.

Lists of equal items (automation notes, project fit, principles, reasons for
a web presentation, by hand vs. automated) are swiped through side by side
(SwipeRow) instead of stacked. The four service cards form a compact 2x2
grid, step lists spell out only the current step, the workflow legend and
the per-project task lists are left out, and sections use less padding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:50:26 +02:00
mmintelandClaude Opus 5.5 5b037d7e0c ci: smoke test checks the addresses of both languages
Since the German version the legal pages live at /impressum and /datenschutz
and under /en/imprint and /en/privacy. The smoke test still expected
/imprint and /privacy to answer 200 and /impressum to redirect, so it has
been red on every deploy while the site was fine. A check that is always red
no longer shows a real outage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:50:26 +02:00
25 changed files with 599 additions and 225 deletions
+3 -3
View File
@@ -477,8 +477,8 @@ jobs:
run: |
BASE_URL="${{ needs.prepare.outputs.next_public_url }}"
# The legal pages must be reachable.
for PAGE in /imprint /privacy; do
# The freelance page and the legal pages must be reachable in both languages.
for PAGE in /freelance /en/freelance /impressum /datenschutz /en/imprint /en/privacy; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$PAGE")
if [ "$STATUS" != "200" ]; then
echo "❌ $PAGE failed! (Status: $STATUS)"
@@ -488,7 +488,7 @@ jobs:
done
# URLs of the previous site must redirect instead of returning 404.
for OLD in /blog /contact /websites /case-studies/klz-cables /impressum; do
for OLD in /blog /contact /websites /case-studies/klz-cables; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$OLD")
if [ "$STATUS" != "308" ]; then
echo "❌ $OLD does not redirect! (Status: $STATUS)"
+13 -7
View File
@@ -25,9 +25,14 @@ 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 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 `/`
- Two content pages. The homepage tells one story from top to bottom (who, what you need, examples, how
working together goes, results, questions, contact); each section opens with a `bridge` sentence from
`story` in the content files that leads over from the one before. The technical sections (project fit,
tools, how a change is secured) live on `/freelance` (`/en/freelance`), for people who develop themselves
or lead a team. Do not move technical detail back onto the homepage.
- The header has the link to the freelance page, 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
@@ -39,8 +44,8 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m
## 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
- German lives at the root (`/`, `/freelance`, `/impressum`, `/datenschutz`), English under `/en`
(`/en/freelance`, `/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.
@@ -54,8 +59,9 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m
- 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`, `BitCurtain` (the language switch transition). Reuse them
instead of inventing new effects.
`BinaryTicker`, `ScrollBits`, `BitBurst`, `BitCurtain` (the language switch transition), `BitAura` (a lit
edge that sheds digits; glow only on dark surfaces, on light ones just a few blue digits), `BitSeam`,
`BitGlow`. 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`).
+141
View File
@@ -0,0 +1,141 @@
import type { Metadata } from "next";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { Block } from "@/src/components/home/Block";
import { ClosingCta } from "@/src/components/home/ClosingCta";
import { PipelineRun } from "@/src/components/illustrations/PipelineRun";
import { Reveal } from "@/src/components/Reveal";
import { SwipeRow } from "@/src/components/SwipeRow";
import { contentFor } from "@/src/content";
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 { freelance } = contentFor(locale);
return {
title: { absolute: freelance.metaTitle },
description: freelance.metaDescription,
alternates: {
canonical: pathFor("freelance", locale),
languages: languageAlternates("freelance"),
},
};
}
const MUTED = "text-slate-400";
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
const LIST_TITLE = "m-0 font-sans text-xl font-bold tracking-tight md:text-2xl";
/**
* The page for people who develop themselves or lead a team: which projects
* fit, the tools, and how a change is secured before it goes live.
*/
export default async function FreelancePage({ params }: Props) {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const content = contentFor(locale);
const { freelance, technology } = content;
return (
<div className="relative flex flex-col bg-white">
<section className="bg-white">
<div className="mx-auto w-full max-w-[1400px] px-5 pb-4 pt-14 md:px-12 md:pb-10 md:pt-28">
<p className="m-0 font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500">
{freelance.label}
</p>
<h1 className="m-0 mt-8 max-w-6xl font-sans text-4xl font-bold leading-[0.95] tracking-tighter text-slate-900 md:text-7xl lg:text-[6.5rem]">
{freelance.title.lead}{" "}
<span className={MUTED}>{freelance.title.rest}</span>
</h1>
<p className={`${BODY} mt-10 max-w-3xl md:mt-14`}>
{freelance.intro}
</p>
</div>
</section>
<Block
index="01"
label={technology.label}
title={
<>
{technology.title.lead}{" "}
<span className={MUTED}>{technology.title.rest}</span>
</>
}
>
<SwipeRow className="grid grid-cols-1 gap-12 md:grid-cols-3 md:gap-10">
{technology.fit.map((group, i) => (
<Reveal key={group.title} delay={0.1 * i}>
<div
className={`border-t pt-6 ${i === 2 ? "border-slate-300" : "border-slate-900"}`}
>
<h3
className={`${LIST_TITLE} ${i === 2 ? "text-slate-400" : "text-slate-900"}`}
>
{group.title}
</h3>
<ul className="m-0 mt-4 list-none p-0">
{group.items.map((item) => (
<li
key={item}
className={`border-b border-slate-100 py-4 font-sans text-lg md:text-xl ${
i === 2 ? "text-slate-400" : "text-slate-900"
}`}
>
{item}
</li>
))}
</ul>
</div>
</Reveal>
))}
</SwipeRow>
<Reveal>
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-24 md:p-10">
<h3 className={`${LIST_TITLE} text-slate-900`}>
{technology.techLabel}
</h3>
<div className="mt-6 grid grid-cols-1 gap-7 sm:grid-cols-2 md:mt-8 md:gap-10 lg:grid-cols-4">
{technology.tech.map((group) => (
<div key={group.area}>
<p className="m-0 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl">
{group.area}
</p>
<ul className="m-0 mt-4 flex list-none flex-wrap gap-2 p-0">
{group.items.map((item) => (
<li
key={item}
className="rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-slate-700"
>
<DecodeText text={item} trigger="both" />
</li>
))}
</ul>
</div>
))}
</div>
</div>
</Reveal>
</Block>
<Block
index="02"
label={freelance.pipelineLabel}
title={technology.pipelineTitle}
>
<Reveal>
<p className={`${BODY} mb-12 max-w-3xl`}>
{technology.pipelineIntro}
</p>
</Reveal>
<Reveal>
<PipelineRun />
</Reveal>
</Block>
<ClosingCta contact={content.contact} />
</div>
);
}
+51 -84
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { BitAura } from "@/src/components/bits/BitAura";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { Block } from "@/src/components/home/Block";
import { ClosingCta } from "@/src/components/home/ClosingCta";
import { Faq } from "@/src/components/home/Faq";
@@ -12,13 +12,13 @@ import { StickyBook } from "@/src/components/home/StickyBook";
import { AutomationFlow } from "@/src/components/illustrations/AutomationFlow";
import { CareerLine } from "@/src/components/illustrations/CareerLine";
import { ContactDiagram } from "@/src/components/illustrations/ContactDiagram";
import { PipelineRun } from "@/src/components/illustrations/PipelineRun";
import {
PresentationCompare,
ReasonDrawing,
} from "@/src/components/illustrations/WebPresentation";
import { WorkShowcase } from "@/src/components/illustrations/WorkShowcase";
import { Reveal } from "@/src/components/Reveal";
import { SwipeRow } from "@/src/components/SwipeRow";
import { contentFor } from "@/src/content";
import type { SplitTitle } from "@/src/content/types";
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
@@ -41,7 +41,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
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 LIST_TITLE =
"m-0 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl";
const Title: React.FC<{ title: SplitTitle; muted?: string }> = ({
title,
@@ -60,7 +61,8 @@ export default async function LandingPage({ params }: Props) {
services,
automation,
presentations,
technology,
story,
freelance,
working,
work,
faq,
@@ -104,6 +106,7 @@ export default async function LandingPage({ params }: Props) {
<Block
index="02"
label={services.label}
bridge={story.services}
title={<Title title={services.title} />}
>
<Reveal>
@@ -114,6 +117,7 @@ export default async function LandingPage({ params }: Props) {
<Block
index="03"
label={automation.label}
bridge={story.automation}
title={<Title title={automation.title} />}
>
<Reveal>
@@ -122,11 +126,11 @@ export default async function LandingPage({ params }: Props) {
<Reveal>
<AutomationFlow />
</Reveal>
<div className="mt-16 grid grid-cols-1 gap-12 md:mt-24 md:grid-cols-3 md:gap-10">
<SwipeRow className="mt-12 grid grid-cols-1 gap-12 md:mt-24 md:grid-cols-3 md:gap-10">
{automation.notes.map((group, i) => (
<Reveal key={group.title} delay={0.1 * i}>
<div className="border-t border-slate-900 pt-6">
<p className={`${MONO} m-0`}>{group.title}</p>
<h3 className={LIST_TITLE}>{group.title}</h3>
<ul className="m-0 mt-4 list-none p-0">
{group.items.map((item) => (
<li
@@ -140,12 +144,13 @@ export default async function LandingPage({ params }: Props) {
</div>
</Reveal>
))}
</div>
</SwipeRow>
</Block>
<Block
index="04"
label={presentations.label}
bridge={story.presentations}
tone="dark"
title={<Title title={presentations.title} muted="text-slate-500" />}
>
@@ -162,7 +167,7 @@ export default async function LandingPage({ params }: Props) {
</BitAura>
</Reveal>
<div className="mt-16 grid grid-cols-1 gap-x-10 gap-y-14 sm:grid-cols-2 md:mt-28 lg:grid-cols-4">
<SwipeRow className="mt-12 grid grid-cols-1 gap-x-10 gap-y-14 md:mt-28 md:grid-cols-2 lg:grid-cols-4">
{presentations.reasons.map((reason, i) => (
<Reveal key={reason.title} delay={0.08 * i}>
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
@@ -176,14 +181,14 @@ export default async function LandingPage({ params }: Props) {
</p>
</Reveal>
))}
</div>
</SwipeRow>
<div className="mt-16 grid grid-cols-1 gap-12 md:mt-28 md:grid-cols-12 md:gap-12">
<Reveal className="md:col-span-6">
<div className="border-t border-slate-700 pt-6">
<p className="m-0 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400">
<h3 className="m-0 font-sans text-xl font-bold tracking-tight text-white md:text-2xl">
{presentations.goodFor.title}
</p>
</h3>
<ul className="m-0 mt-4 list-none p-0">
{presentations.goodFor.items.map((item) => (
<li
@@ -217,76 +222,8 @@ export default async function LandingPage({ params }: Props) {
<Block
index="05"
label={technology.label}
title={<Title title={technology.title} />}
>
<div className="grid grid-cols-1 gap-12 md:grid-cols-3 md:gap-10">
{technology.fit.map((group, i) => (
<Reveal key={group.title} delay={0.1 * i}>
<div
className={`border-t pt-6 ${i === 2 ? "border-slate-300" : "border-slate-900"}`}
>
<p className={`${MONO} m-0`}>{group.title}</p>
<ul className="m-0 mt-4 list-none p-0">
{group.items.map((item) => (
<li
key={item}
className={`border-b border-slate-100 py-4 font-sans text-lg md:text-xl ${
i === 2 ? "text-slate-400" : "text-slate-900"
}`}
>
{item}
</li>
))}
</ul>
</div>
</Reveal>
))}
</div>
<Reveal>
<div className="mt-16 rounded-3xl bg-slate-50 p-6 md:mt-24 md:p-10">
<p className={`${MONO} m-0`}>{technology.techLabel}</p>
<div className="mt-8 grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-4">
{technology.tech.map((group) => (
<div key={group.area}>
<p className="m-0 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl">
{group.area}
</p>
<ul className="m-0 mt-4 flex list-none flex-wrap gap-2 p-0">
{group.items.map((item) => (
<li
key={item}
className="rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-slate-700"
>
<DecodeText text={item} trigger="both" />
</li>
))}
</ul>
</div>
))}
</div>
</div>
</Reveal>
<div className="mt-16 md:mt-24">
<Reveal>
<h3 className="m-0 font-sans text-3xl font-bold tracking-tighter text-slate-900 md:text-5xl">
{technology.pipelineTitle}
</h3>
<p className={`${BODY} mb-12 mt-6 max-w-3xl`}>
{technology.pipelineIntro}
</p>
</Reveal>
<Reveal>
<PipelineRun />
</Reveal>
</div>
</Block>
<Block
index="06"
label={working.label}
bridge={story.working}
title={<Title title={working.title} />}
>
<Reveal>
@@ -296,7 +233,7 @@ export default async function LandingPage({ params }: Props) {
</div>
</BitAura>
</Reveal>
<div className="grid grid-cols-1 gap-x-12 gap-y-12 md:grid-cols-2 md:gap-y-16">
<SwipeRow className="grid grid-cols-1 gap-x-12 gap-y-12 md:grid-cols-2 md:gap-y-16">
{working.principles.map((item, i) => (
<Reveal key={item.title} delay={0.08 * i}>
<div className="border-t border-slate-900 pt-6">
@@ -307,12 +244,37 @@ export default async function LandingPage({ params }: Props) {
</div>
</Reveal>
))}
</div>
</SwipeRow>
<Reveal>
<Link
href={pathFor("freelance", locale)}
className="group mt-14 flex flex-col gap-5 rounded-3xl bg-slate-50 p-6 no-underline transition-colors duration-300 hover:bg-slate-100 md:mt-24 md:flex-row md:items-center md:justify-between md:p-10"
>
<span className="block max-w-2xl">
<span className="block font-sans text-2xl font-bold tracking-tight text-slate-900 md:text-3xl">
{freelance.teaser.title}
</span>
<span className="mt-3 block font-sans text-base leading-relaxed text-slate-500 md:text-lg">
{freelance.teaser.text}
</span>
</span>
<span className="inline-flex shrink-0 items-center gap-3 self-start rounded-full bg-slate-900 px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-white md:self-auto">
{freelance.teaser.cta}
<span
aria-hidden="true"
className="transition-transform duration-300 group-hover:translate-x-1"
>
→
</span>
</span>
</Link>
</Reveal>
</Block>
<Block
index="07"
index="06"
label={work.label}
bridge={story.work}
tone="dark"
title={<Title title={work.title} muted="text-slate-500" />}
>
@@ -324,7 +286,12 @@ export default async function LandingPage({ params }: Props) {
</Reveal>
</Block>
<Block index="08" label={faq.label} title={<Title title={faq.title} />}>
<Block
index="07"
label={faq.label}
bridge={story.faq}
title={<Title title={faq.title} />}
>
<Reveal>
<Faq items={faq.items} />
</Reveal>
+32 -45
View File
@@ -1018,47 +1018,6 @@ pre:has(code[class*="language-"]) {
}
}
/* Corner marks that snap onto a card on hover or focus. */
.bit-card {
position: relative;
}
.bit-card::before,
.bit-card::after {
content: "";
position: absolute;
width: 14px;
height: 14px;
border: 1.5px solid currentColor;
opacity: 0;
transition:
opacity 250ms ease,
transform 350ms cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: none;
}
.bit-card::before {
top: 12px;
right: 12px;
border-left: 0;
border-bottom: 0;
transform: translate(-6px, 6px);
}
.bit-card::after {
bottom: 12px;
left: 12px;
border-right: 0;
border-top: 0;
transform: translate(6px, -6px);
}
.bit-card:hover::before,
.bit-card:hover::after,
.bit-card:focus-visible::before,
.bit-card:focus-visible::after,
.bit-card[aria-selected="true"]::before,
.bit-card[aria-selected="true"]::after {
opacity: 0.55;
transform: translate(0, 0);
}
/* Light that follows the pointer on a `data-glow` surface (see BitGlow). */
.bit-glow {
position: absolute;
@@ -1127,10 +1086,6 @@ pre:has(code[class*="language-"]) {
.bit-glow {
display: none;
}
.bit-card::before,
.bit-card::after {
transition: none;
}
}
/*
@@ -1352,6 +1307,38 @@ pre:has(code[class*="language-"]) {
}
}
/*
* SwipeRow: on phones equal items stand side by side and are swiped through,
* with the next one peeking in. From tablet width on the slide wrappers
* dissolve and the items sit in the grid the row was given.
*/
@media (max-width: 767px) {
.swipe-row {
display: flex;
gap: 1rem;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
/* Bleed to the screen edges, but start in line with the text. */
margin-inline: -1.25rem;
padding-inline: 1.25rem;
scroll-padding-inline: 1.25rem;
scrollbar-width: none;
}
.swipe-row::-webkit-scrollbar {
display: none;
}
.swipe-slide {
flex: 0 0 82%;
scroll-snap-align: start;
}
}
@media (min-width: 768px) {
.swipe-slide {
display: contents;
}
}
/*
* Native feel: the page should behave like a well-made app on every input type.
*/
+3 -3
View File
@@ -3,13 +3,13 @@ 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
* The homepage and the freelance page, each 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) => ({
return sitemapEntries(SITE_URL, ["home", "freelance"]).map((entry) => ({
url: entry.url,
alternates: { languages: { ...entry.alternates.languages } },
}));
+67 -3
View File
@@ -1,6 +1,8 @@
// 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:
// language, so they always match the site. The hero is shown the way the dark
// sections of the site look: light type on a dark surface, a lit edge, and
// binary digits coming off it. 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://localhost:3000
@@ -37,7 +39,9 @@ for (const shot of SHOTS) {
main > div > section:first-of-type .grid { margin-top: 0 !important; display: block !important; }
main > div > section:first-of-type h1 span:nth-of-type(2) { color: rgba(15, 23, 42, 0.2) !important; }
main > div > section:first-of-type > div.relative { padding-top: 34px !important; }
html, body { overflow: hidden !important; }
html, body { overflow: hidden !important; background: #000 !important; }
/* Dark version of the hero: inverted, with the hues turned back. */
main > div > section:first-of-type { filter: invert(1) hue-rotate(180deg) !important; }
`,
});
// Let the page finish starting up first; it would discard the added labels.
@@ -54,6 +58,66 @@ for (const shot of SHOTS) {
"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);
// Everything below lies over the inverted hero and is not inverted itself.
const W = 1200;
const H = 630;
const layer = (css) => {
const el = document.createElement("div");
el.style.cssText = `position:absolute;left:0;top:0;width:${W}px;height:${H}px;z-index:60;pointer-events:none;${css}`;
document.body.append(el);
return el;
};
document.body.style.position = "relative";
// Black becomes the slate of the site's dark sections; light type stays light.
layer("background:#0f172a;mix-blend-mode:screen");
// Light coming from the lower right corner.
layer(
"mix-blend-mode:screen;background:radial-gradient(58% 62% at 92% 108%, rgba(99,102,241,.85), transparent 70%),radial-gradient(34% 40% at 104% -6%, rgba(129,140,248,.4), transparent 70%)",
);
// A frame whose lower right edge is lit, with a soft glow behind it.
const INSET = 22;
const edge = (blur, width, opacity) => {
const el = layer(
`left:${INSET}px;top:${INSET}px;width:${W - 2 * INSET}px;height:${H - 2 * INSET}px;box-sizing:border-box;border-radius:30px;padding:${width}px;opacity:${opacity};filter:blur(${blur}px);` +
"background:conic-gradient(from 60deg at 50% 50%, transparent 0 8%, rgba(129,140,248,.95) 22%, #fff 30%, rgba(129,140,248,.95) 40%, transparent 56% 100%);" +
"-webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask:linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
);
return el;
};
layer(
`left:${INSET}px;top:${INSET}px;width:${W - 2 * INSET}px;height:${H - 2 * INSET}px;box-sizing:border-box;border-radius:30px;border:1px solid rgba(165,180,252,.16)`,
);
edge(26, 14, 0.9);
edge(8, 5, 0.9);
edge(0, 2, 1);
// Digits coming off the lit edge. Seeded, so the picture is the same on every run.
let seed = 20261004;
const random = () => {
seed = (seed * 1664525 + 1013904223) % 4294967296;
return seed / 4294967296;
};
const digits = layer(
"font:500 15px var(--font-jetbrains-mono), ui-monospace, Menlo, monospace;color:#c7d2fe",
);
for (let i = 0; i < 150; i++) {
// Along the bottom edge (right half) and up the right edge (lower half).
const alongBottom = random() < 0.62;
const spread = Math.pow(random(), 1.8);
const x = alongBottom ? W * 0.42 + random() * (W * 0.58 - INSET - 30) : 0;
// Under the lead text the digits stay close to the edge.
const reach = spread * (alongBottom && x < 820 ? 54 : 120);
const left = alongBottom ? x : W - INSET - 14 - reach;
const y = alongBottom
? H - INSET - 16 - reach
: H * 0.3 + random() * (H * 0.7 - INSET - 30);
const digit = document.createElement("span");
digit.textContent = random() < 0.5 ? "0" : "1";
const big = random() < 0.2;
digit.style.cssText = `position:absolute;left:${left.toFixed(1)}px;top:${y.toFixed(1)}px;opacity:${((1 - spread) * (0.35 + random() * 0.65)).toFixed(2)};font-size:${big ? 19 : 14}px;font-weight:${big ? 700 : 500}`;
digits.append(digit);
}
window.scrollTo(0, 0);
});
await page.waitForTimeout(4500);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 278 KiB

+6
View File
@@ -50,6 +50,12 @@ export const Footer: React.FC = () => {
>
{nav.contactLong}
</Link>
<Link
href={pathFor("freelance", locale)}
className="hover:text-slate-900 transition-colors duration-300 no-underline"
>
{nav.freelance}
</Link>
<Link
href={pathFor("imprint", locale)}
className="hover:text-slate-900 transition-colors duration-300 no-underline"
+10 -2
View File
@@ -10,10 +10,12 @@ import { ScrollBits } from "./bits/ScrollBits";
import { useContent, useLocale } from "./ContentProvider";
import { LanguageSwitch } from "./LanguageSwitch";
/** Header: the logo, the language switch and one action. Everything else lives on the homepage. */
/** Header: the logo, the link to the freelance page, the language switch and one action. */
export const Header: React.FC = () => {
const { nav } = useContent();
const home = pathFor("home", useLocale());
const locale = useLocale();
const home = pathFor("home", locale);
const freelance = pathFor("freelance", locale);
const [isScrolled, setIsScrolled] = React.useState(false);
React.useEffect(() => {
@@ -62,6 +64,12 @@ export const Header: React.FC = () => {
<ScrollBits className="hidden font-mono text-[10px] tracking-[0.3em] text-slate-400 md:block" />
<div className="flex items-center gap-2.5 md:gap-4">
<Link
href={freelance}
className="hidden font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-500 no-underline transition-colors duration-300 hover:text-slate-900 md:block"
>
{nav.freelance}
</Link>
<LanguageSwitch />
<Link
href={`${home}#contact`}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
/**
* SwipeRow: on phones the items stand side by side and are swiped through,
* with the next one peeking in; from tablet width on they fall back into the
* grid given by `className`. It keeps long lists of equal items from
* stretching the page on a small screen. Styles: `swipe-row` in globals.css.
*/
export const SwipeRow: React.FC<{
className?: string;
children: React.ReactNode;
}> = ({ className = "", children }) => (
<div className={`swipe-row ${className}`}>
{React.Children.map(children, (child) => (
<div className="swipe-slide">{child}</div>
))}
</div>
);
+1 -1
View File
@@ -81,7 +81,7 @@ const LIGHT_PALETTE: Palette = {
blend: "source-over",
glow: "15, 23, 42",
core: "15, 23, 42",
digit: "15, 23, 42",
digit: "79, 70, 229",
// No haze on light backgrounds: coloured light only reads on dark. Ink digits only.
gain: 0,
density: 0.6,
+22 -2
View File
@@ -10,6 +10,8 @@ interface BlockProps {
index: string;
label: string;
title: React.ReactNode;
/** One sentence that leads over from the section before (the thread of the page). */
bridge?: string;
id?: string;
/** `dark` turns the section into a dark stage. */
tone?: "light" | "dark";
@@ -24,6 +26,7 @@ export const Block: React.FC<BlockProps> = ({
index,
label,
title,
bridge,
id,
tone = "light",
children,
@@ -49,7 +52,24 @@ export const Block: React.FC<BlockProps> = ({
/>
)}
{dark && <BitSeam />}
<div className="relative mx-auto w-full max-w-[1400px] px-5 py-20 md:px-12 md:py-36">
<div className="relative mx-auto w-full max-w-[1400px] px-5 py-14 md:px-12 md:py-36">
{bridge && (
<Reveal>
<div className="-mt-14 mb-10 md:-mt-36 md:mb-20">
<span
className={`block h-14 w-px md:h-28 ${dark ? "bg-slate-600" : "bg-slate-300"}`}
aria-hidden="true"
/>
<p
className={`m-0 mt-5 max-w-2xl font-sans text-xl leading-snug md:text-3xl ${
dark ? "text-slate-300" : "text-slate-500"
}`}
>
{bridge}
</p>
</div>
</Reveal>
)}
<Reveal>
<div className="mb-8 flex items-center gap-4 md:mb-12">
<span
@@ -78,7 +98,7 @@ export const Block: React.FC<BlockProps> = ({
{title}
</h2>
</BitWipe>
<div className="mt-14 md:mt-24">{children}</div>
<div className="mt-10 md:mt-24">{children}</div>
</div>
</section>
);
+1 -1
View File
@@ -26,7 +26,7 @@ export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
/>
<div className="bit-glow bit-glow-dots" aria-hidden="true" />
<BitSeam />
<div className="relative mx-auto grid w-full max-w-[1400px] grid-cols-1 gap-14 px-5 py-24 md:px-12 md:py-40 lg:grid-cols-12 lg:items-center lg:gap-12">
<div className="relative mx-auto grid w-full max-w-[1400px] grid-cols-1 gap-12 px-5 py-16 md:px-12 md:py-40 lg:grid-cols-12 lg:items-center lg:gap-12">
<Reveal className="lg:col-span-6">
<BitHeadline
as="h2"
+12 -9
View File
@@ -92,7 +92,7 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
setStep(index);
setAuto(false);
}}
className={`relative flex w-full gap-5 border-t py-6 text-left transition-colors duration-300 md:gap-6 ${
className={`relative flex w-full gap-5 border-t py-4 text-left transition-colors duration-300 md:gap-6 md:py-6 ${
selected ? "border-slate-900" : "border-slate-200"
}`}
>
@@ -122,8 +122,11 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
{item.title}
</span>
<span
className={`mt-1.5 block font-sans text-base leading-relaxed transition-colors duration-300 ${
selected ? "text-slate-600" : "text-slate-400"
className={`mt-1.5 font-sans text-base leading-relaxed transition-colors duration-300 ${
// On phones only the current step is spelled out.
selected
? "block text-slate-600"
: "hidden text-slate-400 md:block"
}`}
>
{item.text}
@@ -153,7 +156,7 @@ export const PathPicker: React.FC = () => {
<div
role="tablist"
aria-label={services.tablistLabel}
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4"
className="grid grid-cols-2 gap-2 md:gap-3 lg:grid-cols-4 lg:gap-4"
>
{paths.map((path, index) => {
const selected = path.id === activeId;
@@ -167,7 +170,7 @@ export const PathPicker: React.FC = () => {
aria-controls={`path-panel-${path.id}`}
onClick={() => setActiveId(path.id)}
data-glow
className={`bit-card group flex h-full flex-col justify-between rounded-3xl border p-5 text-left transition-all duration-300 md:p-7 ${
className={`group flex h-full flex-col justify-between rounded-3xl border p-4 text-left transition-all duration-300 md:p-7 ${
selected
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-900 hover:-translate-y-1 hover:border-slate-400"
@@ -177,11 +180,11 @@ export const PathPicker: React.FC = () => {
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-400">
0{index + 1}
</span>
<span className="mt-3 block font-sans text-xl font-bold leading-tight tracking-tight md:mt-10 md:text-2xl">
<span className="mt-3 block font-sans text-base font-bold leading-tight tracking-tight md:mt-10 md:text-2xl">
{path.need}
</span>
<span
className={`mt-2 block font-sans text-sm leading-relaxed md:mt-4 md:text-base ${selected ? "text-slate-300" : "text-slate-500"}`}
className={`mt-2 hidden font-sans text-sm leading-relaxed md:mt-4 md:block md:text-base ${selected ? "text-slate-300" : "text-slate-500"}`}
>
{path.forWhom}
</span>
@@ -199,7 +202,7 @@ export const PathPicker: React.FC = () => {
id={`path-panel-${path.id}`}
aria-labelledby={`path-tab-${path.id}`}
hidden={!selected}
className="mt-12 md:mt-20"
className="mt-8 md:mt-20"
>
<motion.div
key={selected ? "on" : "off"}
@@ -251,7 +254,7 @@ export const PathPicker: React.FC = () => {
{path.pricingText}
</p>
</div>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 md:col-span-6">
<div className="grid grid-cols-2 gap-6 md:col-span-6 md:gap-8">
{path.prices.map((price) => (
<div key={price.label}>
<p className="m-0 font-sans text-base text-slate-500 md:text-lg">
@@ -1,6 +1,7 @@
"use client";
import { BitAura } from "@/src/components/bits/BitAura";
import { SwipeRow } from "@/src/components/SwipeRow";
import * as React from "react";
import { useNarrow } from "./useNarrow";
import { useContent } from "@/src/components/ContentProvider";
@@ -222,40 +223,35 @@ export const AutomationFlow: React.FC = () => {
return (
<div ref={ref}>
<div
role="tablist"
aria-label={automation.examplesLabel}
className="flex flex-wrap gap-2"
>
{workflows.map((item) => {
const selected = item.id === flow.id;
return (
<button
key={item.id}
type="button"
role="tab"
aria-selected={selected}
onClick={() => setFlowId(item.id)}
className={`rounded-full border px-5 py-2.5 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
selected
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-400 hover:text-slate-900"
}`}
>
{item.name}
</button>
);
})}
</div>
<p className="m-0 mt-6 max-w-3xl font-sans text-lg leading-relaxed text-slate-600 md:text-xl">
{flow.summary}
</p>
<BitAura source="left" tone="light" className="mt-8">
{/* The examples, their description and the run are one box: they belong together. */}
<BitAura source="left" tone="light">
<div className="overflow-hidden rounded-3xl bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
<span className={`${MONO} text-slate-400`}>{flow.name}</span>
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-slate-800 px-5 py-4 md:px-7">
<div
role="tablist"
aria-label={automation.examplesLabel}
className="flex flex-wrap gap-2"
>
{workflows.map((item) => {
const selected = item.id === flow.id;
return (
<button
key={item.id}
type="button"
role="tab"
aria-selected={selected}
onClick={() => setFlowId(item.id)}
className={`rounded-full border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
selected
? "border-white bg-white text-slate-900"
: "border-slate-700 text-slate-300 hover:border-slate-400 hover:text-white"
}`}
>
{item.name}
</button>
);
})}
</div>
<span className={`${MONO} flex items-center gap-2 text-slate-500`}>
<span
className={`h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-amber-300 animate-pulse"}`}
@@ -263,6 +259,9 @@ export const AutomationFlow: React.FC = () => {
{finished ? automation.runComplete : automation.running}
</span>
</div>
<p className="m-0 max-w-4xl px-5 pb-2 pt-6 font-sans text-lg leading-relaxed text-slate-200 md:px-7 md:pt-8 md:text-xl">
{flow.summary}
</p>
<div>
<svg
@@ -391,7 +390,7 @@ export const AutomationFlow: React.FC = () => {
</div>
<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">
<div className="px-5 py-5 md:col-span-7 md:border-r md:border-slate-800 md:px-7">
<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]"
@@ -413,7 +412,7 @@ export const AutomationFlow: React.FC = () => {
))}
</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">
<ul className="m-0 hidden list-none flex-col justify-center gap-2 px-5 py-5 md:col-span-5 md:flex md:px-7">
{KINDS.filter((kind) =>
flow.nodes.some((node) => node.kind === kind),
).map((kind) => (
@@ -439,23 +438,28 @@ export const AutomationFlow: React.FC = () => {
))}
</ul>
</div>
<div className="border-t border-slate-800 px-5 py-6 md:px-7 md:py-8">
<SwipeRow className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-10">
<div>
<p className={`${MONO} m-0 text-slate-500`}>
{automation.byHand}
</p>
<p className="m-0 mt-3 font-sans text-base leading-relaxed text-slate-400 md:text-lg">
{flow.byHand}
</p>
</div>
<div>
<p className={`${MONO} m-0 text-emerald-400`}>
{automation.automated}
</p>
<p className="m-0 mt-3 font-sans text-base leading-relaxed text-white md:text-lg">
{flow.automated}
</p>
</div>
</SwipeRow>
</div>
</div>
</BitAura>
<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`}>{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`}>{automation.automated}</p>
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-900 md:text-xl">
{flow.automated}
</p>
</div>
</div>
</div>
);
};
@@ -46,7 +46,7 @@ export const CareerLine: React.FC<{ stations: readonly CareerStation[] }> = ({
}`}
aria-hidden="true"
/>
<span className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400 md:col-span-3 md:pt-1.5">
<span className="font-mono text-sm font-bold uppercase tracking-[0.14em] text-indigo-200 md:col-span-3 md:pt-1.5 md:text-base">
{station.years}
</span>
<span className="md:col-span-9">
@@ -241,7 +241,10 @@ export const PipelineRun: React.FC = () => {
</p>
<p
className={`m-0 mt-2 font-sans text-base leading-relaxed transition-colors duration-300 ${
active ? "text-slate-600" : "text-slate-400"
// On phones only the current step is spelled out.
active
? "block text-slate-600"
: "hidden text-slate-400 md:block"
}`}
>
{item.text}
@@ -203,7 +203,7 @@ export const WorkShowcase: React.FC = () => {
{copy.text}
</p>
<ul className="m-0 mt-8 list-none p-0">
<ul className="m-0 mt-8 hidden list-none p-0 md:block">
{copy.did.map((line) => (
<li
key={line}
+35 -8
View File
@@ -10,14 +10,14 @@ 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.",
"Freelance-Webentwickler mit 15 Jahren Erfahrung. Ich baue Web-Apps, Interfaces 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",
"User Interfaces",
"Websites",
"Prozessautomatisierung",
"Web3-Frontends",
@@ -26,6 +26,7 @@ export const de: Content = {
},
nav: {
contact: "Kontakt",
freelance: "Freelance",
contactLong: GET_IN_TOUCH,
logoLabel: "Marc Mintel, nach oben",
imprint: "Impressum",
@@ -40,7 +41,7 @@ export const de: Content = {
{ text: "& WEBSITES.", tone: "bits" },
],
lead: "Freelance-Webentwickler mit 15 Jahren Erfahrung.",
leadStrong: "Ich baue Web-Apps, Oberflächen und Websites.",
leadStrong: "Ich baue Web-Apps, Interfaces und Websites.",
primaryCta: GET_IN_TOUCH,
secondaryCta: "Was ich mache",
trust: ["15 Jahre im Web", "Remote, EU-Kernzeiten"],
@@ -51,7 +52,7 @@ export const de: Content = {
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.",
"Meine Stärke ist der Teil, den Menschen sehen und benutzen: Design und Interface. Ich kenne mich auch mit dem aus, was dahinter liegt, und kann deshalb eine vollständige Anwendung bauen und nicht nur ihr Interface.",
"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",
@@ -86,10 +87,10 @@ export const de: Content = {
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.",
"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, das Interface oder die Teile, zu denen niemand kommt.",
youGet: [
"Web-Apps, Dashboards, Kundenportale und interne Werkzeuge",
"Oberflächen mit Blick für Design und Bedienbarkeit",
"Interfaces mit Blick für Design und Bedienbarkeit",
"Web3-Frontends: Wallet-Anbindung, Live-Daten, Dashboards",
"Kleine Werkzeuge und Automatisierung für wiederkehrende Arbeit",
],
@@ -497,6 +498,32 @@ export const de: Content = {
},
cta: "Nach einer Präsentation fragen",
},
story: {
services: "So viel zu mir. Jetzt zu Ihnen.",
automation: "Einen dieser Fälle zeige ich genauer.",
presentations: "Und manchmal ist das Richtige gar keine App.",
working: "Egal, was Sie brauchen: So läuft die Zusammenarbeit.",
work: "Und so sieht das Ergebnis aus.",
faq: "Bleiben die Fragen, die fast immer kommen.",
},
freelance: {
metaTitle: "Freelance-Webentwickler für Ihr Team | Marc Mintel",
metaDescription:
"Für welche Projekte ich passe, womit ich arbeite (React, Next.js, TypeScript) und wie ich Änderungen absichere, bevor sie live gehen.",
label: "Freelance",
title: {
lead: "Ein Entwickler für Ihr Team.",
rest: "Die technischen Details.",
},
intro:
"Diese Seite ist für alle, die selbst entwickeln oder ein Team leiten: für welche Projekte ich passe, womit ich arbeite und wie ich Änderungen absichere, bevor sie live gehen.",
pipelineLabel: "Absicherung",
teaser: {
title: "Sie entwickeln selbst oder leiten ein Team?",
text: "Für welche Projekte ich passe, womit ich arbeite und wie ich Änderungen absichere, steht auf einer eigenen Seite.",
cta: "Zu den technischen Details",
},
},
technology: {
label: "Technik",
title: { lead: "Für welche Projekte", rest: "ich der Richtige bin." },
@@ -505,7 +532,7 @@ export const de: Content = {
title: "Passt gut",
items: [
"Web-Apps: Dashboards, Portale, interne Werkzeuge",
"Oberflächen, bei denen Design und Bedienbarkeit zählen",
"Interfaces, bei denen Design und Bedienbarkeit zählen",
"Unternehmenswebsites, auch in mehreren Sprachen",
"Web3-Frontends: Wallets, Live-Daten, Dashboards",
"Automatisierung: Systeme und ihre APIs verbinden",
@@ -531,7 +558,7 @@ export const de: Content = {
techLabel: "Womit ich arbeite",
tech: [
{
area: "Oberfläche",
area: "Interface",
items: ["TypeScript", "React", "Next.js", "Vue.js", "Tailwind CSS"],
},
{
+27
View File
@@ -25,6 +25,7 @@ export const en: Content = {
},
nav: {
contact: GET_IN_TOUCH,
freelance: "Freelance",
contactLong: GET_IN_TOUCH,
logoLabel: "Marc Mintel, back to top",
imprint: "Imprint",
@@ -477,6 +478,32 @@ export const en: Content = {
},
cta: "Ask about a presentation",
},
story: {
services: "That is me. Now to you.",
automation: "One of these cases, shown in more detail.",
presentations: "And sometimes the right thing is not an app at all.",
working: "Whatever you need: this is how working together goes.",
work: "And this is what the result looks like.",
faq: "That leaves the questions that almost always come up.",
},
freelance: {
metaTitle: "Freelance web developer for your team | Marc Mintel",
metaDescription:
"Which projects I am right for, what I work with (React, Next.js, TypeScript) and how I secure changes before they go live.",
label: "Freelance",
title: {
lead: "A developer for your team.",
rest: "The technical details.",
},
intro:
"This page is for people who develop themselves or lead a team: which projects I am right for, what I work with, and how I secure changes before they go live.",
pipelineLabel: "Safeguards",
teaser: {
title: "You develop yourself or lead a team?",
text: "Which projects I am right for, what I work with and how I secure changes is on a page of its own.",
cta: "To the technical details",
},
},
technology: {
label: "Technology",
title: { lead: "Which projects", rest: "I am right for." },
+24
View File
@@ -146,6 +146,7 @@ export type Content = {
};
readonly nav: {
readonly contact: string;
readonly freelance: string;
readonly contactLong: string;
readonly logoLabel: string;
readonly imprint: string;
@@ -220,6 +221,29 @@ export type Content = {
readonly honest: TitledText;
readonly cta: string;
};
/**
* The thread through the homepage: one sentence at the top of each section
* that leads over from the one before, so the page reads as one story.
*/
readonly story: {
readonly services: string;
readonly automation: string;
readonly presentations: string;
readonly working: string;
readonly work: string;
readonly faq: string;
};
/** The page for people who develop themselves or lead a team. */
readonly freelance: {
readonly metaTitle: string;
readonly metaDescription: string;
readonly label: string;
readonly title: SplitTitle;
readonly intro: string;
readonly pipelineLabel: string;
/** The pointer to this page on the homepage. */
readonly teaser: TitledText & { readonly cta: string };
};
readonly technology: {
readonly label: string;
readonly title: SplitTitle;
+62
View File
@@ -296,3 +296,65 @@ describe("sitemapEntries", () => {
expect(sitemapEntries("https://example.com", [])).toEqual([]);
});
});
describe("the freelance page, which has the same name in both languages", () => {
it("lives at /freelance in German and /en/freelance in English", () => {
expect(pathFor("freelance", "de")).toBe("/freelance");
expect(pathFor("freelance", "en")).toBe("/en/freelance");
expect(languageAlternates("freelance")).toEqual({
de: "/freelance",
en: "/en/freelance",
"x-default": "/freelance",
});
});
it("switches between the languages", () => {
expect(switchPath("/freelance", "en")).toBe("/en/freelance");
expect(switchPath("/en/freelance", "de")).toBe("/freelance");
});
it("shows /freelance in German to a German browser", () => {
expect(routeRequest({ pathname: "/freelance", ...german })).toEqual({
kind: "rewrite",
to: "/de/freelance",
negotiated: true,
});
});
it("sends an English browser from /freelance to /en/freelance, not permanently", () => {
expect(routeRequest({ pathname: "/freelance", ...english })).toEqual({
kind: "redirect",
to: "/en/freelance",
permanent: false,
negotiated: true,
});
});
it("shows /en/freelance to everyone instead of redirecting it onto itself", () => {
expect(routeRequest({ pathname: "/en/freelance", ...german })).toEqual({
kind: "rewrite",
to: "/en/freelance",
negotiated: false,
});
});
it("moves the internal address /de/freelance to /freelance", () => {
expect(routeRequest({ pathname: "/de/freelance", ...german })).toEqual({
kind: "redirect",
to: "/freelance",
permanent: true,
negotiated: false,
});
});
it("is listed in the sitemap in both languages", () => {
expect(
sitemapEntries("https://example.com", ["freelance"]).map(
(entry) => entry.url,
),
).toEqual([
"https://example.com/freelance",
"https://example.com/en/freelance",
]);
});
});
+11 -4
View File
@@ -6,7 +6,7 @@ import { LOCALES, preferredLocale, type Locale } from "./locale";
* Internally every page is rendered under its language (/de/..., /en/...);
* `routeRequest` maps one onto the other.
*/
export type PageId = "home" | "imprint" | "privacy";
export type PageId = "home" | "freelance" | "imprint" | "privacy";
type LanguageAlternates = Readonly<Record<Locale | "x-default", string>>;
@@ -31,18 +31,24 @@ export type SitemapEntry = {
type PagePaths = Readonly<Record<PageId, string>>;
const PAGES: readonly PageId[] = ["home", "imprint", "privacy"];
const PAGES: readonly PageId[] = ["home", "freelance", "imprint", "privacy"];
/** The path of a page below its language, as the app directory names it. */
const INTERNAL: PagePaths = {
home: "",
freelance: "/freelance",
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" },
de: {
home: "",
freelance: "/freelance",
imprint: "/impressum",
privacy: "/datenschutz",
},
en: INTERNAL,
};
@@ -111,8 +117,9 @@ export function routeRequest(request: {
const english = restAfter(request.pathname, PREFIX.en);
if (english !== null) {
// A German page name under /en is moved, unless the page has the same name in both languages.
const misnamed = pageAt(english, PUBLIC.de);
return misnamed === null || misnamed === "home"
return misnamed === null || PUBLIC.de[misnamed] === INTERNAL[misnamed]
? { kind: "rewrite", to: `/en${english}`, negotiated: false }
: redirect(pathFor(misnamed, "en"), true);
}