Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83616433e4 | ||
|
|
c70833a725 | ||
|
|
11e991e54a | ||
|
|
c67c13d82f | ||
|
|
ca0eddfde8 | ||
|
|
5b037d7e0c |
@@ -477,8 +477,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
BASE_URL="${{ needs.prepare.outputs.next_public_url }}"
|
BASE_URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||||
|
|
||||||
# The legal pages must be reachable.
|
# The freelance page and the legal pages must be reachable in both languages.
|
||||||
for PAGE in /imprint /privacy; do
|
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")
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$PAGE")
|
||||||
if [ "$STATUS" != "200" ]; then
|
if [ "$STATUS" != "200" ]; then
|
||||||
echo "❌ $PAGE failed! (Status: $STATUS)"
|
echo "❌ $PAGE failed! (Status: $STATUS)"
|
||||||
@@ -488,7 +488,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
# URLs of the previous site must redirect instead of returning 404.
|
# 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")
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$OLD")
|
||||||
if [ "$STATUS" != "308" ]; then
|
if [ "$STATUS" != "308" ]; then
|
||||||
echo "❌ $OLD does not redirect! (Status: $STATUS)"
|
echo "❌ $OLD does not redirect! (Status: $STATUS)"
|
||||||
|
|||||||
@@ -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,
|
- 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.
|
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.
|
- 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
|
- Two content pages. The homepage tells one story from top to bottom (who, what you need, examples, how
|
||||||
jumps to the contact section (`#contact`). Contact is one email address: no calendar integration, no form.
|
working together goes, results, questions, contact); each section opens with a `bridge` sentence from
|
||||||
The only other pages are the imprint and the privacy notice. URLs of the previous site redirect to `/`
|
`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`).
|
(see `next.config.mjs`).
|
||||||
- Title, description and link previews come from `site` in `src/content/de.ts` and `en.ts`.
|
- 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
|
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)
|
## Two languages (German is the main one)
|
||||||
|
|
||||||
- German lives at the root (`/`, `/impressum`, `/datenschutz`), English under `/en` (`/en/imprint`,
|
- German lives at the root (`/`, `/freelance`, `/impressum`, `/datenschutz`), English under `/en`
|
||||||
`/en/privacy`). The app directory renders both under `app/[locale]`; `proxy.ts` maps the public addresses
|
(`/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`.
|
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
|
- 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.
|
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),
|
- 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),
|
`BitWipe` (headline revealed behind a column of digits), `BitReveal` (picture assembling from bits),
|
||||||
`BinaryTicker`, `ScrollBits`, `BitBurst`, `BitCurtain` (the language switch transition). Reuse them
|
`BinaryTicker`, `ScrollBits`, `BitBurst`, `BitCurtain` (the language switch transition), `BitAura` (a lit
|
||||||
instead of inventing new effects.
|
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.
|
- `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 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`).
|
never counts as visible. Observe an unclipped wrapper (see `BitWipe`).
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
import { BitAura } from "@/src/components/bits/BitAura";
|
import { BitAura } from "@/src/components/bits/BitAura";
|
||||||
import { DecodeText } from "@/src/components/bits/DecodeText";
|
|
||||||
import { Block } from "@/src/components/home/Block";
|
import { Block } from "@/src/components/home/Block";
|
||||||
import { ClosingCta } from "@/src/components/home/ClosingCta";
|
import { ClosingCta } from "@/src/components/home/ClosingCta";
|
||||||
import { Faq } from "@/src/components/home/Faq";
|
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 { AutomationFlow } from "@/src/components/illustrations/AutomationFlow";
|
||||||
import { CareerLine } from "@/src/components/illustrations/CareerLine";
|
import { CareerLine } from "@/src/components/illustrations/CareerLine";
|
||||||
import { ContactDiagram } from "@/src/components/illustrations/ContactDiagram";
|
import { ContactDiagram } from "@/src/components/illustrations/ContactDiagram";
|
||||||
import { PipelineRun } from "@/src/components/illustrations/PipelineRun";
|
|
||||||
import {
|
import {
|
||||||
PresentationCompare,
|
PresentationCompare,
|
||||||
ReasonDrawing,
|
ReasonDrawing,
|
||||||
} from "@/src/components/illustrations/WebPresentation";
|
} from "@/src/components/illustrations/WebPresentation";
|
||||||
import { WorkShowcase } from "@/src/components/illustrations/WorkShowcase";
|
import { WorkShowcase } from "@/src/components/illustrations/WorkShowcase";
|
||||||
import { Reveal } from "@/src/components/Reveal";
|
import { Reveal } from "@/src/components/Reveal";
|
||||||
|
import { SwipeRow } from "@/src/components/SwipeRow";
|
||||||
import { contentFor } from "@/src/content";
|
import { contentFor } from "@/src/content";
|
||||||
import type { SplitTitle } from "@/src/content/types";
|
import type { SplitTitle } from "@/src/content/types";
|
||||||
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
|
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 MUTED = "text-slate-400";
|
||||||
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
|
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 }> = ({
|
const Title: React.FC<{ title: SplitTitle; muted?: string }> = ({
|
||||||
title,
|
title,
|
||||||
@@ -60,7 +61,8 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
services,
|
services,
|
||||||
automation,
|
automation,
|
||||||
presentations,
|
presentations,
|
||||||
technology,
|
story,
|
||||||
|
freelance,
|
||||||
working,
|
working,
|
||||||
work,
|
work,
|
||||||
faq,
|
faq,
|
||||||
@@ -104,6 +106,7 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
<Block
|
<Block
|
||||||
index="02"
|
index="02"
|
||||||
label={services.label}
|
label={services.label}
|
||||||
|
bridge={story.services}
|
||||||
title={<Title title={services.title} />}
|
title={<Title title={services.title} />}
|
||||||
>
|
>
|
||||||
<Reveal>
|
<Reveal>
|
||||||
@@ -114,6 +117,7 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
<Block
|
<Block
|
||||||
index="03"
|
index="03"
|
||||||
label={automation.label}
|
label={automation.label}
|
||||||
|
bridge={story.automation}
|
||||||
title={<Title title={automation.title} />}
|
title={<Title title={automation.title} />}
|
||||||
>
|
>
|
||||||
<Reveal>
|
<Reveal>
|
||||||
@@ -122,11 +126,11 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
<Reveal>
|
<Reveal>
|
||||||
<AutomationFlow />
|
<AutomationFlow />
|
||||||
</Reveal>
|
</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) => (
|
{automation.notes.map((group, i) => (
|
||||||
<Reveal key={group.title} delay={0.1 * i}>
|
<Reveal key={group.title} delay={0.1 * i}>
|
||||||
<div className="border-t border-slate-900 pt-6">
|
<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">
|
<ul className="m-0 mt-4 list-none p-0">
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => (
|
||||||
<li
|
<li
|
||||||
@@ -140,12 +144,13 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</SwipeRow>
|
||||||
</Block>
|
</Block>
|
||||||
|
|
||||||
<Block
|
<Block
|
||||||
index="04"
|
index="04"
|
||||||
label={presentations.label}
|
label={presentations.label}
|
||||||
|
bridge={story.presentations}
|
||||||
tone="dark"
|
tone="dark"
|
||||||
title={<Title title={presentations.title} muted="text-slate-500" />}
|
title={<Title title={presentations.title} muted="text-slate-500" />}
|
||||||
>
|
>
|
||||||
@@ -162,7 +167,7 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
</BitAura>
|
</BitAura>
|
||||||
</Reveal>
|
</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) => (
|
{presentations.reasons.map((reason, i) => (
|
||||||
<Reveal key={reason.title} delay={0.08 * i}>
|
<Reveal key={reason.title} delay={0.08 * i}>
|
||||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
|
<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>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</SwipeRow>
|
||||||
|
|
||||||
<div className="mt-16 grid grid-cols-1 gap-12 md:mt-28 md:grid-cols-12 md:gap-12">
|
<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">
|
<Reveal className="md:col-span-6">
|
||||||
<div className="border-t border-slate-700 pt-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}
|
{presentations.goodFor.title}
|
||||||
</p>
|
</h3>
|
||||||
<ul className="m-0 mt-4 list-none p-0">
|
<ul className="m-0 mt-4 list-none p-0">
|
||||||
{presentations.goodFor.items.map((item) => (
|
{presentations.goodFor.items.map((item) => (
|
||||||
<li
|
<li
|
||||||
@@ -217,76 +222,8 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
|
|
||||||
<Block
|
<Block
|
||||||
index="05"
|
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}
|
label={working.label}
|
||||||
|
bridge={story.working}
|
||||||
title={<Title title={working.title} />}
|
title={<Title title={working.title} />}
|
||||||
>
|
>
|
||||||
<Reveal>
|
<Reveal>
|
||||||
@@ -296,7 +233,7 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</BitAura>
|
</BitAura>
|
||||||
</Reveal>
|
</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) => (
|
{working.principles.map((item, i) => (
|
||||||
<Reveal key={item.title} delay={0.08 * i}>
|
<Reveal key={item.title} delay={0.08 * i}>
|
||||||
<div className="border-t border-slate-900 pt-6">
|
<div className="border-t border-slate-900 pt-6">
|
||||||
@@ -307,12 +244,37 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</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>
|
||||||
|
|
||||||
<Block
|
<Block
|
||||||
index="07"
|
index="06"
|
||||||
label={work.label}
|
label={work.label}
|
||||||
|
bridge={story.work}
|
||||||
tone="dark"
|
tone="dark"
|
||||||
title={<Title title={work.title} muted="text-slate-500" />}
|
title={<Title title={work.title} muted="text-slate-500" />}
|
||||||
>
|
>
|
||||||
@@ -324,7 +286,12 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
</Block>
|
</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>
|
<Reveal>
|
||||||
<Faq items={faq.items} />
|
<Faq items={faq.items} />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|||||||
+32
-45
@@ -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). */
|
/* Light that follows the pointer on a `data-glow` surface (see BitGlow). */
|
||||||
.bit-glow {
|
.bit-glow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1127,10 +1086,6 @@ pre:has(code[class*="language-"]) {
|
|||||||
.bit-glow {
|
.bit-glow {
|
||||||
display: none;
|
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.
|
* Native feel: the page should behave like a well-made app on every input type.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { SITE_URL } from "@/src/content/site";
|
|||||||
import { sitemapEntries } from "@/src/domain/routes";
|
import { sitemapEntries } from "@/src/domain/routes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The site is a single page in two languages; the legal pages are not meant for
|
* The homepage and the freelance page, each in two languages; the legal pages
|
||||||
* search results. No modification date: it would have to be the date the content
|
* 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
|
* last changed, and a date that moves with every build teaches search engines to
|
||||||
* ignore it.
|
* ignore it.
|
||||||
*/
|
*/
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
return sitemapEntries(SITE_URL, ["home"]).map((entry) => ({
|
return sitemapEntries(SITE_URL, ["home", "freelance"]).map((entry) => ({
|
||||||
url: entry.url,
|
url: entry.url,
|
||||||
alternates: { languages: { ...entry.alternates.languages } },
|
alternates: { languages: { ...entry.alternates.languages } },
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Renders the share images (Open Graph and Twitter) from the real hero, one per
|
// 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
|
// language, so they always match the site. The hero is shown the way the dark
|
||||||
// design changes:
|
// 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 (uses the live site)
|
||||||
// pnpm --filter @mintel/web share-image http://localhost:3000
|
// 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 .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 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; }
|
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.
|
// 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";
|
"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.style.position = "relative";
|
||||||
section.append(tag, site);
|
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);
|
window.scrollTo(0, 0);
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(4500);
|
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 |
@@ -50,6 +50,12 @@ export const Footer: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{nav.contactLong}
|
{nav.contactLong}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={pathFor("freelance", locale)}
|
||||||
|
className="hover:text-slate-900 transition-colors duration-300 no-underline"
|
||||||
|
>
|
||||||
|
{nav.freelance}
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={pathFor("imprint", locale)}
|
href={pathFor("imprint", locale)}
|
||||||
className="hover:text-slate-900 transition-colors duration-300 no-underline"
|
className="hover:text-slate-900 transition-colors duration-300 no-underline"
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import { ScrollBits } from "./bits/ScrollBits";
|
|||||||
import { useContent, useLocale } from "./ContentProvider";
|
import { useContent, useLocale } from "./ContentProvider";
|
||||||
import { LanguageSwitch } from "./LanguageSwitch";
|
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 = () => {
|
export const Header: React.FC = () => {
|
||||||
const { nav } = useContent();
|
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);
|
const [isScrolled, setIsScrolled] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
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" />
|
<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">
|
<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 />
|
<LanguageSwitch />
|
||||||
<Link
|
<Link
|
||||||
href={`${home}#contact`}
|
href={`${home}#contact`}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -81,7 +81,7 @@ const LIGHT_PALETTE: Palette = {
|
|||||||
blend: "source-over",
|
blend: "source-over",
|
||||||
glow: "15, 23, 42",
|
glow: "15, 23, 42",
|
||||||
core: "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.
|
// No haze on light backgrounds: coloured light only reads on dark. Ink digits only.
|
||||||
gain: 0,
|
gain: 0,
|
||||||
density: 0.6,
|
density: 0.6,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface BlockProps {
|
|||||||
index: string;
|
index: string;
|
||||||
label: string;
|
label: string;
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
|
/** One sentence that leads over from the section before (the thread of the page). */
|
||||||
|
bridge?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
/** `dark` turns the section into a dark stage. */
|
/** `dark` turns the section into a dark stage. */
|
||||||
tone?: "light" | "dark";
|
tone?: "light" | "dark";
|
||||||
@@ -24,6 +26,7 @@ export const Block: React.FC<BlockProps> = ({
|
|||||||
index,
|
index,
|
||||||
label,
|
label,
|
||||||
title,
|
title,
|
||||||
|
bridge,
|
||||||
id,
|
id,
|
||||||
tone = "light",
|
tone = "light",
|
||||||
children,
|
children,
|
||||||
@@ -49,7 +52,24 @@ export const Block: React.FC<BlockProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{dark && <BitSeam />}
|
{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>
|
<Reveal>
|
||||||
<div className="mb-8 flex items-center gap-4 md:mb-12">
|
<div className="mb-8 flex items-center gap-4 md:mb-12">
|
||||||
<span
|
<span
|
||||||
@@ -78,7 +98,7 @@ export const Block: React.FC<BlockProps> = ({
|
|||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
</BitWipe>
|
</BitWipe>
|
||||||
<div className="mt-14 md:mt-24">{children}</div>
|
<div className="mt-10 md:mt-24">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
|
|||||||
/>
|
/>
|
||||||
<div className="bit-glow bit-glow-dots" aria-hidden="true" />
|
<div className="bit-glow bit-glow-dots" aria-hidden="true" />
|
||||||
<BitSeam />
|
<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">
|
<Reveal className="lg:col-span-6">
|
||||||
<BitHeadline
|
<BitHeadline
|
||||||
as="h2"
|
as="h2"
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
|
|||||||
setStep(index);
|
setStep(index);
|
||||||
setAuto(false);
|
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"
|
selected ? "border-slate-900" : "border-slate-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -122,8 +122,11 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
|
|||||||
{item.title}
|
{item.title}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`mt-1.5 block font-sans text-base leading-relaxed transition-colors duration-300 ${
|
className={`mt-1.5 font-sans text-base leading-relaxed transition-colors duration-300 ${
|
||||||
selected ? "text-slate-600" : "text-slate-400"
|
// On phones only the current step is spelled out.
|
||||||
|
selected
|
||||||
|
? "block text-slate-600"
|
||||||
|
: "hidden text-slate-400 md:block"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.text}
|
{item.text}
|
||||||
@@ -153,7 +156,7 @@ export const PathPicker: React.FC = () => {
|
|||||||
<div
|
<div
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label={services.tablistLabel}
|
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) => {
|
{paths.map((path, index) => {
|
||||||
const selected = path.id === activeId;
|
const selected = path.id === activeId;
|
||||||
@@ -167,7 +170,7 @@ export const PathPicker: React.FC = () => {
|
|||||||
aria-controls={`path-panel-${path.id}`}
|
aria-controls={`path-panel-${path.id}`}
|
||||||
onClick={() => setActiveId(path.id)}
|
onClick={() => setActiveId(path.id)}
|
||||||
data-glow
|
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
|
selected
|
||||||
? "border-slate-900 bg-slate-900 text-white"
|
? "border-slate-900 bg-slate-900 text-white"
|
||||||
: "border-slate-200 bg-white text-slate-900 hover:-translate-y-1 hover:border-slate-400"
|
: "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">
|
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-400">
|
||||||
0{index + 1}
|
0{index + 1}
|
||||||
</span>
|
</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}
|
{path.need}
|
||||||
</span>
|
</span>
|
||||||
<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}
|
{path.forWhom}
|
||||||
</span>
|
</span>
|
||||||
@@ -199,7 +202,7 @@ export const PathPicker: React.FC = () => {
|
|||||||
id={`path-panel-${path.id}`}
|
id={`path-panel-${path.id}`}
|
||||||
aria-labelledby={`path-tab-${path.id}`}
|
aria-labelledby={`path-tab-${path.id}`}
|
||||||
hidden={!selected}
|
hidden={!selected}
|
||||||
className="mt-12 md:mt-20"
|
className="mt-8 md:mt-20"
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
key={selected ? "on" : "off"}
|
key={selected ? "on" : "off"}
|
||||||
@@ -251,7 +254,7 @@ export const PathPicker: React.FC = () => {
|
|||||||
{path.pricingText}
|
{path.pricingText}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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) => (
|
{path.prices.map((price) => (
|
||||||
<div key={price.label}>
|
<div key={price.label}>
|
||||||
<p className="m-0 font-sans text-base text-slate-500 md:text-lg">
|
<p className="m-0 font-sans text-base text-slate-500 md:text-lg">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BitAura } from "@/src/components/bits/BitAura";
|
import { BitAura } from "@/src/components/bits/BitAura";
|
||||||
|
import { SwipeRow } from "@/src/components/SwipeRow";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useNarrow } from "./useNarrow";
|
import { useNarrow } from "./useNarrow";
|
||||||
import { useContent } from "@/src/components/ContentProvider";
|
import { useContent } from "@/src/components/ContentProvider";
|
||||||
@@ -222,40 +223,35 @@ export const AutomationFlow: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
<div
|
{/* The examples, their description and the run are one box: they belong together. */}
|
||||||
role="tablist"
|
<BitAura source="left" tone="light">
|
||||||
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">
|
|
||||||
<div className="overflow-hidden rounded-3xl bg-slate-900">
|
<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">
|
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-slate-800 px-5 py-4 md:px-7">
|
||||||
<span className={`${MONO} text-slate-400`}>{flow.name}</span>
|
<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={`${MONO} flex items-center gap-2 text-slate-500`}>
|
||||||
<span
|
<span
|
||||||
className={`h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-amber-300 animate-pulse"}`}
|
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}
|
{finished ? automation.runComplete : automation.running}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<svg
|
<svg
|
||||||
@@ -391,7 +390,7 @@ export const AutomationFlow: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 border-t border-slate-800 md:grid-cols-12">
|
<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>
|
<p className={`${MONO} m-0 text-slate-500`}>{automation.log}</p>
|
||||||
<ol
|
<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]"
|
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>
|
</ol>
|
||||||
</div>
|
</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) =>
|
{KINDS.filter((kind) =>
|
||||||
flow.nodes.some((node) => node.kind === kind),
|
flow.nodes.some((node) => node.kind === kind),
|
||||||
).map((kind) => (
|
).map((kind) => (
|
||||||
@@ -439,23 +438,28 @@ export const AutomationFlow: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</BitAura>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const CareerLine: React.FC<{ stations: readonly CareerStation[] }> = ({
|
|||||||
}`}
|
}`}
|
||||||
aria-hidden="true"
|
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}
|
{station.years}
|
||||||
</span>
|
</span>
|
||||||
<span className="md:col-span-9">
|
<span className="md:col-span-9">
|
||||||
|
|||||||
@@ -241,7 +241,10 @@ export const PipelineRun: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className={`m-0 mt-2 font-sans text-base leading-relaxed transition-colors duration-300 ${
|
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}
|
{item.text}
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ export const WorkShowcase: React.FC = () => {
|
|||||||
{copy.text}
|
{copy.text}
|
||||||
</p>
|
</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) => (
|
{copy.did.map((line) => (
|
||||||
<li
|
<li
|
||||||
key={line}
|
key={line}
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ export const de: Content = {
|
|||||||
site: {
|
site: {
|
||||||
title: "Freelance-Webentwickler für Web-Apps & Websites | Marc Mintel",
|
title: "Freelance-Webentwickler für Web-Apps & Websites | Marc Mintel",
|
||||||
description:
|
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",
|
jobTitle: "Freelance-Webentwickler",
|
||||||
ogLocale: "de_DE",
|
ogLocale: "de_DE",
|
||||||
shareImageAlt:
|
shareImageAlt:
|
||||||
"Web-Apps und Websites. Marc Mintel, Freelance-Webentwickler mit 15 Jahren Erfahrung.",
|
"Web-Apps und Websites. Marc Mintel, Freelance-Webentwickler mit 15 Jahren Erfahrung.",
|
||||||
knowsAbout: [
|
knowsAbout: [
|
||||||
"Webanwendungen",
|
"Webanwendungen",
|
||||||
"Benutzeroberflächen",
|
"User Interfaces",
|
||||||
"Websites",
|
"Websites",
|
||||||
"Prozessautomatisierung",
|
"Prozessautomatisierung",
|
||||||
"Web3-Frontends",
|
"Web3-Frontends",
|
||||||
@@ -26,6 +26,7 @@ export const de: Content = {
|
|||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
contact: "Kontakt",
|
contact: "Kontakt",
|
||||||
|
freelance: "Freelance",
|
||||||
contactLong: GET_IN_TOUCH,
|
contactLong: GET_IN_TOUCH,
|
||||||
logoLabel: "Marc Mintel, nach oben",
|
logoLabel: "Marc Mintel, nach oben",
|
||||||
imprint: "Impressum",
|
imprint: "Impressum",
|
||||||
@@ -40,7 +41,7 @@ export const de: Content = {
|
|||||||
{ text: "& WEBSITES.", tone: "bits" },
|
{ text: "& WEBSITES.", tone: "bits" },
|
||||||
],
|
],
|
||||||
lead: "Freelance-Webentwickler mit 15 Jahren Erfahrung.",
|
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,
|
primaryCta: GET_IN_TOUCH,
|
||||||
secondaryCta: "Was ich mache",
|
secondaryCta: "Was ich mache",
|
||||||
trust: ["15 Jahre im Web", "Remote, EU-Kernzeiten"],
|
trust: ["15 Jahre im Web", "Remote, EU-Kernzeiten"],
|
||||||
@@ -51,7 +52,7 @@ export const de: Content = {
|
|||||||
title: "Ich bin Marc.",
|
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.",
|
lead: "Ich baue seit 15 Jahren für das Web: zuerst in Agenturen, später als Senior-Entwickler und heute selbstständig.",
|
||||||
paragraphs: [
|
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.",
|
"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",
|
portraitAlt: "Marc Mintel, Freelance-Webentwickler",
|
||||||
@@ -86,10 +87,10 @@ export const de: Content = {
|
|||||||
forWhom: "Start-ups, Produktteams, Agenturen, Web3-Teams",
|
forWhom: "Start-ups, Produktteams, Agenturen, Web3-Teams",
|
||||||
headline: "Ein erfahrener Entwickler, der in Ihr Projekt einsteigt.",
|
headline: "Ein erfahrener Entwickler, der in Ihr Projekt einsteigt.",
|
||||||
intro:
|
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: [
|
youGet: [
|
||||||
"Web-Apps, Dashboards, Kundenportale und interne Werkzeuge",
|
"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",
|
"Web3-Frontends: Wallet-Anbindung, Live-Daten, Dashboards",
|
||||||
"Kleine Werkzeuge und Automatisierung für wiederkehrende Arbeit",
|
"Kleine Werkzeuge und Automatisierung für wiederkehrende Arbeit",
|
||||||
],
|
],
|
||||||
@@ -497,6 +498,32 @@ export const de: Content = {
|
|||||||
},
|
},
|
||||||
cta: "Nach einer Präsentation fragen",
|
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: {
|
technology: {
|
||||||
label: "Technik",
|
label: "Technik",
|
||||||
title: { lead: "Für welche Projekte", rest: "ich der Richtige bin." },
|
title: { lead: "Für welche Projekte", rest: "ich der Richtige bin." },
|
||||||
@@ -505,7 +532,7 @@ export const de: Content = {
|
|||||||
title: "Passt gut",
|
title: "Passt gut",
|
||||||
items: [
|
items: [
|
||||||
"Web-Apps: Dashboards, Portale, interne Werkzeuge",
|
"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",
|
"Unternehmenswebsites, auch in mehreren Sprachen",
|
||||||
"Web3-Frontends: Wallets, Live-Daten, Dashboards",
|
"Web3-Frontends: Wallets, Live-Daten, Dashboards",
|
||||||
"Automatisierung: Systeme und ihre APIs verbinden",
|
"Automatisierung: Systeme und ihre APIs verbinden",
|
||||||
@@ -531,7 +558,7 @@ export const de: Content = {
|
|||||||
techLabel: "Womit ich arbeite",
|
techLabel: "Womit ich arbeite",
|
||||||
tech: [
|
tech: [
|
||||||
{
|
{
|
||||||
area: "Oberfläche",
|
area: "Interface",
|
||||||
items: ["TypeScript", "React", "Next.js", "Vue.js", "Tailwind CSS"],
|
items: ["TypeScript", "React", "Next.js", "Vue.js", "Tailwind CSS"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const en: Content = {
|
|||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
contact: GET_IN_TOUCH,
|
contact: GET_IN_TOUCH,
|
||||||
|
freelance: "Freelance",
|
||||||
contactLong: GET_IN_TOUCH,
|
contactLong: GET_IN_TOUCH,
|
||||||
logoLabel: "Marc Mintel, back to top",
|
logoLabel: "Marc Mintel, back to top",
|
||||||
imprint: "Imprint",
|
imprint: "Imprint",
|
||||||
@@ -477,6 +478,32 @@ export const en: Content = {
|
|||||||
},
|
},
|
||||||
cta: "Ask about a presentation",
|
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: {
|
technology: {
|
||||||
label: "Technology",
|
label: "Technology",
|
||||||
title: { lead: "Which projects", rest: "I am right for." },
|
title: { lead: "Which projects", rest: "I am right for." },
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export type Content = {
|
|||||||
};
|
};
|
||||||
readonly nav: {
|
readonly nav: {
|
||||||
readonly contact: string;
|
readonly contact: string;
|
||||||
|
readonly freelance: string;
|
||||||
readonly contactLong: string;
|
readonly contactLong: string;
|
||||||
readonly logoLabel: string;
|
readonly logoLabel: string;
|
||||||
readonly imprint: string;
|
readonly imprint: string;
|
||||||
@@ -220,6 +221,29 @@ export type Content = {
|
|||||||
readonly honest: TitledText;
|
readonly honest: TitledText;
|
||||||
readonly cta: string;
|
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 technology: {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly title: SplitTitle;
|
readonly title: SplitTitle;
|
||||||
|
|||||||
@@ -296,3 +296,65 @@ describe("sitemapEntries", () => {
|
|||||||
expect(sitemapEntries("https://example.com", [])).toEqual([]);
|
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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { LOCALES, preferredLocale, type Locale } from "./locale";
|
|||||||
* Internally every page is rendered under its language (/de/..., /en/...);
|
* Internally every page is rendered under its language (/de/..., /en/...);
|
||||||
* `routeRequest` maps one onto the other.
|
* `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>>;
|
type LanguageAlternates = Readonly<Record<Locale | "x-default", string>>;
|
||||||
|
|
||||||
@@ -31,18 +31,24 @@ export type SitemapEntry = {
|
|||||||
|
|
||||||
type PagePaths = Readonly<Record<PageId, string>>;
|
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. */
|
/** The path of a page below its language, as the app directory names it. */
|
||||||
const INTERNAL: PagePaths = {
|
const INTERNAL: PagePaths = {
|
||||||
home: "",
|
home: "",
|
||||||
|
freelance: "/freelance",
|
||||||
imprint: "/imprint",
|
imprint: "/imprint",
|
||||||
privacy: "/privacy",
|
privacy: "/privacy",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The path of a page below its language, as visitors see it. */
|
/** The path of a page below its language, as visitors see it. */
|
||||||
const PUBLIC: Readonly<Record<Locale, PagePaths>> = {
|
const PUBLIC: Readonly<Record<Locale, PagePaths>> = {
|
||||||
de: { home: "", imprint: "/impressum", privacy: "/datenschutz" },
|
de: {
|
||||||
|
home: "",
|
||||||
|
freelance: "/freelance",
|
||||||
|
imprint: "/impressum",
|
||||||
|
privacy: "/datenschutz",
|
||||||
|
},
|
||||||
en: INTERNAL,
|
en: INTERNAL,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,8 +117,9 @@ export function routeRequest(request: {
|
|||||||
|
|
||||||
const english = restAfter(request.pathname, PREFIX.en);
|
const english = restAfter(request.pathname, PREFIX.en);
|
||||||
if (english !== null) {
|
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);
|
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 }
|
? { kind: "rewrite", to: `/en${english}`, negotiated: false }
|
||||||
: redirect(pathFor(misnamed, "en"), true);
|
: redirect(pathFor(misnamed, "en"), true);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user