feat: rebuild mintel.me as a single-page freelance site
The site now presents one freelance developer on one page: hero, introduction, four typical cases (developer for a product, make an app reliable, automate a process, a website) with what you get, how it runs and what it costs, a process automation section, technology and project fit, how I work, two live references, questions and contact. - Hero and closing headline are drawn from running binary digits. - Each case explains its four steps with an animated drawing; the automation section shows example workflows with work travelling between systems; a pipeline run shows what a test catches before a release. - Contact is one email address, kept in parts and assembled in the browser so it never appears in the page source (src/domain/email.ts, spec first). - Imprint and privacy pages added. - The old site (blog, case studies, contact form, fixed-price pages, video rendering and related scripts) is removed. Its URLs redirect to the homepage. - The deploy smoke test checks the legal pages and the redirects instead of the removed case study. - Dev scripts use docker compose v2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,28 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Pill } from "@/src/components/site";
|
||||
import { AVAILABILITY_CONFIG } from "@/src/content/site";
|
||||
import {
|
||||
formatAvailability,
|
||||
resolveAvailability,
|
||||
} from "@/src/domain/availability";
|
||||
|
||||
const todayIso = (): string => new Date().toISOString().slice(0, 10);
|
||||
|
||||
/** Computes "today" in the browser so a statically rendered page never shows stale availability. */
|
||||
export const AvailabilityPill: React.FC = () => {
|
||||
const [today, setToday] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setToday(todayIso());
|
||||
}, []);
|
||||
|
||||
if (today === null) return null;
|
||||
|
||||
return (
|
||||
<Pill>
|
||||
{formatAvailability(resolveAvailability(AVAILABILITY_CONFIG, today))}
|
||||
</Pill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,385 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* BitHeadline: a headline whose letters are made of running binary digits.
|
||||
*
|
||||
* - "solid" lines condense from bits into massive dark type, with digits still moving inside.
|
||||
* - "bits" lines stay built from digits only, so they read lighter.
|
||||
*
|
||||
* The real text stays in the DOM (transparent once the canvas is drawn), so it remains
|
||||
* selectable, accessible and visible without JavaScript.
|
||||
*/
|
||||
|
||||
export type BitLine = {
|
||||
readonly text: string;
|
||||
readonly tone: "solid" | "bits";
|
||||
};
|
||||
|
||||
type Measured = {
|
||||
readonly text: string;
|
||||
readonly tone: BitLine["tone"];
|
||||
readonly x: number;
|
||||
readonly baseline: number;
|
||||
/** Vertical extent of the glyphs, which can exceed a tight line box. */
|
||||
readonly top: number;
|
||||
readonly height: number;
|
||||
readonly width: number;
|
||||
readonly font: string;
|
||||
readonly letterSpacing: string;
|
||||
};
|
||||
|
||||
const DARK = "15, 23, 42"; // slate-900
|
||||
const LIGHT = "255, 255, 255";
|
||||
const INTRO_MS = 1600;
|
||||
const FRAME_MS = 1000 / 30;
|
||||
const POINTER_RADIUS = 170;
|
||||
|
||||
const easeOut = (t: number): number => 1 - Math.pow(1 - t, 3);
|
||||
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
|
||||
|
||||
const makeSprite = (
|
||||
digit: string,
|
||||
color: string,
|
||||
cell: number,
|
||||
dpr: number,
|
||||
): HTMLCanvasElement => {
|
||||
const sprite = document.createElement("canvas");
|
||||
sprite.width = Math.ceil(cell * dpr);
|
||||
sprite.height = Math.ceil(cell * dpr);
|
||||
const sctx = sprite.getContext("2d");
|
||||
if (sctx) {
|
||||
sctx.scale(dpr, dpr);
|
||||
const mono =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--font-jetbrains-mono")
|
||||
.trim() || "ui-monospace";
|
||||
sctx.font = `700 ${Math.round(cell * 1.15)}px ${mono}, ui-monospace, Menlo, monospace`;
|
||||
sctx.textAlign = "center";
|
||||
sctx.textBaseline = "middle";
|
||||
sctx.fillStyle = color;
|
||||
sctx.fillText(digit, cell / 2, cell / 2 + 0.5);
|
||||
}
|
||||
return sprite;
|
||||
};
|
||||
|
||||
export const BitHeadline: React.FC<{
|
||||
lines: readonly BitLine[];
|
||||
className?: string;
|
||||
/** Render as this heading level. */
|
||||
as?: "h1" | "h2";
|
||||
/** Light type on a dark surface. */
|
||||
invert?: boolean;
|
||||
}> = ({ lines, className = "", as: Tag = "h1", invert = false }) => {
|
||||
const wrapRef = React.useRef<HTMLHeadingElement>(null);
|
||||
const INK = invert ? LIGHT : DARK;
|
||||
const PAPER = invert ? DARK : LIGHT;
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const [painted, setPainted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const wrap = wrapRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!wrap || !canvas || !ctx) return;
|
||||
|
||||
const off = document.createElement("canvas");
|
||||
const offCtx = off.getContext("2d");
|
||||
if (!offCtx) return;
|
||||
|
||||
const reducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
|
||||
let measured: Measured[] = [];
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let dpr = 1;
|
||||
let cell = 12;
|
||||
let wash = 0.07;
|
||||
let cols = 0;
|
||||
let rows = 0;
|
||||
let bits = new Uint8Array(0);
|
||||
let phase = new Float32Array(0);
|
||||
let speed = new Float32Array(0);
|
||||
let sprites: {
|
||||
light: HTMLCanvasElement[];
|
||||
dark: HTMLCanvasElement[];
|
||||
} | null = null;
|
||||
let raf = 0;
|
||||
let startedAt = 0;
|
||||
let lastFrame = 0;
|
||||
let pointer: { x: number; y: number } | null = null;
|
||||
let disposed = false;
|
||||
|
||||
const measure = () => {
|
||||
const box = wrap.getBoundingClientRect();
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
width = box.width;
|
||||
height = box.height;
|
||||
for (const target of [canvas, off]) {
|
||||
target.width = Math.ceil(width * dpr);
|
||||
target.height = Math.ceil(height * dpr);
|
||||
}
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
offCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const spans = Array.from(
|
||||
wrap.querySelectorAll<HTMLSpanElement>("[data-bit-line]"),
|
||||
);
|
||||
measured = spans.map((span, index) => {
|
||||
const rect = span.getBoundingClientRect();
|
||||
const style = getComputedStyle(span);
|
||||
const font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
||||
ctx.font = font;
|
||||
const metrics = ctx.measureText("Hg");
|
||||
const ascent = metrics.fontBoundingBoxAscent;
|
||||
const descent = metrics.fontBoundingBoxDescent;
|
||||
const lineTop = rect.top - box.top;
|
||||
const baseline =
|
||||
lineTop + (rect.height - (ascent + descent)) / 2 + ascent;
|
||||
return {
|
||||
text: span.textContent ?? "",
|
||||
tone: lines[index]?.tone ?? "solid",
|
||||
x: rect.left - box.left,
|
||||
top: baseline - ascent,
|
||||
height: ascent + descent,
|
||||
width: rect.width,
|
||||
baseline,
|
||||
font,
|
||||
letterSpacing: style.letterSpacing,
|
||||
};
|
||||
});
|
||||
|
||||
const fontSize = parseFloat(getComputedStyle(wrap).fontSize) || 100;
|
||||
cell = Math.round(Math.min(9, Math.max(4, fontSize / 19)));
|
||||
// Small type needs a stronger wash under the digits to keep its shape.
|
||||
wash = fontSize < 100 ? 0.2 : 0.07;
|
||||
cols = Math.ceil(width / cell);
|
||||
rows = Math.ceil(height / cell);
|
||||
bits = new Uint8Array(cols * rows);
|
||||
phase = new Float32Array(cols * rows);
|
||||
speed = new Float32Array(cols * rows);
|
||||
for (let i = 0; i < bits.length; i++) {
|
||||
bits[i] = Math.random() < 0.5 ? 0 : 1;
|
||||
phase[i] = Math.random() * Math.PI * 2;
|
||||
speed[i] = 0.6 + Math.random() * 1.8;
|
||||
}
|
||||
sprites = {
|
||||
light: [
|
||||
makeSprite("0", `rgb(${PAPER})`, cell, dpr),
|
||||
makeSprite("1", `rgb(${PAPER})`, cell, dpr),
|
||||
],
|
||||
dark: [
|
||||
makeSprite("0", `rgb(${INK})`, cell, dpr),
|
||||
makeSprite("1", `rgb(${INK})`, cell, dpr),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const setFont = (context: CanvasRenderingContext2D, line: Measured) => {
|
||||
context.font = line.font;
|
||||
context.textBaseline = "alphabetic";
|
||||
context.textAlign = "left";
|
||||
if ("letterSpacing" in context) {
|
||||
(
|
||||
context as CanvasRenderingContext2D & { letterSpacing: string }
|
||||
).letterSpacing =
|
||||
line.letterSpacing === "normal" ? "0px" : line.letterSpacing;
|
||||
}
|
||||
};
|
||||
|
||||
/** Digits over one line's box. `strength` scales the alpha, `set` picks the sprite color. */
|
||||
const drawDigits = (
|
||||
context: CanvasRenderingContext2D,
|
||||
line: Measured,
|
||||
set: HTMLCanvasElement[],
|
||||
seconds: number,
|
||||
base: number,
|
||||
swing: number,
|
||||
) => {
|
||||
const firstRow = Math.max(0, Math.floor(line.top / cell));
|
||||
const lastRow = Math.min(
|
||||
rows,
|
||||
Math.ceil((line.top + line.height) / cell),
|
||||
);
|
||||
const firstCol = Math.max(0, Math.floor(line.x / cell));
|
||||
const lastCol = Math.min(cols, Math.ceil((line.x + line.width) / cell));
|
||||
// A soft diagonal scan band that travels across the headline.
|
||||
const scan = ((seconds * 0.16) % 1.6) * (width + height) - height * 0.3;
|
||||
|
||||
for (let row = firstRow; row < lastRow; row++) {
|
||||
for (let col = firstCol; col < lastCol; col++) {
|
||||
const i = row * cols + col;
|
||||
const x = col * cell;
|
||||
const y = row * cell;
|
||||
let alpha =
|
||||
base +
|
||||
swing *
|
||||
(0.5 +
|
||||
0.5 * Math.sin(seconds * (speed[i] ?? 1) + (phase[i] ?? 0)));
|
||||
const scanDistance = Math.abs(x + y - scan);
|
||||
if (scanDistance < 90) alpha += (1 - scanDistance / 90) * 0.45;
|
||||
if (pointer) {
|
||||
const d = Math.hypot(x - pointer.x, y - pointer.y);
|
||||
if (d < POINTER_RADIUS) alpha += (1 - d / POINTER_RADIUS) * 0.7;
|
||||
}
|
||||
if (alpha <= 0.02) continue;
|
||||
// Occasionally flip a bit so the field stays alive.
|
||||
if (Math.random() < 0.004) bits[i] = bits[i] === 1 ? 0 : 1;
|
||||
const sprite = set[bits[i] ?? 0];
|
||||
if (!sprite) continue;
|
||||
context.globalAlpha = clamp01(alpha);
|
||||
context.drawImage(sprite, x, y, cell, cell);
|
||||
}
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
};
|
||||
|
||||
const paint = (now: number) => {
|
||||
if (!sprites) return;
|
||||
const seconds = now / 1000;
|
||||
const intro = reducedMotion
|
||||
? 1
|
||||
: easeOut(clamp01((now - startedAt) / INTRO_MS));
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
for (const line of measured) {
|
||||
offCtx.globalCompositeOperation = "source-over";
|
||||
offCtx.clearRect(0, 0, width, height);
|
||||
|
||||
if (line.tone === "solid") {
|
||||
// Letters start as pure bits and condense into solid ink.
|
||||
drawDigits(
|
||||
offCtx,
|
||||
line,
|
||||
sprites.dark,
|
||||
seconds,
|
||||
0.75 * (1 - intro),
|
||||
0.25 * (1 - intro),
|
||||
);
|
||||
offCtx.globalCompositeOperation = "destination-in";
|
||||
setFont(offCtx, line);
|
||||
offCtx.fillStyle = "#000";
|
||||
offCtx.fillText(line.text, line.x, line.baseline);
|
||||
ctx.drawImage(off, 0, 0, width, height);
|
||||
|
||||
setFont(ctx, line);
|
||||
ctx.fillStyle = `rgba(${INK}, ${intro})`;
|
||||
ctx.fillText(line.text, line.x, line.baseline);
|
||||
|
||||
// Bright digits keep moving inside the solid letters.
|
||||
offCtx.globalCompositeOperation = "source-over";
|
||||
offCtx.clearRect(0, 0, width, height);
|
||||
drawDigits(
|
||||
offCtx,
|
||||
line,
|
||||
sprites.light,
|
||||
seconds,
|
||||
0.02 * intro,
|
||||
0.16 * intro,
|
||||
);
|
||||
offCtx.globalCompositeOperation = "destination-in";
|
||||
setFont(offCtx, line);
|
||||
offCtx.fillText(line.text, line.x, line.baseline);
|
||||
ctx.drawImage(off, 0, 0, width, height);
|
||||
} else {
|
||||
// Stays built from digits only, on a faint wash so the letters keep their shape.
|
||||
setFont(ctx, line);
|
||||
ctx.fillStyle = `rgba(${INK}, ${wash * intro})`;
|
||||
ctx.fillText(line.text, line.x, line.baseline);
|
||||
drawDigits(offCtx, line, sprites.dark, seconds, 0.3, 0.5);
|
||||
offCtx.globalCompositeOperation = "destination-in";
|
||||
setFont(offCtx, line);
|
||||
offCtx.fillStyle = "#000";
|
||||
offCtx.fillText(line.text, line.x, line.baseline);
|
||||
ctx.globalAlpha = 0.25 + 0.75 * intro;
|
||||
ctx.drawImage(off, 0, 0, width, height);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loop = (now: number) => {
|
||||
if (disposed) return;
|
||||
if (now - lastFrame >= FRAME_MS) {
|
||||
lastFrame = now;
|
||||
paint(now);
|
||||
}
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
const restart = () => {
|
||||
cancelAnimationFrame(raf);
|
||||
measure();
|
||||
// Hidden at this breakpoint (display: none): nothing to draw.
|
||||
if (width === 0 || height === 0) return;
|
||||
if (reducedMotion) {
|
||||
paint(performance.now());
|
||||
return;
|
||||
}
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
const box = wrap.getBoundingClientRect();
|
||||
pointer = { x: event.clientX - box.left, y: event.clientY - box.top };
|
||||
};
|
||||
const onPointerLeave = () => {
|
||||
pointer = null;
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => restart());
|
||||
|
||||
document.fonts.ready.then(() => {
|
||||
if (disposed) return;
|
||||
startedAt = performance.now();
|
||||
restart();
|
||||
setPainted(true);
|
||||
resizeObserver.observe(wrap);
|
||||
if (!reducedMotion) {
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerleave", onPointerLeave);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cancelAnimationFrame(raf);
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerleave", onPointerLeave);
|
||||
};
|
||||
}, [lines, INK, PAPER, invert]);
|
||||
|
||||
return (
|
||||
<Tag ref={wrapRef} className={`relative m-0 ${className}`}>
|
||||
{lines.map((line) => (
|
||||
<span
|
||||
key={line.text}
|
||||
data-bit-line
|
||||
className={`block w-fit whitespace-nowrap transition-colors duration-300 ${
|
||||
painted
|
||||
? "text-transparent"
|
||||
: line.tone === "solid"
|
||||
? invert
|
||||
? "text-white"
|
||||
: "text-slate-900"
|
||||
: invert
|
||||
? "text-slate-600"
|
||||
: "text-slate-300"
|
||||
}`}
|
||||
>
|
||||
{line.text}
|
||||
</span>
|
||||
))}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
/>
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from "react";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
|
||||
interface BlockProps {
|
||||
/** Two-digit section index, shown with its binary form. */
|
||||
index: string;
|
||||
label: string;
|
||||
title: React.ReactNode;
|
||||
id?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block: one section of the homepage. A single left axis, one kicker line,
|
||||
* one oversized headline, then the content. No sidebars.
|
||||
*/
|
||||
export const Block: React.FC<BlockProps> = ({
|
||||
index,
|
||||
label,
|
||||
title,
|
||||
id,
|
||||
children,
|
||||
}) => (
|
||||
<section
|
||||
id={id}
|
||||
className="relative scroll-mt-24 border-t border-slate-200 bg-white"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[1400px] px-5 py-20 md:px-12 md:py-36">
|
||||
<Reveal>
|
||||
<div className="mb-8 flex items-center gap-4 md:mb-12">
|
||||
<span className="font-mono text-[11px] font-bold tracking-[0.2em] text-slate-900">
|
||||
{index}
|
||||
</span>
|
||||
<span className="h-px w-10 bg-slate-300" />
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className="ml-auto hidden font-mono text-[10px] tracking-[0.3em] text-slate-300 md:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{parseInt(index, 10).toString(2).padStart(8, "0")}
|
||||
</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.1}>
|
||||
<h2 className="m-0 max-w-6xl text-4xl font-bold leading-[0.95] tracking-tighter text-slate-900 md:text-7xl lg:text-[6.5rem]">
|
||||
{title}
|
||||
</h2>
|
||||
</Reveal>
|
||||
<div className="mt-14 md:mt-24">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Container } from "@/src/components/site";
|
||||
|
||||
type Reference = {
|
||||
readonly name: string;
|
||||
readonly href: string;
|
||||
readonly built: string;
|
||||
};
|
||||
|
||||
const REFERENCES: readonly Reference[] = [
|
||||
{
|
||||
name: "klz-cables.com",
|
||||
href: "https://klz-cables.com",
|
||||
built:
|
||||
"multilingual (DE/EN) Next.js site with a headless CMS and generated PDF datasheets",
|
||||
},
|
||||
{
|
||||
name: "e-tib.com",
|
||||
href: "https://e-tib.com",
|
||||
built:
|
||||
"multilingual Next.js site with structured data, automated QA and CI/CD",
|
||||
},
|
||||
];
|
||||
|
||||
export const BuiltAndRunning: React.FC = () => (
|
||||
<section className="border-b border-border py-10 md:py-14">
|
||||
<Container>
|
||||
<p className="!mb-0 text-sm leading-relaxed text-muted md:text-base">
|
||||
<span className="mr-2 font-mono text-xs uppercase tracking-widest text-accent">
|
||||
Built and running
|
||||
</span>
|
||||
{REFERENCES.map((ref, index) => (
|
||||
<span key={ref.name}>
|
||||
{index > 0 ? " · " : null}
|
||||
<a
|
||||
href={ref.href}
|
||||
rel="noopener"
|
||||
className="underline underline-offset-4 hover:text-accent"
|
||||
>
|
||||
{ref.name}
|
||||
</a>{" "}
|
||||
{ref.built}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Container, SectionHeader } from "@/src/components/site";
|
||||
import { ENGAGEMENT_BLOCKS } from "@/src/content/site";
|
||||
|
||||
export const CapacityBlocks: React.FC = () => (
|
||||
<section className="border-b border-border py-14 md:py-20">
|
||||
<Container>
|
||||
<SectionHeader
|
||||
kicker="capacity blocks"
|
||||
title="Book weekly capacity"
|
||||
lead="You reserve hours per week for a month at a time. No fixed-scope surprises."
|
||||
/>
|
||||
<ul className="!m-0 mt-10 grid list-none gap-4 !p-0 md:grid-cols-3">
|
||||
{ENGAGEMENT_BLOCKS.map((block) => (
|
||||
<li
|
||||
key={block.name}
|
||||
className="!mb-0 rounded-lg border border-border bg-surface p-5"
|
||||
>
|
||||
<h3 className="!mb-1 !mt-0 !text-lg !leading-snug md:!text-xl lg:!text-xl">
|
||||
{block.name}
|
||||
</h3>
|
||||
<p className="!mb-4 font-mono text-sm text-muted">
|
||||
{block.hoursPerWeek}
|
||||
</p>
|
||||
<p className="!mb-0 font-mono text-lg font-semibold text-fg">
|
||||
{block.pricePerMonth}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="!mb-0 mt-6 text-sm text-muted">
|
||||
2-week notice, transparent time tracking, no fixed-scope surprises.
|
||||
</p>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from "react";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import { EmailLink } from "@/src/components/EmailLink";
|
||||
import { BitHeadline, type BitLine } from "./BitHeadline";
|
||||
|
||||
const CLOSING: readonly BitLine[] = [
|
||||
{ text: "LET'S", tone: "bits" },
|
||||
{ text: "TALK.", tone: "solid" },
|
||||
];
|
||||
|
||||
/** ClosingCta: the contact section. One address, no forms, no calendar. */
|
||||
export const ClosingCta: React.FC = () => (
|
||||
<section
|
||||
id="contact"
|
||||
className="relative border-t border-slate-200 bg-slate-900 text-white"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[1400px] px-5 py-24 md:px-12 md:py-44">
|
||||
<Reveal>
|
||||
<BitHeadline
|
||||
as="h2"
|
||||
invert
|
||||
lines={CLOSING}
|
||||
className="font-sans text-[19vw] font-black leading-[0.86] tracking-tighter md:text-[13vw] xl:text-[12rem]"
|
||||
/>
|
||||
<p className="m-0 mt-10 max-w-2xl font-sans text-xl leading-snug text-slate-300 md:text-3xl">
|
||||
Write me a few lines about what you need. We then arrange a short
|
||||
call.
|
||||
</p>
|
||||
<div className="mt-12">
|
||||
<EmailLink className="inline-block border-b-2 border-white pb-2 font-sans text-3xl font-bold tracking-tight text-white no-underline transition-opacity hover:opacity-70 md:text-6xl" />
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
|
||||
const QUESTIONS = [
|
||||
{
|
||||
q: "I am not technical. Is that a problem?",
|
||||
a: "No. I explain what I am doing in plain language, and you can ask at any time. You do not need to read code.",
|
||||
},
|
||||
{
|
||||
q: "How long am I committed?",
|
||||
a: "Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.",
|
||||
},
|
||||
{
|
||||
q: "Who owns what you build?",
|
||||
a: "You do. Code and design belong to you, and you can hand them to another developer.",
|
||||
},
|
||||
{
|
||||
q: "Can you work with our existing team and code?",
|
||||
a: "Yes. I worked in agencies and product teams for years, so joining an existing project is familiar to me.",
|
||||
},
|
||||
{
|
||||
q: "How do I reach you?",
|
||||
a: "By email or video call. I work remotely and am available during European business hours.",
|
||||
},
|
||||
{
|
||||
q: "What do you not do?",
|
||||
a: "Mobile apps for the app stores, Windows software and running your servers. If your project needs those, I say so in the first call.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const Faq: React.FC = () => (
|
||||
<div className="grid grid-cols-1 gap-x-12 md:grid-cols-2">
|
||||
{QUESTIONS.map((item) => (
|
||||
<details
|
||||
key={item.q}
|
||||
className="group border-t border-slate-200 py-6 open:pb-8"
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-start justify-between gap-6 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl [&::-webkit-details-marker]:hidden">
|
||||
{item.q}
|
||||
<span
|
||||
className="mt-1 shrink-0 font-mono text-xl text-slate-400 transition-transform duration-300 group-open:rotate-45"
|
||||
aria-hidden="true"
|
||||
>
|
||||
+
|
||||
</span>
|
||||
</summary>
|
||||
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500 md:text-lg">
|
||||
{item.a}
|
||||
</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
import { ButtonLink, Container } from "@/src/components/site";
|
||||
|
||||
export const FinalCta: React.FC = () => (
|
||||
<section className="py-16 md:py-24">
|
||||
<Container>
|
||||
<h2 className="!mb-4 !mt-0 !text-3xl !leading-tight md:!text-4xl">
|
||||
Tell me what you are building
|
||||
</h2>
|
||||
<p className="!mb-8 max-w-xl text-base text-muted md:text-lg">
|
||||
A 20 minute call is enough to see whether a capacity block fits.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<ButtonLink href="/book">Book a 20 min call</ButtonLink>
|
||||
<ButtonLink href="/notes" variant="secondary">
|
||||
Read the notes
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
@@ -1,58 +0,0 @@
|
||||
import { ButtonLink, Container, TerminalCard } from "@/src/components/site";
|
||||
import { STACK } from "@/src/content/site";
|
||||
import { AvailabilityPill } from "./AvailabilityPill";
|
||||
|
||||
const REPORT_LINES: readonly { label: string; value: string }[] = [
|
||||
{ label: "spec", value: "12 cases written before code" },
|
||||
{ label: "red", value: "12 failing for the right reason" },
|
||||
{ label: "green", value: "12 passing" },
|
||||
{ label: "mutation", value: "changed files checked, survivors fixed" },
|
||||
{ label: "coverage", value: "changed lines reviewed for gaps" },
|
||||
];
|
||||
|
||||
export const Hero: React.FC = () => (
|
||||
<section className="border-b border-border py-14 md:py-24">
|
||||
<Container>
|
||||
<div className="grid gap-12 lg:grid-cols-2 lg:items-center">
|
||||
<div>
|
||||
<div className="mb-6 min-h-7">
|
||||
<AvailabilityPill />
|
||||
</div>
|
||||
<h1 className="!mb-6 !text-4xl !leading-[1.1] md:!text-6xl">
|
||||
AI makes code cheap.
|
||||
<br />
|
||||
<span className="text-accent">I make it correct.</span>
|
||||
</h1>
|
||||
<p className="!mb-8 max-w-xl text-base leading-relaxed text-muted md:text-lg">
|
||||
Senior engineer for teams that build fast with AI and don't
|
||||
want chaos. Spec-first, test-driven, mutation-checked. Booked in
|
||||
weekly capacity blocks.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<ButtonLink href="/book">Book a 20 min call</ButtonLink>
|
||||
<ButtonLink href="/process" variant="secondary">
|
||||
See how I ship
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
<TerminalCard title="delivery-report (illustrative example)">
|
||||
<ul className="!m-0 list-none !p-0">
|
||||
{REPORT_LINES.map((line) => (
|
||||
<li key={line.label} className="!mb-1 flex gap-3">
|
||||
<span className="w-20 shrink-0 text-accent">{line.label}</span>
|
||||
<span className="text-muted">{line.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="!mb-0 !mt-4 text-xs text-faint">
|
||||
Example format. Every real delivery lists the exact commands and
|
||||
their results.
|
||||
</p>
|
||||
</TerminalCard>
|
||||
</div>
|
||||
<p className="!mb-0 mt-12 font-mono text-xs text-faint md:text-sm">
|
||||
{STACK.join(" · ")}
|
||||
</p>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as React from "react";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { AbstractCircuit } from "@/src/components/Effects";
|
||||
import { BitHeadline, type BitLine } from "./BitHeadline";
|
||||
|
||||
const HEADLINE: readonly BitLine[] = [
|
||||
{ text: "WEB APPS", tone: "solid" },
|
||||
{ text: "& WEBSITES.", tone: "bits" },
|
||||
];
|
||||
|
||||
const TRUST = ["15 years in web", "Remote, EU core hours"] as const;
|
||||
|
||||
export const HeroStatement: React.FC = () => (
|
||||
<section className="relative overflow-hidden bg-white">
|
||||
<div className="absolute inset-0 opacity-70">
|
||||
<AbstractCircuit />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mx-auto flex min-h-[calc(100dvh-5rem)] w-full max-w-[1400px] flex-col justify-center px-5 py-16 md:px-12 md:py-24">
|
||||
<BitHeadline
|
||||
lines={HEADLINE}
|
||||
className="font-sans text-[12.5vw] font-black leading-[0.88] tracking-tighter md:text-[11.5vw] xl:text-[10.5rem]"
|
||||
/>
|
||||
|
||||
<div className="mt-10 grid grid-cols-1 gap-10 md:mt-14 md:grid-cols-12 md:items-end md:gap-12">
|
||||
<p className="m-0 max-w-2xl font-sans text-xl leading-snug text-slate-500 md:col-span-7 md:text-3xl">
|
||||
Freelance developer with 15 years of experience.{" "}
|
||||
<span className="text-slate-900">
|
||||
I build web apps, interfaces and websites.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4 md:col-span-5 md:items-end">
|
||||
<div
|
||||
id="hero-cta"
|
||||
className="flex w-full flex-col gap-4 sm:flex-row md:w-auto"
|
||||
>
|
||||
<Button href="#contact" size="large" className="w-full sm:w-auto">
|
||||
Get in touch
|
||||
</Button>
|
||||
<Button
|
||||
href="#hello"
|
||||
variant="outline"
|
||||
size="large"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
What I do
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="m-0 mt-14 flex list-none flex-wrap gap-x-8 gap-y-3 border-t border-slate-200 p-0 pt-6 md:mt-20">
|
||||
{TRUST.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]"
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
<li className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]">
|
||||
Built and running:{" "}
|
||||
<a
|
||||
href="https://klz-cables.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-slate-900 underline-offset-4 hover:underline"
|
||||
>
|
||||
klz-cables.com
|
||||
</a>
|
||||
{" · "}
|
||||
<a
|
||||
href="https://e-tib.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-slate-900 underline-offset-4 hover:underline"
|
||||
>
|
||||
e-tib.com
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -6,7 +6,7 @@ const STRUCTURED_DATA = {
|
||||
name: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
description:
|
||||
"Senior freelance engineer for teams that build fast with AI: web apps, AI-code rescue, automation and Web3 frontends.",
|
||||
"Senior freelance developer for web apps, interfaces and websites: weekly capacity for product teams, making unreliable apps dependable, and websites built end to end.",
|
||||
founder: { "@type": "Person", name: SITE_NAME },
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Container, SectionHeader } from "@/src/components/site";
|
||||
|
||||
const STEPS: readonly { name: string; text: string }[] = [
|
||||
{
|
||||
name: "Spec first",
|
||||
text: "The behavior is written down as concrete cases before any code.",
|
||||
},
|
||||
{
|
||||
name: "Red",
|
||||
text: "The tests run and fail for the right reason: missing behavior.",
|
||||
},
|
||||
{
|
||||
name: "Green",
|
||||
text: "The simplest clean implementation that makes them pass.",
|
||||
},
|
||||
{
|
||||
name: "Mutation check",
|
||||
text: "Mutation testing on the changed code shows whether the tests really catch bugs.",
|
||||
},
|
||||
{
|
||||
name: "Delivery report",
|
||||
text: "Every pull request lists the exact commands that were run and their results.",
|
||||
},
|
||||
];
|
||||
|
||||
export const HowIShip: React.FC = () => (
|
||||
<section className="border-b border-border py-14 md:py-20">
|
||||
<Container>
|
||||
<SectionHeader
|
||||
kicker="how I ship"
|
||||
title="Fast with AI, checked by tests"
|
||||
lead="AI writes the first draft. These five steps decide whether it ships."
|
||||
/>
|
||||
<ol className="!m-0 mt-10 grid list-none gap-4 !p-0 md:grid-cols-5">
|
||||
{STEPS.map((step, index) => (
|
||||
<li
|
||||
key={step.name}
|
||||
className="!mb-0 rounded-lg border border-border bg-surface p-4"
|
||||
>
|
||||
<p className="!mb-2 font-mono text-xs text-accent">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</p>
|
||||
<h3 className="!mb-2 !mt-0 !text-base !leading-snug md:!text-base lg:!text-base">
|
||||
{step.name}
|
||||
</h3>
|
||||
<p className="!mb-0 text-sm text-muted">{step.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="!mb-0 mt-6 text-sm">
|
||||
<Link
|
||||
href="/process"
|
||||
className="underline underline-offset-4 hover:text-accent"
|
||||
>
|
||||
Read the full process and a sample report
|
||||
</Link>
|
||||
</p>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { PathScene } from "@/src/components/illustrations/PathScenes";
|
||||
import { SERVICE_PATHS, type ServicePath } from "@/src/content/paths";
|
||||
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
|
||||
const STEP_MS = 4800;
|
||||
|
||||
/**
|
||||
* PathStory: the four steps of one path, each with a drawing that explains it.
|
||||
* The steps advance on their own until the visitor picks one.
|
||||
*/
|
||||
const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
|
||||
path,
|
||||
active,
|
||||
}) => {
|
||||
const [step, setStep] = React.useState(0);
|
||||
const [auto, setAuto] = React.useState(true);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const [inView, setInView] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setInView(entry?.isIntersecting ?? false),
|
||||
{ threshold: 0.35 },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active || !auto || !inView) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
const timer = window.setInterval(
|
||||
() => setStep((current) => (current + 1) % path.steps.length),
|
||||
STEP_MS,
|
||||
);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active, auto, inView, path.steps.length]);
|
||||
|
||||
const current = path.steps[step];
|
||||
|
||||
return (
|
||||
<div ref={ref} className="grid grid-cols-1 gap-8 md:grid-cols-12 md:gap-12">
|
||||
<div className="md:order-2 md:col-span-7">
|
||||
<div className="relative overflow-hidden rounded-3xl bg-slate-900 md:sticky md:top-28">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-300">
|
||||
{current?.title}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-500">
|
||||
{step + 1} / {path.steps.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-2 py-4 md:px-10 md:py-8">
|
||||
<PathScene
|
||||
pathId={path.id}
|
||||
step={step}
|
||||
title={current ? `${current.title}. ${current.text}` : path.need}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol className="m-0 list-none p-0 md:order-1 md:col-span-5">
|
||||
{path.steps.map((item, index) => {
|
||||
const selected = index === step;
|
||||
return (
|
||||
<li key={item.title}>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={selected ? "step" : undefined}
|
||||
onClick={() => {
|
||||
setStep(index);
|
||||
setAuto(false);
|
||||
}}
|
||||
className={`relative flex w-full gap-5 border-t py-6 text-left transition-colors duration-300 md:gap-6 ${
|
||||
selected ? "border-slate-900" : "border-slate-200"
|
||||
}`}
|
||||
>
|
||||
{selected && auto && inView && (
|
||||
<motion.span
|
||||
key={`${path.id}-${index}`}
|
||||
className="absolute left-0 top-[-1px] h-px bg-slate-900"
|
||||
initial={{ width: "0%" }}
|
||||
animate={{ width: "100%" }}
|
||||
transition={{ duration: STEP_MS / 1000, ease: "linear" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`w-8 shrink-0 font-sans text-3xl font-black leading-none tracking-tighter transition-colors duration-300 md:text-4xl ${
|
||||
selected ? "text-slate-900" : "text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span>
|
||||
<span
|
||||
className={`block font-sans text-lg font-bold tracking-tight transition-colors duration-300 md:text-xl ${
|
||||
selected ? "text-slate-900" : "text-slate-500"
|
||||
}`}
|
||||
>
|
||||
{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"
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* PathPicker: the visitor says what they need and gets the answer for exactly that:
|
||||
* what they get, how it runs, what it costs, what to do next.
|
||||
* All three panels stay in the DOM so the content is readable without JavaScript.
|
||||
*/
|
||||
export const PathPicker: React.FC = () => {
|
||||
const [activeId, setActiveId] = React.useState(SERVICE_PATHS[0]?.id ?? "");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="What do you need?"
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4"
|
||||
>
|
||||
{SERVICE_PATHS.map((path, index) => {
|
||||
const selected = path.id === activeId;
|
||||
return (
|
||||
<button
|
||||
key={path.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`path-tab-${path.id}`}
|
||||
aria-selected={selected}
|
||||
aria-controls={`path-panel-${path.id}`}
|
||||
onClick={() => setActiveId(path.id)}
|
||||
className={`group flex h-full flex-col justify-between rounded-3xl border p-5 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"
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
{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"}`}
|
||||
>
|
||||
{path.forWhom}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{SERVICE_PATHS.map((path) => {
|
||||
const selected = path.id === activeId;
|
||||
return (
|
||||
<div
|
||||
key={path.id}
|
||||
role="tabpanel"
|
||||
id={`path-panel-${path.id}`}
|
||||
aria-labelledby={`path-tab-${path.id}`}
|
||||
hidden={!selected}
|
||||
className="mt-12 md:mt-20"
|
||||
>
|
||||
<motion.div
|
||||
key={selected ? "on" : "off"}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-12 md:grid-cols-12 md:gap-12">
|
||||
<div className="md:col-span-7">
|
||||
<h3 className="m-0 font-sans text-3xl font-bold leading-[1.05] tracking-tighter text-slate-900 md:text-5xl">
|
||||
{path.headline}
|
||||
</h3>
|
||||
<p className="m-0 mt-6 max-w-2xl font-sans text-lg leading-relaxed text-slate-500 md:text-xl">
|
||||
{path.intro}
|
||||
</p>
|
||||
</div>
|
||||
<div className="md:col-span-5">
|
||||
<p className={`${MONO} m-0`}>What you get</p>
|
||||
<ul className="m-0 mt-4 list-none p-0">
|
||||
{path.youGet.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="flex gap-4 border-t border-slate-200 py-4 font-sans text-base text-slate-900 md:text-lg"
|
||||
>
|
||||
<span
|
||||
className="mt-2.5 h-1.5 w-1.5 shrink-0 rounded-full bg-slate-900"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 md:mt-24">
|
||||
<p className={`${MONO} m-0 mb-6`}>How it runs</p>
|
||||
<PathStory path={path} active={selected} />
|
||||
</div>
|
||||
|
||||
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-16 md:p-10">
|
||||
<p className={`${MONO} m-0`}>What it costs</p>
|
||||
<div className="mt-6 grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12">
|
||||
<div className="md:col-span-6">
|
||||
<p className="m-0 font-sans text-2xl font-bold leading-tight tracking-tight text-slate-900 md:text-4xl">
|
||||
{path.pricingModel}
|
||||
</p>
|
||||
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500 md:text-lg">
|
||||
{path.pricingText}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 md:col-span-6">
|
||||
{path.prices.map((price) => (
|
||||
<div key={price.label}>
|
||||
<p className="m-0 font-sans text-base text-slate-500 md:text-lg">
|
||||
{price.label}
|
||||
</p>
|
||||
<p className="m-0 mt-2 font-sans text-3xl font-black tracking-tighter text-slate-900 md:text-5xl">
|
||||
{price.amount}
|
||||
</p>
|
||||
<p className={`${MONO} m-0 mt-2`}>{price.note}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-10 flex flex-col gap-6 border-t border-slate-200 pt-8 md:flex-row md:items-center md:justify-between">
|
||||
<p className="m-0 max-w-2xl font-sans text-base leading-relaxed text-slate-500">
|
||||
{path.smallPrint}
|
||||
</p>
|
||||
<Button href="#contact" size="large">
|
||||
{path.cta}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
/**
|
||||
* PortraitSection: the introduction, with the portrait fading out of a dark surface
|
||||
* and the text set over it.
|
||||
*/
|
||||
export const PortraitSection: React.FC<{
|
||||
id?: string;
|
||||
index: string;
|
||||
label: string;
|
||||
title: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}> = ({ id, index, label, title, children }) => (
|
||||
<section
|
||||
id={id}
|
||||
className="relative scroll-mt-24 overflow-hidden bg-slate-900 text-white"
|
||||
>
|
||||
{/* Portrait: right side on wide screens, top on small ones. */}
|
||||
<motion.div
|
||||
className="relative h-[110vw] w-full md:absolute md:inset-y-0 md:right-0 md:h-auto md:w-[62%]"
|
||||
initial={{ opacity: 0, scale: 1.06 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true, margin: "-10%" }}
|
||||
transition={{ duration: 1.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<Image
|
||||
src="/marc-mintel.png"
|
||||
alt="Portrait of Marc Mintel"
|
||||
fill
|
||||
sizes="(min-width: 768px) 62vw, 100vw"
|
||||
className="object-cover object-[50%_18%] grayscale"
|
||||
priority={false}
|
||||
/>
|
||||
{/* Fades: into the surface on the left, and at top and bottom. */}
|
||||
<div className="absolute inset-0 hidden bg-gradient-to-r from-slate-900 via-slate-900/75 to-slate-900/10 md:block" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-2/5 bg-gradient-to-t from-slate-900 to-transparent" />
|
||||
<div className="absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-slate-900 to-transparent" />
|
||||
</motion.div>
|
||||
|
||||
<div className="relative z-10 mx-auto w-full max-w-[1400px] px-5 pb-20 md:px-12 md:py-40">
|
||||
<motion.div
|
||||
className="-mt-24 max-w-3xl md:mt-0"
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-10%" }}
|
||||
transition={{ duration: 0.8, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<div className="mb-8 flex items-center gap-4 md:mb-12">
|
||||
<span className="font-mono text-[11px] font-bold tracking-[0.2em] text-white">
|
||||
{index}
|
||||
</span>
|
||||
<span className="h-px w-10 bg-slate-600" />
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-400">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="m-0 text-5xl font-bold leading-[0.95] tracking-tighter text-white md:text-7xl lg:text-[6.5rem]">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="mt-10 md:mt-14">{children}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Container, Stat } from "@/src/components/site";
|
||||
|
||||
const FACTS: readonly { value: string; label: string }[] = [
|
||||
{ value: "15 years", label: "in web, apprenticeship from 2011" },
|
||||
{ value: "Team lead", label: "frontend team lead at an agency" },
|
||||
{ value: "Since 2021", label: "working fully remote" },
|
||||
{
|
||||
value: "7 chains",
|
||||
label: "Rust + Solidity DeFi system with a zero-survivor mutation gate",
|
||||
},
|
||||
];
|
||||
|
||||
export const ProofStrip: React.FC = () => (
|
||||
<section aria-label="Background" className="border-b border-border py-10">
|
||||
<Container>
|
||||
<div className="grid grid-cols-2 gap-8 lg:grid-cols-4">
|
||||
{FACTS.map((fact) => (
|
||||
<Stat key={fact.value} value={fact.value} label={fact.label} />
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* StickyBook: a quiet contact button (wide screens only; on phones the sticky header
|
||||
* already carries the same button) that appears once the hero call-to-action has
|
||||
* scrolled away, and steps aside again when the closing section is in view.
|
||||
*/
|
||||
export const StickyBook: React.FC = () => {
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const hero = document.getElementById("hero-cta");
|
||||
const closing = document.getElementById("contact");
|
||||
if (!hero || !closing) return;
|
||||
|
||||
const seen = new Map<Element, boolean>();
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) seen.set(entry.target, entry.isIntersecting);
|
||||
setVisible(!seen.get(hero) && !seen.get(closing));
|
||||
});
|
||||
observer.observe(hero);
|
||||
observer.observe(closing);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-x-0 bottom-6 z-[90] hidden justify-center px-4 transition-all duration-500 md:flex ${
|
||||
visible
|
||||
? "translate-y-0 opacity-100"
|
||||
: "pointer-events-none translate-y-6 opacity-0"
|
||||
}`}
|
||||
aria-hidden={!visible}
|
||||
>
|
||||
<a
|
||||
href="#contact"
|
||||
tabIndex={visible ? 0 : -1}
|
||||
className="flex items-center gap-3 rounded-full bg-slate-900 px-7 py-4 font-sans text-[11px] font-bold uppercase tracking-[0.2em] text-white no-underline shadow-2xl shadow-slate-900/20 transition-transform duration-300 hover:-translate-y-0.5"
|
||||
>
|
||||
Get in touch <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Container, SectionHeader } from "@/src/components/site";
|
||||
|
||||
const SPRINT_POINTS: readonly string[] = [
|
||||
"Time-boxed sprint with a clear scope list, from 3,800 EUR. Extra work is a follow-up block, not open scope.",
|
||||
"Static Next.js site: no CMS and no server for you to run.",
|
||||
"Hosting, SSL and domain renewal are included in a flat monthly plan.",
|
||||
"Changes by email, no CMS training.",
|
||||
];
|
||||
|
||||
export const WebsitesSection: React.FC = () => (
|
||||
<section className="border-b border-border py-14 md:py-20">
|
||||
<Container>
|
||||
<SectionHeader
|
||||
kicker="websites"
|
||||
title="Websites, handled end to end"
|
||||
lead="For clients who want a site and no involvement in the technical side."
|
||||
/>
|
||||
<ul className="!m-0 mt-8 max-w-2xl list-none !p-0">
|
||||
{SPRINT_POINTS.map((point) => (
|
||||
<li key={point} className="!mb-3 flex gap-3 text-base text-muted">
|
||||
<span aria-hidden="true" className="font-mono text-accent">
|
||||
+
|
||||
</span>
|
||||
<span>{point}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="!mb-0 mt-4 max-w-2xl font-mono text-sm text-faint">
|
||||
Not part of it: email and DNS administration.
|
||||
</p>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Container, SectionHeader } from "@/src/components/site";
|
||||
|
||||
const OFFERS: readonly { title: string; text: string }[] = [
|
||||
{
|
||||
title: "Web and web apps",
|
||||
text: "Next.js and TypeScript with strong design and frontend work, plus light backend for APIs, auth and integrations.",
|
||||
},
|
||||
{
|
||||
title: "AI-code rescue",
|
||||
text: "A 1-week entry block: audit of an AI-built codebase, test foundation, mutation baseline and a prioritized report.",
|
||||
},
|
||||
{
|
||||
title: "CLI tools and automation",
|
||||
text: "Command-line tools and automation, including AI agents and MCP servers.",
|
||||
},
|
||||
{
|
||||
title: "Web3 frontends and systems",
|
||||
text: "Wallet flows, dashboards and WebSockets, backed by a Rust and Solidity background.",
|
||||
},
|
||||
];
|
||||
|
||||
export const WhatIDo: React.FC = () => (
|
||||
<section className="border-b border-border py-14 md:py-20">
|
||||
<Container>
|
||||
<SectionHeader kicker="what I do" title="What you can book" />
|
||||
<ul className="!m-0 mt-10 grid list-none gap-4 !p-0 sm:grid-cols-2">
|
||||
{OFFERS.map((offer) => (
|
||||
<li
|
||||
key={offer.title}
|
||||
className="!mb-0 rounded-lg border border-border bg-surface p-5"
|
||||
>
|
||||
<h3 className="!mb-2 !mt-0 !text-lg !leading-snug md:!text-xl lg:!text-xl">
|
||||
{offer.title}
|
||||
</h3>
|
||||
<p className="!mb-0 text-sm text-muted md:text-base">
|
||||
{offer.text}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="!mb-0 mt-6 font-mono text-sm text-faint">
|
||||
Not offered: Windows apps, mobile apps, running your infrastructure.
|
||||
</p>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
Reference in New Issue
Block a user