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:
2026-10-03 22:33:06 +02:00
co-authored by Claude Opus 5.5
parent 4eb663f42a
commit fa40fefe2f
781 changed files with 4925 additions and 55137 deletions
+71
View File
@@ -0,0 +1,71 @@
import type { CareerStation } from "@/src/components/illustrations/CareerLine";
/** The career in four stations. Facts from the owner's CV, deliberately summarised. */
export const CAREER: readonly CareerStation[] = [
{ years: "2011", role: "Started in the web", place: "Apprenticeship" },
{
years: "2014 – 2020",
role: "Agencies",
place: "Frontend developer",
},
{
years: "2020 – 2023",
role: "Product companies",
place: "Senior software developer",
},
{
years: "Today",
role: "Independent",
place: "Working directly with clients",
},
];
/** Which projects I am a good fit for, and which not. */
export const FIT = [
{
title: "A good fit",
items: [
"Web apps: dashboards, portals, internal tools",
"Interfaces where design and usability matter",
"Company websites, also in several languages",
"Web3 frontends: wallets, live data, dashboards",
"Automation: connecting systems and their APIs",
],
},
{
title: "Also possible",
items: [
"The backend an app needs: APIs, login, integrations",
"Command-line tools",
"Taking over and tidying up existing code",
],
},
{
title: "Not my field",
items: [
"Mobile apps for the app stores",
"Windows software",
"Running servers, specialised database work",
],
},
] as const;
/** Technologies I work with. Only what I have actually used in projects. */
export const TECH = [
{
area: "Interface",
items: ["TypeScript", "React", "Next.js", "Vue.js", "Tailwind CSS"],
},
{
area: "Behind it",
items: ["Node.js", "REST APIs", "PostgreSQL", "Headless CMS"],
},
{
area: "Web3 and systems",
items: ["Solidity", "Rust"],
},
{
area: "Delivery",
items: ["Automated tests", "Docker", "CI/CD"],
},
] as const;
+322
View File
@@ -0,0 +1,322 @@
/**
* Example workflows for the automation section. They are illustrations of typical
* cases, not descriptions of specific client projects.
*/
export type NodeKind = "trigger" | "rule" | "ai" | "action" | "human";
export type FlowNode = {
readonly id: string;
readonly kind: NodeKind;
/** Position of the node centre on the wide 920 x 400 canvas. */
readonly x: number;
readonly y: number;
/** Position of the node centre on the tall 400 x 540 canvas for phones. */
readonly nx: number;
readonly ny: number;
readonly system: string;
readonly label: string;
/** What appears in the log when a run reaches this node. */
readonly log: string;
};
/** One step of a run: the connections travelled at the same time. */
export type FlowStep = readonly (readonly [from: string, to: string])[];
export type Workflow = {
readonly id: string;
readonly name: string;
readonly summary: string;
readonly byHand: string;
readonly automated: string;
readonly nodes: readonly FlowNode[];
/** Every connection that exists, for drawing. */
readonly edges: readonly (readonly [from: string, to: string])[];
/** The runs that play in turn. Each run is a list of steps. */
readonly runs: readonly (readonly FlowStep[])[];
};
export const WORKFLOWS: readonly Workflow[] = [
{
id: "order",
name: "Order to invoice",
summary:
"A new order arrives in the shop. The invoice, the customer record and the confirmation follow without anyone typing.",
byHand:
"Someone opens the order, writes the invoice, updates the customer record and sends an email.",
automated:
"The order triggers everything. People only see the finished result.",
nodes: [
{
id: "shop",
kind: "trigger",
x: 110,
y: 200,
nx: 200,
ny: 60,
system: "Shop",
label: "New order",
log: "order received",
},
{
id: "stock",
kind: "rule",
x: 340,
y: 200,
nx: 200,
ny: 190,
system: "Rule",
label: "In stock?",
log: "stock checked: available",
},
{
id: "invoice",
kind: "action",
x: 580,
y: 110,
nx: 105,
ny: 330,
system: "Accounting",
label: "Create invoice",
log: "invoice created",
},
{
id: "crm",
kind: "action",
x: 580,
y: 290,
nx: 295,
ny: 330,
system: "CRM",
label: "Update customer",
log: "customer record updated",
},
{
id: "mail",
kind: "action",
x: 810,
y: 200,
nx: 200,
ny: 470,
system: "Email",
label: "Send confirmation",
log: "confirmation sent",
},
],
edges: [
["shop", "stock"],
["stock", "invoice"],
["stock", "crm"],
["invoice", "mail"],
["crm", "mail"],
],
runs: [
[
[["shop", "stock"]],
[
["stock", "invoice"],
["stock", "crm"],
],
[
["invoice", "mail"],
["crm", "mail"],
],
],
],
},
{
id: "documents",
name: "Incoming documents",
summary:
"Invoices arrive as PDF by email. An AI step reads them, a rule checks the result, and a person only sees the unclear ones.",
byHand:
"Someone opens every PDF, reads supplier, amount and date, and types them into the accounting system.",
automated:
"Clear cases are booked directly. Unclear ones go to a person with the document attached.",
nodes: [
{
id: "inbox",
kind: "trigger",
x: 110,
y: 200,
nx: 200,
ny: 60,
system: "Inbox",
label: "Email with PDF",
log: "document received",
},
{
id: "read",
kind: "ai",
x: 340,
y: 200,
nx: 200,
ny: 190,
system: "AI step",
label: "Read the document",
log: "supplier, amount and date extracted",
},
{
id: "check",
kind: "rule",
x: 570,
y: 200,
nx: 200,
ny: 320,
system: "Rule",
label: "Plausible?",
log: "values checked",
},
{
id: "book",
kind: "action",
x: 810,
y: 110,
nx: 105,
ny: 460,
system: "Accounting",
label: "Book it",
log: "booked automatically",
},
{
id: "ask",
kind: "human",
x: 810,
y: 290,
nx: 295,
ny: 460,
system: "A person",
label: "Review and decide",
log: "unclear: sent to a person for review",
},
],
edges: [
["inbox", "read"],
["read", "check"],
["check", "book"],
["check", "ask"],
],
runs: [
[[["inbox", "read"]], [["read", "check"]], [["check", "book"]]],
[[["inbox", "read"]], [["read", "check"]], [["check", "book"]]],
[[["inbox", "read"]], [["read", "check"]], [["check", "ask"]]],
],
},
{
id: "lead",
name: "Enquiry to follow-up",
summary:
"Someone fills in the contact form. The contact is created, the right person is told, and a reminder makes sure nobody forgets.",
byHand:
"Someone copies the enquiry into the CRM, forwards it by email and hopes the follow-up is not forgotten.",
automated: "The enquiry is filed, assigned and followed up on schedule.",
nodes: [
{
id: "form",
kind: "trigger",
x: 110,
y: 200,
nx: 200,
ny: 60,
system: "Website",
label: "Contact form",
log: "enquiry received",
},
{
id: "contact",
kind: "action",
x: 340,
y: 200,
nx: 200,
ny: 190,
system: "CRM",
label: "Create contact",
log: "contact created",
},
{
id: "notify",
kind: "action",
x: 580,
y: 110,
nx: 105,
ny: 330,
system: "Team chat",
label: "Notify sales",
log: "sales notified",
},
{
id: "wait",
kind: "rule",
x: 580,
y: 290,
nx: 295,
ny: 330,
system: "Rule",
label: "No reply in 3 days?",
log: "follow-up scheduled",
},
{
id: "remind",
kind: "action",
x: 810,
y: 290,
nx: 295,
ny: 470,
system: "Tasks",
label: "Create reminder",
log: "reminder created",
},
],
edges: [
["form", "contact"],
["contact", "notify"],
["contact", "wait"],
["wait", "remind"],
],
runs: [
[
[["form", "contact"]],
[
["contact", "notify"],
["contact", "wait"],
],
[["wait", "remind"]],
],
],
},
];
export const KIND_LABEL: Readonly<Record<NodeKind, string>> = {
trigger: "Trigger: something happens",
rule: "Rule: a fixed check",
ai: "AI step: reads or sorts",
action: "Action: a system does something",
human: "Person: decides the unclear cases",
};
export const AUTOMATION_NOTES = [
{
title: "Worth automating when",
items: [
"The same steps repeat every day or every week",
"Data is typed from one tool into another",
"Mistakes happen because the work is monotonous",
],
},
{
title: "What stays with people",
items: [
"Decisions and exceptions",
"Anything a customer should hear from a person",
"The final say when the automation is unsure",
],
},
{
title: "What I need from you",
items: [
"Access to the tools and their interfaces (APIs)",
"Someone who knows the process as it is done today",
"Real examples to test with",
],
},
] as const;
@@ -1,121 +0,0 @@
import * as React from "react";
import { CoreShellDiagram } from "@/src/components/notes/CoreShellDiagram";
import {
NoteFigure,
NoteList,
NoteParagraph,
NoteSection,
} from "@/src/components/notes/NoteProse";
import type { Note } from "./types";
const Body: React.FC = () => (
<>
<NoteParagraph>
I am building a liquidation system that runs across 7 chains. It is
written in Rust, with Solidity executors on chain. It is built to run
unattended, which changes how much you can rely on manual checking: almost
all of the confidence has to come from tests that run before the code
does. The architecture that makes this workable is a functional core with
imperative shells.
</NoteParagraph>
<NoteSection>The shape</NoteSection>
<NoteParagraph>
A detector per chain indexes borrower positions and computes health
factors. That work is split in two. The math lives in a pure domain crate:
health factor, fixed-point arithmetic, and a closed-form optimum that is
checked against an exact swap engine. The crate has no network, no clock,
no randomness and no global state. Everything arrives as arguments and
leaves as return values.
</NoteParagraph>
<NoteParagraph>
Around that core sit thin shells. A shell reads state from a chain,
converts it into plain data, calls the core, and acts on the answer. It
contains almost no decisions, so there is little in it that can be wrong
in an interesting way.
</NoteParagraph>
<NoteFigure caption="Data flows down; only the shells touch the network.">
<CoreShellDiagram />
</NoteFigure>
<NoteSection>The on-chain side</NoteSection>
<NoteParagraph>
The executor contract does one job in one transaction. It takes a flash
loan, liquidates the position, swaps the collateral, repays the loan, and
reverts if the trade would not be profitable. Because the transaction is
atomic, a failed attempt leaves nothing behind except the gas for the
attempt. No own capital is at risk per attempt, which is a property of the
design rather than of careful operation.
</NoteParagraph>
<NoteParagraph>
The same idea applies on chain as off chain: the contract is a small,
closed piece of logic with explicit inputs. The off-chain core decides
whether to try; the contract has the final say.
</NoteParagraph>
<NoteSection>Testing the edge, not the middle</NoteSection>
<NoteParagraph>
Fixed-point arithmetic has cliffs. Past a certain input size a
multiplication no longer fits in the integer type. If the code wraps
around, a hugely healthy position can suddenly look unhealthy, or the
other way round, and nothing crashes to tell you.
</NoteParagraph>
<NoteParagraph>
One spec in the domain crate tests the exact overflow cliff of the health
factor. It pins the behavior one step below the cliff and at the cliff,
and requires the result to saturate instead of wrapping. This kind of case
is where an optimizing refactor or a generated change breaks things
silently, and it is trivial to write because the core is a pure function:
call it with the boundary values and compare the result.
</NoteParagraph>
<NoteSection>Why the split pays off</NoteSection>
<NoteList>
<li>
<strong>The core is testable without a network.</strong> Specs run fast,
with no node, no fork and no flaky timing. That makes spec-first
practical for the part with the most arithmetic.
</li>
<li>
<strong>Mutation testing can prove the specs.</strong> Pure functions
give mutation tools clean targets. If a mutant of the health factor
survives, a spec is missing, and the gate says so. That is a stronger
statement than a coverage percentage.
</li>
<li>
<strong>Shells are proven by end-to-end specs.</strong> They are not
pure, so they are not tested like the core. They are exercised as a
whole against the behavior they are meant to produce, and because they
hold so little logic, those specs stay small.
</li>
</NoteList>
<NoteSection>Where it hurts</NoteSection>
<NoteParagraph>
The discipline has a cost. You have to keep deciding where a line of code
belongs, and the temptation to put a small calculation in the shell
because it is right there is constant. Every such shortcut moves logic
into the part that is hardest to test. The gates described in the note on
mechanical spec-first enforcement help here: a gate that forbids methods,
mutable references and unwrap pushes the code toward the style the core
depends on.
</NoteParagraph>
<NoteParagraph>
The pattern is not specific to this project. Any system where money,
permissions or irreversible actions sit behind a thin layer of I/O gains
from keeping the decision logic pure and the I/O boring.
</NoteParagraph>
</>
);
export const functionalCoreImperativeShell: Note = {
slug: "functional-core-imperative-shell-multi-chain",
title: "Functional core, imperative shell in a multi-chain system",
summary:
"How a pure Rust core, thin shells and an atomic on-chain executor make a 7-chain liquidation system testable.",
publishedOn: "2026-10-02",
readingMinutes: 4,
tags: ["architecture", "rust", "web3"],
Body,
};
-16
View File
@@ -1,16 +0,0 @@
import { sortNotesNewestFirst, validateNoteRegistry } from "@/src/domain/notes";
import { functionalCoreImperativeShell } from "./functional-core-imperative-shell";
import { specFirstGates } from "./spec-first-gates";
import type { Note } from "./types";
const ALL_NOTES: readonly Note[] = [
specFirstGates,
functionalCoreImperativeShell,
];
const validated = validateNoteRegistry(ALL_NOTES);
if (!validated.ok) {
throw new Error(`Invalid notes: ${validated.error.join("; ")}`);
}
export const NOTES: readonly Note[] = sortNotesNewestFirst(validated.value);
@@ -1,142 +0,0 @@
import * as React from "react";
import { GatePipelineDiagram } from "@/src/components/notes/GatePipelineDiagram";
import {
NoteCode,
NoteFigure,
NoteList,
NoteParagraph,
NoteSection,
} from "@/src/components/notes/NoteProse";
import type { Note } from "./types";
const Body: React.FC = () => (
<>
<NoteParagraph>
A workflow that depends on discipline lasts until the first deadline. In
my Rust systems the workflow is enforced by scripts instead: a change that
skips the spec, drops coverage or leaves a weak test does not get past the
gates, no matter who wrote it or how it was generated.
</NoteParagraph>
<NoteSection>The gates</NoteSection>
<NoteParagraph>
There are four, and each one answers a different question.
</NoteParagraph>
<NoteList>
<li>
<strong>TDD gate.</strong> Was the behavior specified as a test before
it was implemented? Skipping the spec fails the gate.
</li>
<li>
<strong>Functional-style gate.</strong> It forbids methods, traits,{" "}
<NoteCode>&amp;mut</NoteCode> and <NoteCode>unwrap</NoteCode> in the
domain code. Pure functions over immutable data are easier to test and
to mutate, so the style is a rule, not a preference. The gate is itself
covered by a self-test script, because a checker that silently stops
checking is worse than none.
</li>
<li>
<strong>Mutation gate.</strong> The tool changes the code in small ways
and the specs must notice. The policy is zero survivors. A surviving
mutant means either a weak assertion or dead code, and both get fixed.
</li>
<li>
<strong>Coverage ratchet.</strong> Coverage may rise but may not fall.
The gate fails when the number drops below the last recorded one.
</li>
</NoteList>
<NoteFigure caption="Four gates in sequence, run at three scopes.">
<GatePipelineDiagram />
</NoteFigure>
<NoteSection>Three speeds</NoteSection>
<NoteParagraph>
Mutation testing is slow, so the same gates run at three scopes. In the
dev loop they look only at changed lines, which keeps feedback fast enough
to use constantly. Before a merge they run on the changed crates. The
merge gate runs everything. Nothing is skipped on the way to main; a
cheaper check just runs earlier and more often.
</NoteParagraph>
<NoteParagraph>
The mutation gate also keeps a proof ledger. When the code bytes and the
toolchain are unchanged since a previous run, the mutants already caught
are carried over instead of being recomputed. That keeps a zero-survivor
policy affordable on a large codebase, and it stays honest because any
change to the code or the toolchain invalidates the entry.
</NoteParagraph>
<NoteSection>Claims drift, measurements do not</NoteSection>
<NoteParagraph>
The most useful lesson came from reading my own documentation. A README
and a Makefile said &quot;100% coverage at all times&quot;, while the
measured badge said 87.5%. Nobody lied; the sentence was true once and
then the code moved. Prose does not fail a build.
</NoteParagraph>
<NoteParagraph>
So I quote the measured number and enforce a ratchet instead of promising
an absolute. A ratchet is a claim the machine checks on every run: the
number may only go up. It is a weaker sentence than &quot;always
100%&quot; and a far more reliable one.
</NoteParagraph>
<NoteSection>A story from this website</NoteSection>
<NoteParagraph>
This site has a small domain core with the same setup: Vitest for the
specs and Stryker for mutation testing. At one point Stryker reported a
mutation score of 14%. That looked like terrible specs, but the specs were
fine. The mutation tool was silently running zero tests per mutant under
Vitest 5, so every mutant looked like it had survived.
</NoteParagraph>
<NoteParagraph>
The cause was a version mismatch between the Stryker Vitest runner and
Vitest itself. Pinning Vitest to 4.1.x fixed it. The first real run then
found 9 genuine gaps in the specs, the kind a green test suite had been
hiding. Rewriting one date check also removed redundant conditions that no
test could ever distinguish from the simpler version.
</NoteParagraph>
<NoteParagraph>
The pin is now written down in the project instructions, with the reason,
so the next dependency bump does not quietly undo it.
</NoteParagraph>
<NoteSection>What I take from it</NoteSection>
<NoteList>
<li>
<strong>Verify that your verification runs.</strong> A gate that
executes zero tests passes or fails for the wrong reasons. Treat &quot;0
tests ran&quot; as a failure, and look at a surprising score before
explaining it away.
</li>
<li>
<strong>Enforce, do not remind.</strong> If a rule matters, a script
should fail when it is broken. Rules that live in a README drift.
</li>
<li>
<strong>Quote measurements.</strong> Write the number the tool printed,
with the command that printed it, and let a ratchet protect it.
</li>
<li>
<strong>Make the cheap check run first.</strong> Three speeds let the
slow, thorough gates exist without slowing the loop that people use
every few minutes.
</li>
</NoteList>
<NoteParagraph>
None of this is specific to Rust. The same shape works for a TypeScript
project: a spec before the code, mutation testing on the changed files,
and a coverage number that is only allowed to go up.
</NoteParagraph>
</>
);
export const specFirstGates: Note = {
slug: "how-i-enforce-spec-first-mechanically",
title: "How I enforce spec-first mechanically",
summary:
"Four gates, three speeds, and one lesson: claims drift, so measure and ratchet instead of promising.",
publishedOn: "2026-10-03",
readingMinutes: 4,
tags: ["testing", "mutation testing", "process"],
Body,
};
-4
View File
@@ -1,4 +0,0 @@
import type * as React from "react";
import type { NoteMeta } from "@/src/domain/notes";
export type Note = NoteMeta & { readonly Body: React.FC };
+203
View File
@@ -0,0 +1,203 @@
/**
* The three ways a visitor can work with me, written for the visitor, not for engineers.
* Each path answers the same four questions: what you get, how it runs, what it costs, what to do next.
*/
export type PathStep = {
readonly title: string;
readonly text: string;
};
export type PathPrice = {
readonly label: string;
readonly amount: string;
readonly note: string;
};
export type ServicePath = {
readonly id: string;
/** How the visitor would say it. */
readonly need: string;
readonly forWhom: string;
readonly headline: string;
readonly intro: string;
readonly youGet: readonly string[];
readonly steps: readonly PathStep[];
/** How this kind of work is priced, in one line, and why. */
readonly pricingModel: string;
readonly pricingText: string;
readonly prices: readonly PathPrice[];
readonly smallPrint: string;
readonly cta: string;
};
export const SERVICE_PATHS: readonly ServicePath[] = [
{
id: "developer",
need: "We need a developer for our product",
forWhom: "Startups, product teams, agencies, Web3 teams",
headline: "An experienced developer who joins your project.",
intro:
"You have a product and more work than your team can handle. I work on agreed days of the week on what is next on your list: new features, the interface, or the parts nobody gets around to.",
youGet: [
"Web apps, dashboards, customer portals and internal tools",
"Interfaces with attention to design and usability",
"Web3 frontends: wallet connection, live data, dashboards",
"Small tools and automation for recurring work",
],
steps: [
{
title: "A first call, about 20 minutes",
text: "You tell me what you are building and where you need help. If I am not the right person for it, I say so.",
},
{
title: "We agree on the days",
text: "How many days a week I work for you, and what should come first.",
},
{
title: "I work through the list",
text: "Finished parts go to you for review as they are done. I track my hours and you can see what they were spent on.",
},
{
title: "Two weeks notice",
text: "The agreement can be ended with two weeks notice to the end of a month.",
},
],
pricingModel: "By effort, for as long as your project runs",
pricingText:
"I work on your project on agreed days of the week and bill the hours I actually worked. There is no package to buy and no minimum term.",
prices: [
{ label: "Per hour", amount: "100 EUR", note: "net" },
{ label: "Per day", amount: "800 EUR", note: "8 hours, net" },
],
smallPrint:
"One invoice per month, with the tracked hours. Either side can end it with two weeks notice to the end of a month.",
cta: "Get in touch",
},
{
id: "rescue",
need: "Our app needs to become reliable",
forWhom: "Teams whose app was built quickly and is now hard to change",
headline: "A week to look at your app and fix what is most urgent.",
intro:
"The app works, but bugs come back and small changes take longer than they should. I take a week to go through the code, fix what I can in that time and write down what I would do next.",
youGet: [
"An honest assessment: what is solid and what is fragile",
"Automated tests for the most important parts",
"The most urgent problems fixed, as far as a week allows",
"A written list of next steps, in plain language",
],
steps: [
{
title: "A first call, about 20 minutes",
text: "You show me the app and tell me where it causes trouble.",
},
{
title: "One week of work",
text: "I read the code, add tests where they matter most and fix the most urgent problems.",
},
{
title: "A written summary",
text: "What I found, what I changed and what I recommend. Written so that you can follow it without being a developer.",
},
{
title: "You decide how to continue",
text: "With me, with your own team, or not at all.",
},
],
pricingModel: "A fixed week at a fixed price",
pricingText:
"The scope is one week of my time. You know the cost before I start.",
prices: [{ label: "One week, 35 hours", amount: "3,500 EUR", note: "net" }],
smallPrint:
"A week does not fix everything. If I see in the first call that it will not be enough to be useful, I tell you.",
cta: "Get in touch",
},
{
id: "automation",
need: "We want to automate a process",
forWhom:
"Companies where people copy data between tools or repeat the same steps",
headline:
"Systems that pass work on to each other, so people do not have to.",
intro:
"Someone exports a list here, types it in there and sends an email about it. I connect the tools you already use, so that recurring steps run on their own and your people can do the work that needs them.",
youGet: [
"Connections between your systems through their interfaces (APIs)",
"Recurring workflows that run without manual steps",
"Where it helps, steps handled with AI: sorting, summarising, reading documents",
"A short description of how it works, so it can be maintained",
],
steps: [
{
title: "A first call, about 20 minutes",
text: "You describe the process and which tools are involved.",
},
{
title: "I look at how it is done today",
text: "Step by step, with the people who do it. Often a part can be automated, not everything.",
},
{
title: "I connect the systems",
text: "One step at a time. You test it with real cases before it replaces the manual work.",
},
{
title: "It runs on its own",
text: "You get a note on what it does and what to check when one of the tools changes.",
},
],
pricingModel: "An estimate first, then a budget",
pricingText:
"Once I have seen the process I estimate the hours. That estimate becomes the budget. I bill the hours I actually needed and tell you early if it will not be enough.",
prices: [{ label: "Per hour", amount: "100 EUR", note: "net" }],
smallPrint:
"How long it takes depends on the process, so there is no price before I have seen it. Fees for the tools themselves are paid by you.",
cta: "Get in touch",
},
{
id: "website",
need: "I just need a website",
forWhom: "Companies and self-employed people who want it taken care of",
headline: "A website, built and looked after for you.",
intro:
"You do not want to learn a website builder or deal with hosting. You tell me what your business does and what the site should achieve. I design it, build it and put it online.",
youGet: [
"A design made for your business, not a template",
"Works on phone, tablet and desktop",
"Hosting taken care of, no server for you to manage",
"Changes by email: you write what you need, I make the change",
],
steps: [
{
title: "A first call, about 20 minutes",
text: "You tell me about your business and what the website is for.",
},
{
title: "A design to look at",
text: "You get a first version to click through. We adjust it until it fits.",
},
{
title: "I build it and put it online",
text: "With your texts and images and a contact form. You approve it before it goes live.",
},
{
title: "Changes afterwards",
text: "A new photo or a changed price: you send me an email.",
},
],
pricingModel: "An estimate first, then a budget",
pricingText:
"After the first call you get an estimate. It becomes your budget: I bill the hours actually used. The estimate usually holds, and if it will not, I tell you before the budget is used up.",
prices: [
{
label: "A typical company website",
amount: "from 3,800 EUR",
note: "one-off, net",
},
{ label: "Anything extra, per hour", amount: "100 EUR", note: "net" },
],
smallPrint:
"Hosting and small changes afterwards cost a monthly fee that we agree on before we start. I do not manage email inboxes; they stay with your provider.",
cta: "Get in touch",
},
];
+19 -54
View File
@@ -1,62 +1,27 @@
import {
parseAvailabilityConfig,
type AvailabilityConfig,
} from "@/src/domain/availability";
import { parseEmailParts, type EmailParts } from "@/src/domain/email";
export const SITE_NAME = "Marc Mintel";
export const SITE_URL = "https://mintel.me";
// TODO(owner): replace with the real Cal.com link before launch.
export const BOOKING_URL = "https://cal.com/PLACEHOLDER/intro";
// TODO(owner): replace with the real contact address before launch.
export const CONTACT_EMAIL = "hello@example.com";
// PLACEHOLDER VALUES: the owner must confirm the next free date and weekly hours.
const RAW_AVAILABILITY_CONFIG = {
freeFrom: "2026-11-01",
hoursPerWeek: 20,
} as const;
const parsedAvailability = parseAvailabilityConfig(RAW_AVAILABILITY_CONFIG);
if (parsedAvailability.ok === false) {
// The contact address, kept in parts on purpose: the complete address must never
// appear as one string in the page source (see src/components/EmailLink.tsx).
const parsedEmail = parseEmailParts({
user: "hello",
domain: "mintel",
tld: "me",
});
if (parsedEmail.ok === false) {
throw new Error(
`Invalid AVAILABILITY_CONFIG in src/content/site.ts: ${parsedAvailability.error}`,
`Invalid contact address in src/content/site.ts: ${parsedEmail.error}`,
);
}
export const AVAILABILITY_CONFIG: AvailabilityConfig = parsedAvailability.value;
export const CONTACT_EMAIL_PARTS: EmailParts = parsedEmail.value;
export type EngagementBlock = {
readonly name: string;
readonly hoursPerWeek: string;
readonly pricePerMonth: string;
};
export const ENGAGEMENT_BLOCKS: readonly EngagementBlock[] = [
{
name: "Focus",
hoursPerWeek: "15 h/week",
pricePerMonth: "6,000 EUR/month",
},
{
name: "Half-time",
hoursPerWeek: "20 h/week",
pricePerMonth: "8,000 EUR/month",
},
{
name: "Full-time",
hoursPerWeek: "35-40 h/week",
pricePerMonth: "14,000-16,000 EUR/month",
},
];
export const STACK: readonly string[] = [
"TypeScript",
"Next.js",
"React",
"Node.js",
"Rust",
"Solidity",
"PostgreSQL",
"Tailwind CSS",
];
/** Details required for the imprint (section 5 DDG). */
export const OWNER = {
name: "Marc Mintel",
street: "Georg-Meistermann-Straße 7",
city: "54586 Schüller",
country: "Germany",
vatId: "DE367588065",
} as const;