feat(site): tighten proof, contact, and inquiry delivery
Lead with Batch on the home page, drop the product changelog, add a domain note, and stop reporting success when SMTP fails. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"test": "node --experimental-strip-types --test src/i18n/routes.test.ts src/lib/contact/schema.test.ts"
|
"test": "node --experimental-strip-types --test src/i18n/routes.test.ts src/lib/contact/schema.test.ts src/lib/contact/deliver.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.3.1",
|
"next": "16.3.1",
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { ContactForm } from "@/components/contact-form";
|
import { ContactForm } from "@/components/contact-form";
|
||||||
|
import { isOfferId } from "@/content/offer-ids";
|
||||||
import { getOffers } from "@/content/offers";
|
import { getOffers } from "@/content/offers";
|
||||||
|
import { site } from "@/content/site";
|
||||||
import { getDictionary } from "@/i18n/dictionaries";
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { publicPath } from "@/i18n/routes";
|
||||||
import { pageMetadata } from "@/lib/metadata";
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
import { requireLocale } from "@/lib/locale";
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export async function generateMetadata({ params }: PageProps<"/[lang]/contact">) {
|
export async function generateMetadata({ params }: PageProps<"/[lang]/contact">) {
|
||||||
const locale = requireLocale((await params).lang);
|
const locale = requireLocale((await params).lang);
|
||||||
@@ -10,10 +14,16 @@ export async function generateMetadata({ params }: PageProps<"/[lang]/contact">)
|
|||||||
return pageMetadata(locale, "contact", dict.contact.title, dict.contact.lede);
|
return pageMetadata(locale, "contact", dict.contact.title, dict.contact.lede);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ContactPage({ params }: PageProps<"/[lang]/contact">) {
|
export default async function ContactPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: PageProps<"/[lang]/contact">) {
|
||||||
const locale = requireLocale((await params).lang);
|
const locale = requireLocale((await params).lang);
|
||||||
const dict = getDictionary(locale);
|
const dict = getDictionary(locale);
|
||||||
const offers = getOffers(locale);
|
const offers = getOffers(locale);
|
||||||
|
const topicParam = (await searchParams).topic;
|
||||||
|
const topicValue = Array.isArray(topicParam) ? topicParam[0] : topicParam;
|
||||||
|
const defaultTopic = topicValue && isOfferId(topicValue) ? topicValue : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto grid w-full max-w-6xl gap-16 px-6 py-20 md:grid-cols-[1.1fr_0.9fr] md:px-10 md:py-28">
|
<main className="mx-auto grid w-full max-w-6xl gap-16 px-6 py-20 md:grid-cols-[1.1fr_0.9fr] md:px-10 md:py-28">
|
||||||
@@ -21,17 +31,29 @@ export default async function ContactPage({ params }: PageProps<"/[lang]/contact
|
|||||||
<p className="kicker">{dict.contact.kicker}</p>
|
<p className="kicker">{dict.contact.kicker}</p>
|
||||||
<h1 className="display mt-5 text-5xl md:text-6xl">{dict.contact.title}</h1>
|
<h1 className="display mt-5 text-5xl md:text-6xl">{dict.contact.title}</h1>
|
||||||
<p className="mt-8 max-w-xl text-lg leading-8 text-mute-strong">{dict.contact.lede}</p>
|
<p className="mt-8 max-w-xl text-lg leading-8 text-mute-strong">{dict.contact.lede}</p>
|
||||||
|
<p className="mt-4 max-w-xl text-sm leading-7 text-mute">{dict.contact.reply}</p>
|
||||||
|
<p className="mt-3 text-sm">
|
||||||
|
<a className="text-copper" href={`mailto:${site.email}`}>
|
||||||
|
{dict.contact.emailDirect}: {site.email}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<ContactForm locale={locale} dict={dict} />
|
<ContactForm locale={locale} dict={dict} offers={offers} defaultTopic={defaultTopic} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<aside className="border-t border-line pt-10 md:border-l md:border-t-0 md:pl-10 md:pt-0">
|
<aside className="border-t border-line pt-10 md:border-l md:border-t-0 md:pl-10 md:pt-0">
|
||||||
<p className="kicker">{dict.home.offerKicker}</p>
|
<p className="kicker">{dict.home.offerKicker}</p>
|
||||||
<div className="mt-8 grid gap-8">
|
<div className="mt-8 grid gap-8">
|
||||||
{offers.map((offer) => (
|
{offers.map((offer) => (
|
||||||
<article key={offer.title}>
|
<article key={offer.id}>
|
||||||
<h2 className="font-serif text-2xl">{offer.title}</h2>
|
<h2 className="font-serif text-2xl">{offer.title}</h2>
|
||||||
<p className="mt-3 text-sm leading-7 text-mute-strong">{offer.text}</p>
|
<p className="mt-3 text-sm leading-7 text-mute-strong">{offer.text}</p>
|
||||||
|
<Link
|
||||||
|
href={`${publicPath(locale, "contact")}?topic=${offer.id}`}
|
||||||
|
className="mt-4 inline-block text-sm text-copper"
|
||||||
|
>
|
||||||
|
{dict.actions.startConversation} →
|
||||||
|
</Link>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ export default async function LocaleLayout({
|
|||||||
return (
|
return (
|
||||||
<div className="site-shell flex min-h-full flex-col">
|
<div className="site-shell flex min-h-full flex-col">
|
||||||
<SiteHeader locale={locale} dict={dict} />
|
<SiteHeader locale={locale} dict={dict} />
|
||||||
<div className="flex-1">{children}</div>
|
<div id="content" className="flex-1">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
<SiteFooter locale={locale} dict={dict} />
|
<SiteFooter locale={locale} dict={dict} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+17
-36
@@ -1,4 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { FeaturedWork, WorkCard } from "@/components/work-preview";
|
||||||
import { HomeJsonLd } from "@/components/json-ld";
|
import { HomeJsonLd } from "@/components/json-ld";
|
||||||
import { getOffers } from "@/content/offers";
|
import { getOffers } from "@/content/offers";
|
||||||
import { getNotes } from "@/content/notes";
|
import { getNotes } from "@/content/notes";
|
||||||
@@ -8,7 +9,6 @@ import { getDictionary } from "@/i18n/dictionaries";
|
|||||||
import { publicPath } from "@/i18n/routes";
|
import { publicPath } from "@/i18n/routes";
|
||||||
import { pageMetadata } from "@/lib/metadata";
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
import { requireLocale } from "@/lib/locale";
|
import { requireLocale } from "@/lib/locale";
|
||||||
import { getUpdates } from "@/lib/updates";
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: PageProps<"/[lang]">) {
|
export async function generateMetadata({ params }: PageProps<"/[lang]">) {
|
||||||
const locale = requireLocale((await params).lang);
|
const locale = requireLocale((await params).lang);
|
||||||
@@ -27,10 +27,9 @@ export default async function HomePage({ params }: PageProps<"/[lang]">) {
|
|||||||
const locale = requireLocale((await params).lang);
|
const locale = requireLocale((await params).lang);
|
||||||
const dict = getDictionary(locale);
|
const dict = getDictionary(locale);
|
||||||
const person = getPerson(locale);
|
const person = getPerson(locale);
|
||||||
const projects = getProjects(locale);
|
const [featured, ...rest] = getProjects(locale);
|
||||||
const offers = getOffers(locale);
|
const offers = getOffers(locale);
|
||||||
const notes = getNotes(locale).slice(0, 2);
|
const notes = getNotes(locale).slice(0, 3);
|
||||||
const updates = await getUpdates();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
@@ -65,25 +64,14 @@ export default async function HomePage({ params }: PageProps<"/[lang]">) {
|
|||||||
</div>
|
</div>
|
||||||
<p className="max-w-md text-sm leading-7 text-mute-strong">{dict.home.workLede}</p>
|
<p className="max-w-md text-sm leading-7 text-mute-strong">{dict.home.workLede}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-12 md:grid-cols-2">
|
{featured ? <FeaturedWork project={featured} locale={locale} dict={dict} /> : null}
|
||||||
{projects.map((project) => (
|
{rest.length ? (
|
||||||
<article key={project.slug} className="border-t border-line pt-8">
|
<div className="mt-16 grid gap-12 md:grid-cols-2">
|
||||||
<div className="flex items-baseline justify-between gap-4">
|
{rest.map((project) => (
|
||||||
<p className="kicker">{project.label}</p>
|
<WorkCard key={project.slug} project={project} locale={locale} dict={dict} />
|
||||||
<span className="font-mono text-xs text-mute">{project.number}</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="mt-5 font-serif text-3xl">{project.title}</h3>
|
|
||||||
<p className="mt-4 leading-7 text-mute-strong">{project.summary}</p>
|
|
||||||
<p className="mt-6 font-mono text-xs uppercase tracking-[0.14em] text-mute">{project.status}</p>
|
|
||||||
<Link
|
|
||||||
href={publicPath(locale, "workItem", project.slug)}
|
|
||||||
className="mt-6 inline-block border-b border-copper pb-1 text-sm text-copper"
|
|
||||||
>
|
|
||||||
{dict.actions.openCase} →
|
|
||||||
</Link>
|
|
||||||
</article>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -114,10 +102,16 @@ export default async function HomePage({ params }: PageProps<"/[lang]">) {
|
|||||||
<h2 className="display mt-4 max-w-2xl text-4xl md:text-5xl">{dict.home.offerTitle}</h2>
|
<h2 className="display mt-4 max-w-2xl text-4xl md:text-5xl">{dict.home.offerTitle}</h2>
|
||||||
<div className="mt-12 grid gap-10 md:grid-cols-3">
|
<div className="mt-12 grid gap-10 md:grid-cols-3">
|
||||||
{offers.map((offer, index) => (
|
{offers.map((offer, index) => (
|
||||||
<article key={offer.title} className="border-l border-line-strong pl-5">
|
<article key={offer.id} className="border-l border-line-strong pl-5">
|
||||||
<span className="font-mono text-xs text-copper">0{index + 1}</span>
|
<span className="font-mono text-xs text-copper">0{index + 1}</span>
|
||||||
<h3 className="mt-4 font-serif text-2xl">{offer.title}</h3>
|
<h3 className="mt-4 font-serif text-2xl">{offer.title}</h3>
|
||||||
<p className="mt-3 text-sm leading-7 text-mute-strong">{offer.text}</p>
|
<p className="mt-3 text-sm leading-7 text-mute-strong">{offer.text}</p>
|
||||||
|
<Link
|
||||||
|
href={`${publicPath(locale, "contact")}?topic=${offer.id}`}
|
||||||
|
className="mt-5 inline-block text-sm text-copper"
|
||||||
|
>
|
||||||
|
{dict.actions.startConversation} →
|
||||||
|
</Link>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -128,7 +122,7 @@ export default async function HomePage({ params }: PageProps<"/[lang]">) {
|
|||||||
<div className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10">
|
<div className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10">
|
||||||
<p className="kicker">{dict.home.notesKicker}</p>
|
<p className="kicker">{dict.home.notesKicker}</p>
|
||||||
<h2 className="display mt-4 text-4xl md:text-5xl">{dict.home.notesTitle}</h2>
|
<h2 className="display mt-4 text-4xl md:text-5xl">{dict.home.notesTitle}</h2>
|
||||||
<div className="mt-12 grid gap-10 md:grid-cols-2">
|
<div className="mt-12 grid gap-10 md:grid-cols-3">
|
||||||
{notes.map((note) => (
|
{notes.map((note) => (
|
||||||
<article key={note.slug}>
|
<article key={note.slug}>
|
||||||
<time className="font-mono text-xs text-mute" dateTime={note.date}>
|
<time className="font-mono text-xs text-mute" dateTime={note.date}>
|
||||||
@@ -145,19 +139,6 @@ export default async function HomePage({ params }: PageProps<"/[lang]">) {
|
|||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{updates.length ? (
|
|
||||||
<div className="mt-16 border-t border-line pt-10">
|
|
||||||
<p className="kicker">Batchmaker</p>
|
|
||||||
<div className="mt-6 grid gap-6 md:grid-cols-3">
|
|
||||||
{updates.map((update) => (
|
|
||||||
<article key={update.slug}>
|
|
||||||
<h3 className="font-serif text-xl">{update.title}</h3>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-mute-strong">{update.summary}</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getProjects } from "@/content/projects";
|
import { getProjects } from "@/content/projects";
|
||||||
import { getDictionary } from "@/i18n/dictionaries";
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
@@ -22,26 +23,41 @@ export default async function WorkPage({ params }: PageProps<"/[lang]/work">) {
|
|||||||
<h1 className="display mt-5 max-w-4xl text-5xl md:text-7xl">{dict.work.title}</h1>
|
<h1 className="display mt-5 max-w-4xl text-5xl md:text-7xl">{dict.work.title}</h1>
|
||||||
<p className="mt-8 max-w-2xl text-xl leading-9 text-mute-strong">{dict.work.lede}</p>
|
<p className="mt-8 max-w-2xl text-xl leading-9 text-mute-strong">{dict.work.lede}</p>
|
||||||
<div className="mt-16 grid gap-16">
|
<div className="mt-16 grid gap-16">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => {
|
||||||
<article key={project.slug} className="grid gap-8 border-t border-line pt-10 md:grid-cols-[0.4fr_1fr]">
|
const image = project.images[0];
|
||||||
|
return (
|
||||||
|
<article key={project.slug} className="grid gap-8 border-t border-line pt-10 md:grid-cols-[0.9fr_1.1fr] md:items-start">
|
||||||
|
{image ? (
|
||||||
|
<figure className="overflow-hidden border border-line bg-ink-raised">
|
||||||
|
<Image src={image.src} alt={image[locale]} width={1440} height={900} className="block h-auto w-full" />
|
||||||
|
</figure>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className="font-mono text-xs text-copper">{project.number}</p>
|
<p className="font-mono text-xs text-copper">{project.number}</p>
|
||||||
|
<p className="kicker mt-4">{project.label}</p>
|
||||||
<h2 className="mt-4 font-serif text-4xl">{project.title}</h2>
|
<h2 className="mt-4 font-serif text-4xl">{project.title}</h2>
|
||||||
<p className="mt-4 text-sm text-mute">{project.status}</p>
|
<p className="mt-3 text-sm text-copper">{project.stance}</p>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="kicker">{project.label}</p>
|
|
||||||
<p className="mt-5 max-w-2xl leading-8 text-mute-strong">{project.summary}</p>
|
<p className="mt-5 max-w-2xl leading-8 text-mute-strong">{project.summary}</p>
|
||||||
<p className="mt-6 text-sm text-mute">{project.stack}</p>
|
<p className="mt-6 text-sm text-mute">{project.stack}</p>
|
||||||
|
<div className="mt-8 flex flex-wrap gap-6 text-sm">
|
||||||
<Link
|
<Link
|
||||||
href={publicPath(locale, "workItem", project.slug)}
|
href={publicPath(locale, "workItem", project.slug)}
|
||||||
className="mt-8 inline-block border-b border-copper pb-1 text-sm text-copper"
|
className="border-b border-copper pb-1 text-copper"
|
||||||
>
|
>
|
||||||
{dict.actions.openCase} →
|
{dict.actions.openCase} →
|
||||||
</Link>
|
</Link>
|
||||||
|
{project.href ? (
|
||||||
|
<a href={project.href} className="border-b border-line-strong pb-1 text-paper" rel="noreferrer">
|
||||||
|
{project.href.replace(/^https?:\/\//, "")} →
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -101,6 +101,26 @@ body {
|
|||||||
color: var(--paper);
|
color: var(--paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--copper);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
top: -4rem;
|
||||||
|
z-index: 50;
|
||||||
|
background: var(--copper);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.honeypot {
|
.honeypot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -9999px;
|
left: -9999px;
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { site } from "@/content/site";
|
||||||
import { submitInquiry } from "@/lib/contact/actions";
|
import { submitInquiry } from "@/lib/contact/actions";
|
||||||
import type { Dictionary } from "@/i18n/dictionaries";
|
import type { Dictionary } from "@/i18n/dictionaries";
|
||||||
import type { Locale } from "@/i18n/routes";
|
import type { Locale } from "@/i18n/routes";
|
||||||
|
import { isOfferId, type OfferId } from "@/content/offer-ids";
|
||||||
|
|
||||||
export function ContactForm({ locale, dict }: { locale: Locale; dict: Dictionary }) {
|
type OfferOption = {
|
||||||
const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">("idle");
|
id: OfferId;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContactForm({
|
||||||
|
locale,
|
||||||
|
dict,
|
||||||
|
offers,
|
||||||
|
defaultTopic,
|
||||||
|
}: {
|
||||||
|
locale: Locale;
|
||||||
|
dict: Dictionary;
|
||||||
|
offers: readonly OfferOption[];
|
||||||
|
defaultTopic?: OfferId;
|
||||||
|
}) {
|
||||||
|
const [status, setStatus] = useState<"idle" | "sending" | "success" | "error" | "delivery" | "rate">("idle");
|
||||||
const [field, setField] = useState<string | undefined>();
|
const [field, setField] = useState<string | undefined>();
|
||||||
|
|
||||||
async function onSubmit(formData: FormData) {
|
async function onSubmit(formData: FormData) {
|
||||||
@@ -17,6 +34,14 @@ export function ContactForm({ locale, dict }: { locale: Locale; dict: Dictionary
|
|||||||
setStatus("success");
|
setStatus("success");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (result.code === "delivery") {
|
||||||
|
setStatus("delivery");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.code === "rate") {
|
||||||
|
setStatus("rate");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setField(result.field);
|
setField(result.field);
|
||||||
}
|
}
|
||||||
@@ -30,6 +55,8 @@ export function ContactForm({ locale, dict }: { locale: Locale; dict: Dictionary
|
|||||||
}
|
}
|
||||||
|
|
||||||
const invalid = (name: string) => field === name;
|
const invalid = (name: string) => field === name;
|
||||||
|
const failMessage =
|
||||||
|
status === "delivery" ? dict.contact.delivery : status === "rate" ? dict.contact.rate : status === "error" ? dict.contact.error : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={onSubmit} className="grid gap-6">
|
<form action={onSubmit} className="grid gap-6">
|
||||||
@@ -38,6 +65,21 @@ export function ContactForm({ locale, dict }: { locale: Locale; dict: Dictionary
|
|||||||
{dict.contact.honeypot}
|
{dict.contact.honeypot}
|
||||||
<input type="text" name="website" tabIndex={-1} autoComplete="off" />
|
<input type="text" name="website" tabIndex={-1} autoComplete="off" />
|
||||||
</label>
|
</label>
|
||||||
|
<label className="grid gap-2 text-sm">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.16em] text-mute">{dict.contact.topic}</span>
|
||||||
|
<select
|
||||||
|
name="topic"
|
||||||
|
defaultValue={defaultTopic && isOfferId(defaultTopic) ? defaultTopic : ""}
|
||||||
|
className={`border bg-ink-raised px-4 py-3 text-paper outline-none focus:border-copper ${invalid("topic") ? "border-copper" : "border-line-strong"}`}
|
||||||
|
>
|
||||||
|
<option value="">{dict.contact.topicAny}</option>
|
||||||
|
{offers.map((offer) => (
|
||||||
|
<option key={offer.id} value={offer.id}>
|
||||||
|
{offer.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label className="grid gap-2 text-sm">
|
<label className="grid gap-2 text-sm">
|
||||||
<span className="font-mono text-xs uppercase tracking-[0.16em] text-mute">{dict.contact.name}</span>
|
<span className="font-mono text-xs uppercase tracking-[0.16em] text-mute">{dict.contact.name}</span>
|
||||||
<input
|
<input
|
||||||
@@ -74,7 +116,20 @@ export function ContactForm({ locale, dict }: { locale: Locale; dict: Dictionary
|
|||||||
className={`border bg-ink-raised px-4 py-3 text-paper outline-none focus:border-copper ${invalid("message") ? "border-copper" : "border-line-strong"}`}
|
className={`border bg-ink-raised px-4 py-3 text-paper outline-none focus:border-copper ${invalid("message") ? "border-copper" : "border-line-strong"}`}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{status === "error" ? <p className="text-sm text-copper">{dict.contact.error}</p> : null}
|
{failMessage ? (
|
||||||
|
<p className="text-sm text-copper">
|
||||||
|
{failMessage}
|
||||||
|
{status === "delivery" || status === "rate" ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<a className="underline decoration-copper/50 underline-offset-4" href={`mailto:${site.email}`}>
|
||||||
|
{site.email}
|
||||||
|
</a>
|
||||||
|
{status === "delivery" ? "." : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={status === "sending"}
|
disabled={status === "sending"}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { site } from "@/content/site";
|
||||||
import type { Dictionary } from "@/i18n/dictionaries";
|
import type { Dictionary } from "@/i18n/dictionaries";
|
||||||
import { publicPath, type Locale } from "@/i18n/routes";
|
import { publicPath, type Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
@@ -8,7 +9,10 @@ export function SiteFooter({ locale, dict }: { locale: Locale; dict: Dictionary
|
|||||||
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 py-8 text-xs text-mute md:px-10">
|
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 py-8 text-xs text-mute md:px-10">
|
||||||
<p>© {new Date().getFullYear()} Jan Wagner · w-make.com</p>
|
<p>© {new Date().getFullYear()} Jan Wagner · w-make.com</p>
|
||||||
<p className="hidden sm:block">{dict.footer.line}</p>
|
<p className="hidden sm:block">{dict.footer.line}</p>
|
||||||
<div className="flex gap-5">
|
<div className="flex flex-wrap gap-5">
|
||||||
|
<a href={`mailto:${site.email}`} className="link-quiet">
|
||||||
|
{site.email}
|
||||||
|
</a>
|
||||||
<Link href={publicPath(locale, "legal")} className="link-quiet">
|
<Link href={publicPath(locale, "legal")} className="link-quiet">
|
||||||
{dict.footer.imprint}
|
{dict.footer.imprint}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -14,13 +14,16 @@ export function SiteHeader({ locale, dict }: { locale: Locale; dict: Dictionary
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-20 border-b border-line bg-ink/80 backdrop-blur-md">
|
<header className="sticky top-0 z-20 border-b border-line bg-ink/80 backdrop-blur-md">
|
||||||
|
<a href="#content" className="skip-link">
|
||||||
|
{dict.a11y.skip}
|
||||||
|
</a>
|
||||||
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 py-4 md:px-10">
|
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 py-4 md:px-10">
|
||||||
<Link href={publicPath(locale, "home")} className="flex items-center gap-3 text-copper">
|
<Link href={publicPath(locale, "home")} className="flex items-center gap-3 text-copper">
|
||||||
<Mark />
|
<Mark />
|
||||||
<span className="font-mono text-sm tracking-[0.28em]">W-MAKE</span>
|
<span className="font-mono text-sm tracking-[0.28em]">W-MAKE</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex flex-wrap items-center gap-5 md:gap-8">
|
<div className="flex flex-wrap items-center gap-5 md:gap-8">
|
||||||
<nav aria-label="Hauptnavigation" className="flex flex-wrap items-center gap-4 text-sm md:gap-6">
|
<nav aria-label={dict.a11y.nav} className="flex flex-wrap items-center gap-4 text-sm md:gap-6">
|
||||||
{links.map(([label, href]) => (
|
{links.map(([label, href]) => (
|
||||||
<Link key={href} href={href} className="link-quiet">
|
<Link key={href} href={href} className="link-quiet">
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { LocalizedProject } from "@/content/projects";
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries";
|
||||||
|
import { publicPath, type Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export function FeaturedWork({
|
||||||
|
project,
|
||||||
|
locale,
|
||||||
|
dict,
|
||||||
|
}: {
|
||||||
|
project: LocalizedProject;
|
||||||
|
locale: Locale;
|
||||||
|
dict: Dictionary;
|
||||||
|
}) {
|
||||||
|
const image = project.images[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="grid gap-10 border-t border-line pt-10 lg:grid-cols-[1.15fr_0.85fr] lg:items-start">
|
||||||
|
{image ? (
|
||||||
|
<figure className="overflow-hidden border border-line bg-ink-raised">
|
||||||
|
<Image
|
||||||
|
src={image.src}
|
||||||
|
alt={image[locale]}
|
||||||
|
width={1440}
|
||||||
|
height={900}
|
||||||
|
priority
|
||||||
|
className="block h-auto w-full"
|
||||||
|
/>
|
||||||
|
<figcaption className="px-5 py-3 text-sm leading-6 text-mute-strong">{image[locale]}</figcaption>
|
||||||
|
</figure>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
|
<p className="kicker">{project.label}</p>
|
||||||
|
<span className="font-mono text-xs text-mute">{project.number}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-5 font-serif text-4xl md:text-5xl">{project.title}</h3>
|
||||||
|
<p className="mt-4 text-sm text-copper">{project.stance}</p>
|
||||||
|
<p className="mt-4 leading-7 text-mute-strong">{project.summary}</p>
|
||||||
|
<ol className="mt-8 grid gap-4">
|
||||||
|
{project.stages.map((stage, index) => (
|
||||||
|
<li key={stage.title} className="grid grid-cols-[2rem_1fr] gap-3">
|
||||||
|
<span className="font-mono text-xs text-copper">0{index + 1}</span>
|
||||||
|
<div>
|
||||||
|
<p className="font-serif text-lg">{stage.title}</p>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-mute-strong">{stage.text}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<div className="mt-8 flex flex-wrap gap-6 text-sm">
|
||||||
|
<Link href={publicPath(locale, "workItem", project.slug)} className="border-b border-copper pb-1 text-copper">
|
||||||
|
{dict.actions.openCase} →
|
||||||
|
</Link>
|
||||||
|
{project.href ? (
|
||||||
|
<a href={project.href} className="border-b border-line-strong pb-1 text-paper" rel="noreferrer">
|
||||||
|
{project.href.replace(/^https?:\/\//, "")} →
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkCard({
|
||||||
|
project,
|
||||||
|
locale,
|
||||||
|
dict,
|
||||||
|
}: {
|
||||||
|
project: LocalizedProject;
|
||||||
|
locale: Locale;
|
||||||
|
dict: Dictionary;
|
||||||
|
}) {
|
||||||
|
const image = project.images[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="border-t border-line pt-8">
|
||||||
|
{image ? (
|
||||||
|
<figure className="mb-6 overflow-hidden border border-line bg-ink-raised">
|
||||||
|
<Image src={image.src} alt={image[locale]} width={1440} height={900} className="block h-auto w-full" />
|
||||||
|
</figure>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
|
<p className="kicker">{project.label}</p>
|
||||||
|
<span className="font-mono text-xs text-mute">{project.number}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-5 font-serif text-3xl">{project.title}</h3>
|
||||||
|
<p className="mt-3 text-sm text-copper">{project.stance}</p>
|
||||||
|
<p className="mt-4 leading-7 text-mute-strong">{project.summary}</p>
|
||||||
|
<p className="mt-6 font-mono text-xs uppercase tracking-[0.14em] text-mute">{project.status}</p>
|
||||||
|
<Link
|
||||||
|
href={publicPath(locale, "workItem", project.slug)}
|
||||||
|
className="mt-6 inline-block border-b border-copper pb-1 text-sm text-copper"
|
||||||
|
>
|
||||||
|
{dict.actions.openCase} →
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ const copy = {
|
|||||||
"Diese Website speichert Anfragen aus dem Kontaktformular, um sie zu beantworten. Pflichtfelder sind Name, E-Mail und Nachricht. Das optionale Feld Unternehmen hilft bei der Einordnung.",
|
"Diese Website speichert Anfragen aus dem Kontaktformular, um sie zu beantworten. Pflichtfelder sind Name, E-Mail und Nachricht. Das optionale Feld Unternehmen hilft bei der Einordnung.",
|
||||||
"Die Daten werden in einer lokalen SQLite-Datei auf dem Server abgelegt und per E-Mail an eldov@w-make.de weitergeleitet. Es gibt kein Tracking-Pixel, kein Marketing-Cookie und keine Weitergabe an Werbenetzwerke.",
|
"Die Daten werden in einer lokalen SQLite-Datei auf dem Server abgelegt und per E-Mail an eldov@w-make.de weitergeleitet. Es gibt kein Tracking-Pixel, kein Marketing-Cookie und keine Weitergabe an Werbenetzwerke.",
|
||||||
"Server-Logs des Hosters können IP-Adresse und Zeitpunkt einer Anfrage enthalten. Das ist Betrieb, nicht Analyse.",
|
"Server-Logs des Hosters können IP-Adresse und Zeitpunkt einer Anfrage enthalten. Das ist Betrieb, nicht Analyse.",
|
||||||
"Sie können Auskunft, Berichtigung oder Löschung Ihrer Anfrage verlangen, indem Sie an eldov@w-make.de schreiben oder das Formular mit einem Löschwunsch nutzen. Eine vollständige Datenschutzerklärung nach DSGVO folgt mit der Anschrift vor dem öffentlichen Launch.",
|
"Sie können Auskunft, Berichtigung oder Löschung Ihrer Anfrage verlangen, indem Sie an eldov@w-make.de schreiben. Eine vollständige Datenschutzerklärung nach DSGVO folgt, sobald Anschrift und Firmierung feststehen.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
@@ -28,7 +28,7 @@ const copy = {
|
|||||||
"This website stores inquiries from the contact form in order to reply. Required fields are name, email, and message. The optional company field helps with context.",
|
"This website stores inquiries from the contact form in order to reply. Required fields are name, email, and message. The optional company field helps with context.",
|
||||||
"Data is written to a local SQLite file on the server and forwarded by email to eldov@w-make.de. There is no tracking pixel, no marketing cookie, and no sharing with advertising networks.",
|
"Data is written to a local SQLite file on the server and forwarded by email to eldov@w-make.de. There is no tracking pixel, no marketing cookie, and no sharing with advertising networks.",
|
||||||
"The host’s server logs may include IP address and request time. That is operations, not analytics.",
|
"The host’s server logs may include IP address and request time. That is operations, not analytics.",
|
||||||
"You can request access, correction, or deletion of your inquiry by writing to eldov@w-make.de or through the form. A full GDPR privacy notice will accompany the street address before public launch.",
|
"You can request access, correction, or deletion of your inquiry by writing to eldov@w-make.de. A full GDPR privacy notice will follow once street address and legal entity are final.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
+24
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Locale } from "@/i18n/routes";
|
import type { Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
export type NoteSlug = "buchungen" | "satzzettel";
|
export type NoteSlug = "buchungen" | "satzzettel" | "domaene";
|
||||||
|
|
||||||
export type LocalizedNote = {
|
export type LocalizedNote = {
|
||||||
slug: NoteSlug;
|
slug: NoteSlug;
|
||||||
@@ -57,6 +57,29 @@ const notes: Record<NoteSlug, { date: string; de: Omit<LocalizedNote, "slug" | "
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
domaene: {
|
||||||
|
date: "2026-08-16",
|
||||||
|
de: {
|
||||||
|
title: "Was Domänenwissen in der Produktentwicklung verändert",
|
||||||
|
summary: "Wer den Prozess kennt, baut andere Invarianten. Nicht mehr Features — andere Fehler, die man sich nicht leisten kann.",
|
||||||
|
body: [
|
||||||
|
"In der Behälterglas-Produktion ist ein Rezept keine Liste von Zutaten. Es ist eine Entscheidung über Temperatur, Redox, Kosten und das, was die nächste Schicht vorfindet. Wer das nur als Formularfelder modelliert, verliert die Reihenfolge, in der die Realität zuschlägt.",
|
||||||
|
"Domänenwissen ändert deshalb zuerst die Fragen. Nicht: Welche Maske fehlt? Sondern: Was darf nach dem Wiegen nicht mehr stillschweigend verschwinden? Was muss eine Schicht der nächsten hinterlassen, ohne mündliche Zusatzrechnung?",
|
||||||
|
"Daraus folgen unspektakuläre technische Konsequenzen. Buchungen werden Zustände. Berechnungen bleiben an Versionen gebunden. Validierung ist ein Schritt, den jemand sehen kann. Migrationen und Backups gehören zum Produkt, nicht zur Nachsorge.",
|
||||||
|
"Für technische Entscheider: Stellen Sie jemanden, der den Prozess erklären kann, an den Anfang der Architektur — nicht an das Ende der Abnahme. Der teuerste Fehler ist der, den das System erlaubt, weil niemand ihn für fachlich gehalten hat.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "What domain knowledge changes in product development",
|
||||||
|
summary: "Knowing the process produces different invariants. Not more features — different failures you cannot afford.",
|
||||||
|
body: [
|
||||||
|
"In container-glass production a recipe is not a list of ingredients. It is a decision about temperature, redox, cost, and what the next shift will find. Model that as form fields and you lose the order in which reality hits.",
|
||||||
|
"Domain knowledge therefore changes the questions first. Not: which screen is missing? But: what must not silently disappear after weighing? What must one shift leave the next without a side calculation in someone’s head?",
|
||||||
|
"The technical consequences are unspectacular. Bookings become states. Calculations stay bound to versions. Validation is a step someone can see. Migrations and backups belong to the product, not to aftercare.",
|
||||||
|
"For technical decision-makers: put someone who can explain the process at the start of the architecture — not at the end of acceptance. The most expensive defect is the one the system allows because nobody treated it as domain.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const noteSlugs = Object.keys(notes) as NoteSlug[];
|
export const noteSlugs = Object.keys(notes) as NoteSlug[];
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export const offerIds = ["process", "product", "collaboration"] as const;
|
||||||
|
export type OfferId = (typeof offerIds)[number];
|
||||||
|
|
||||||
|
export function isOfferId(value: string): value is OfferId {
|
||||||
|
return (offerIds as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
@@ -1,30 +1,38 @@
|
|||||||
import type { Locale } from "@/i18n/routes";
|
import type { Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export { offerIds, type OfferId, isOfferId } from "./offer-ids";
|
||||||
|
|
||||||
const copy = {
|
const copy = {
|
||||||
de: [
|
de: [
|
||||||
{
|
{
|
||||||
|
id: "process",
|
||||||
title: "Prozessanalyse",
|
title: "Prozessanalyse",
|
||||||
text: "Ich analysiere einen bestehenden digitalen Prozess, identifiziere fachliche Bruchstellen und übersetze die Ergebnisse in eine priorisierte technische Roadmap.",
|
text: "Ich analysiere einen bestehenden digitalen Prozess, identifiziere fachliche Bruchstellen und übersetze die Ergebnisse in eine priorisierte technische Roadmap.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "product",
|
||||||
title: "Produkt- und Backend-Entwicklung",
|
title: "Produkt- und Backend-Entwicklung",
|
||||||
text: "Ich entwickle oder stabilisiere Anwendungen, bei denen Datenmodell, Geschäftslogik und Betrieb wichtiger sind als ein schneller Prototyp.",
|
text: "Ich entwickle oder stabilisiere Anwendungen, bei denen Datenmodell, Geschäftslogik und Betrieb wichtiger sind als ein schneller Prototyp.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "collaboration",
|
||||||
title: "Technische Zusammenarbeit",
|
title: "Technische Zusammenarbeit",
|
||||||
text: "Ich unterstütze Teams bei Architekturentscheidungen, schwierigen Integrationen und der Überführung von gewachsenem Code in ein wartbares System.",
|
text: "Ich unterstütze Teams bei Architekturentscheidungen, schwierigen Integrationen und der Überführung von gewachsenem Code in ein wartbares System.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
en: [
|
en: [
|
||||||
{
|
{
|
||||||
|
id: "process",
|
||||||
title: "Process analysis",
|
title: "Process analysis",
|
||||||
text: "I analyse an existing digital process, make domain fractures visible, and turn the findings into a prioritised technical roadmap.",
|
text: "I analyse an existing digital process, make domain fractures visible, and turn the findings into a prioritised technical roadmap.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "product",
|
||||||
title: "Product and backend development",
|
title: "Product and backend development",
|
||||||
text: "I build or stabilise applications where the data model, domain logic, and operations matter more than a fast prototype.",
|
text: "I build or stabilise applications where the data model, domain logic, and operations matter more than a fast prototype.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "collaboration",
|
||||||
title: "Technical collaboration",
|
title: "Technical collaboration",
|
||||||
text: "I help teams with architecture decisions, difficult integrations, and turning accumulated code into a maintainable system.",
|
text: "I help teams with architecture decisions, difficult integrations, and turning accumulated code into a maintainable system.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type LocalizedProject = {
|
|||||||
label: string;
|
label: string;
|
||||||
title: string;
|
title: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
stance: string;
|
||||||
stack: string;
|
stack: string;
|
||||||
role: string;
|
role: string;
|
||||||
situation: string;
|
situation: string;
|
||||||
@@ -42,6 +43,7 @@ const projects: Record<ProjectSlug, { number: string; href?: string; images: Pro
|
|||||||
label: "Industrie-Software · Produktentwicklung",
|
label: "Industrie-Software · Produktentwicklung",
|
||||||
title: "W-Make Batch",
|
title: "W-Make Batch",
|
||||||
summary: "Batchmanagement für die Behälterglas-Produktion — vom Satzzettel bis zum Tagesprotokoll.",
|
summary: "Batchmanagement für die Behälterglas-Produktion — vom Satzzettel bis zum Tagesprotokoll.",
|
||||||
|
stance: "Das Produktionsprodukt. Eine öffentliche Demo zeigt den Ablauf, nicht den Kundenstand.",
|
||||||
stack: "Node.js · Express · SQLite · Web Components · native Tests · Playwright",
|
stack: "Node.js · Express · SQLite · Web Components · native Tests · Playwright",
|
||||||
role: "Domänenmodell, Full-Stack-Entwicklung und technische Qualität.",
|
role: "Domänenmodell, Full-Stack-Entwicklung und technische Qualität.",
|
||||||
situation: "Ein Produktionsprozess lebt von Rezepten, Materialverbrauch, Silos, Schichten und Berechnungen. Wird das als lose Sammlung von Masken gebaut, gehen fachliche Zusammenhänge verloren — und Buchungen werden zu editierbaren Tabellenzeilen.",
|
situation: "Ein Produktionsprozess lebt von Rezepten, Materialverbrauch, Silos, Schichten und Berechnungen. Wird das als lose Sammlung von Masken gebaut, gehen fachliche Zusammenhänge verloren — und Buchungen werden zu editierbaren Tabellenzeilen.",
|
||||||
@@ -64,6 +66,7 @@ const projects: Record<ProjectSlug, { number: string; href?: string; images: Pro
|
|||||||
label: "Industrial software · product development",
|
label: "Industrial software · product development",
|
||||||
title: "W-Make Batch",
|
title: "W-Make Batch",
|
||||||
summary: "Batch management for container-glass production — from the batch ticket to the daily log.",
|
summary: "Batch management for container-glass production — from the batch ticket to the daily log.",
|
||||||
|
stance: "The production product. A public demo shows the flow, not a client installation.",
|
||||||
stack: "Node.js · Express · SQLite · Web Components · native tests · Playwright",
|
stack: "Node.js · Express · SQLite · Web Components · native tests · Playwright",
|
||||||
role: "Domain model, full-stack development, and technical quality.",
|
role: "Domain model, full-stack development, and technical quality.",
|
||||||
situation: "A production process lives on recipes, material use, silos, shifts, and calculations. Built as a loose set of screens, the domain relationships disappear — and bookings become editable table rows.",
|
situation: "A production process lives on recipes, material use, silos, shifts, and calculations. Built as a loose set of screens, the domain relationships disappear — and bookings become editable table rows.",
|
||||||
@@ -94,6 +97,7 @@ const projects: Record<ProjectSlug, { number: string; href?: string; images: Pro
|
|||||||
label: "Standalone · Recipe Engineering",
|
label: "Standalone · Recipe Engineering",
|
||||||
title: "Batchmaker Studio",
|
title: "Batchmaker Studio",
|
||||||
summary: "Schlankes Recipe-Studio für Rezepte, Rohstoffe, Satzzettel und Glaschemie.",
|
summary: "Schlankes Recipe-Studio für Rezepte, Rohstoffe, Satzzettel und Glaschemie.",
|
||||||
|
stance: "Die Werkbank am Schreibtisch. Derselbe Fachkern, bewusst ohne Silos, Schichten und laufende Buchungen.",
|
||||||
stack: "Node.js · Express · node:sqlite · Vanilla JS · Vite · native Tests",
|
stack: "Node.js · Express · node:sqlite · Vanilla JS · Vite · native Tests",
|
||||||
role: "Produktarchitektur, Full-Stack-Entwicklung und Calculation Engine.",
|
role: "Produktarchitektur, Full-Stack-Entwicklung und Calculation Engine.",
|
||||||
situation: "Rezeptarbeit braucht denselben fachlichen Kern wie die Produktion — aber nicht Silos, Schichten und laufende Buchungen. Ein volles Betriebssystem ist für die Entwicklung am Schreibtisch zu schwer.",
|
situation: "Rezeptarbeit braucht denselben fachlichen Kern wie die Produktion — aber nicht Silos, Schichten und laufende Buchungen. Ein volles Betriebssystem ist für die Entwicklung am Schreibtisch zu schwer.",
|
||||||
@@ -116,6 +120,7 @@ const projects: Record<ProjectSlug, { number: string; href?: string; images: Pro
|
|||||||
label: "Standalone · recipe engineering",
|
label: "Standalone · recipe engineering",
|
||||||
title: "Batchmaker Studio",
|
title: "Batchmaker Studio",
|
||||||
summary: "A lean recipe studio for recipes, raw materials, batch tickets, and glass chemistry.",
|
summary: "A lean recipe studio for recipes, raw materials, batch tickets, and glass chemistry.",
|
||||||
|
stance: "The desk-side workbench. The same domain core, deliberately without silos, shifts, and live bookings.",
|
||||||
stack: "Node.js · Express · node:sqlite · Vanilla JS · Vite · native tests",
|
stack: "Node.js · Express · node:sqlite · Vanilla JS · Vite · native tests",
|
||||||
role: "Product architecture, full-stack development, and calculation engine.",
|
role: "Product architecture, full-stack development, and calculation engine.",
|
||||||
situation: "Recipe work needs the same domain core as production — but not silos, shifts, and live bookings. A full operations system is too heavy for desk-side development.",
|
situation: "Recipe work needs the same domain core as production — but not silos, shifts, and live bookings. A full operations system is too heavy for desk-side development.",
|
||||||
|
|||||||
@@ -62,10 +62,16 @@ export const dictionaries = {
|
|||||||
email: "E-Mail",
|
email: "E-Mail",
|
||||||
company: "Unternehmen (optional)",
|
company: "Unternehmen (optional)",
|
||||||
message: "Worum geht es?",
|
message: "Worum geht es?",
|
||||||
|
topic: "Einstieg (optional)",
|
||||||
|
topicAny: "Anderes / noch unklar",
|
||||||
honeypot: "Website",
|
honeypot: "Website",
|
||||||
success: "Danke. Die Anfrage ist eingegangen.",
|
success: "Danke. Die Anfrage ist eingegangen.",
|
||||||
error: "Bitte prüfen Sie die markierten Felder.",
|
error: "Bitte prüfen Sie die markierten Felder.",
|
||||||
network: "Senden gerade nicht möglich. Bitte später erneut versuchen.",
|
network: "Senden gerade nicht möglich. Bitte später erneut versuchen oder direkt schreiben.",
|
||||||
|
delivery: "Die Anfrage wurde gespeichert, die E-Mail ist fehlgeschlagen. Bitte schreiben Sie direkt an",
|
||||||
|
rate: "Zu viele Anfragen in kurzer Zeit. Bitte schreiben Sie direkt per E-Mail.",
|
||||||
|
reply: "Ich antworte in der Regel innerhalb von zwei Werktagen — und nur, wenn ich wirklich helfen kann.",
|
||||||
|
emailDirect: "Direkt per E-Mail",
|
||||||
},
|
},
|
||||||
legal: {
|
legal: {
|
||||||
imprintKicker: "Impressum",
|
imprintKicker: "Impressum",
|
||||||
@@ -86,6 +92,10 @@ export const dictionaries = {
|
|||||||
imprint: "Impressum",
|
imprint: "Impressum",
|
||||||
privacy: "Datenschutz",
|
privacy: "Datenschutz",
|
||||||
},
|
},
|
||||||
|
a11y: {
|
||||||
|
skip: "Zum Inhalt",
|
||||||
|
nav: "Hauptnavigation",
|
||||||
|
},
|
||||||
language: {
|
language: {
|
||||||
de: "Deutsch",
|
de: "Deutsch",
|
||||||
en: "English",
|
en: "English",
|
||||||
@@ -153,10 +163,16 @@ export const dictionaries = {
|
|||||||
email: "Email",
|
email: "Email",
|
||||||
company: "Company (optional)",
|
company: "Company (optional)",
|
||||||
message: "What is the process?",
|
message: "What is the process?",
|
||||||
|
topic: "Starting point (optional)",
|
||||||
|
topicAny: "Something else / not sure yet",
|
||||||
honeypot: "Website",
|
honeypot: "Website",
|
||||||
success: "Thank you. The inquiry has been received.",
|
success: "Thank you. The inquiry has been received.",
|
||||||
error: "Please check the highlighted fields.",
|
error: "Please check the highlighted fields.",
|
||||||
network: "Sending is unavailable right now. Please try again later.",
|
network: "Sending is unavailable right now. Please try again later or write directly.",
|
||||||
|
delivery: "The inquiry was stored, but email delivery failed. Please write directly to",
|
||||||
|
rate: "Too many inquiries in a short time. Please write directly by email.",
|
||||||
|
reply: "I usually reply within two working days — and only when I can actually help.",
|
||||||
|
emailDirect: "Email directly",
|
||||||
},
|
},
|
||||||
legal: {
|
legal: {
|
||||||
imprintKicker: "Legal notice",
|
imprintKicker: "Legal notice",
|
||||||
@@ -177,6 +193,10 @@ export const dictionaries = {
|
|||||||
imprint: "Legal notice",
|
imprint: "Legal notice",
|
||||||
privacy: "Privacy",
|
privacy: "Privacy",
|
||||||
},
|
},
|
||||||
|
a11y: {
|
||||||
|
skip: "Skip to content",
|
||||||
|
nav: "Main navigation",
|
||||||
|
},
|
||||||
language: {
|
language: {
|
||||||
de: "Deutsch",
|
de: "Deutsch",
|
||||||
en: "English",
|
en: "English",
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { deliverInquiry, type InquiryActionResult } from "./deliver";
|
||||||
import { allowInquiry } from "./rate-limit";
|
import { allowInquiry } from "./rate-limit";
|
||||||
import { sendInquiryMail } from "./mail";
|
import { sendInquiryMail } from "./mail";
|
||||||
import { validateInquiry } from "./schema";
|
import { validateInquiry } from "./schema";
|
||||||
import { saveInquiry } from "./store";
|
import { saveInquiry } from "./store";
|
||||||
|
|
||||||
export type InquiryActionResult =
|
export type { InquiryActionResult };
|
||||||
| { ok: true }
|
|
||||||
| { ok: false; code: "invalid" | "rate" | "error"; field?: string };
|
|
||||||
|
|
||||||
export async function submitInquiry(formData: FormData): Promise<InquiryActionResult> {
|
export async function submitInquiry(formData: FormData): Promise<InquiryActionResult> {
|
||||||
const parsed = validateInquiry({
|
const parsed = validateInquiry({
|
||||||
@@ -18,6 +17,7 @@ export async function submitInquiry(formData: FormData): Promise<InquiryActionRe
|
|||||||
message: formData.get("message"),
|
message: formData.get("message"),
|
||||||
locale: formData.get("locale"),
|
locale: formData.get("locale"),
|
||||||
website: formData.get("website"),
|
website: formData.get("website"),
|
||||||
|
topic: formData.get("topic"),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!parsed.ok && parsed.code === "spam") return { ok: true };
|
if (!parsed.ok && parsed.code === "spam") return { ok: true };
|
||||||
@@ -28,11 +28,8 @@ export async function submitInquiry(formData: FormData): Promise<InquiryActionRe
|
|||||||
const ip = forwarded || headerList.get("x-real-ip") || "unknown";
|
const ip = forwarded || headerList.get("x-real-ip") || "unknown";
|
||||||
if (!allowInquiry(ip)) return { ok: false, code: "rate" };
|
if (!allowInquiry(ip)) return { ok: false, code: "rate" };
|
||||||
|
|
||||||
try {
|
return deliverInquiry(parsed.data, {
|
||||||
saveInquiry(parsed.data);
|
save: saveInquiry,
|
||||||
await sendInquiryMail(parsed.data);
|
send: sendInquiryMail,
|
||||||
return { ok: true };
|
});
|
||||||
} catch {
|
|
||||||
return { ok: false, code: "error" };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { deliverInquiry } from "./deliver.ts";
|
||||||
|
import type { Inquiry } from "./schema.ts";
|
||||||
|
|
||||||
|
const inquiry: Inquiry = {
|
||||||
|
name: "Anna Müller",
|
||||||
|
email: "anna@example.com",
|
||||||
|
message: "Wir brauchen eine belastbare Abbildung unseres Satzprozesses.",
|
||||||
|
locale: "de",
|
||||||
|
topic: "process",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("deliverInquiry", () => {
|
||||||
|
it("saves and succeeds when mail is sent", async () => {
|
||||||
|
const saved: Inquiry[] = [];
|
||||||
|
const result = await deliverInquiry(inquiry, {
|
||||||
|
save: (item) => {
|
||||||
|
saved.push(item);
|
||||||
|
return "id-1";
|
||||||
|
},
|
||||||
|
send: async () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { ok: true });
|
||||||
|
assert.equal(saved.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not report success when mail fails after save", async () => {
|
||||||
|
let saved = false;
|
||||||
|
const result = await deliverInquiry(inquiry, {
|
||||||
|
save: () => {
|
||||||
|
saved = true;
|
||||||
|
return "id-2";
|
||||||
|
},
|
||||||
|
send: async () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(saved, true);
|
||||||
|
assert.deepEqual(result, { ok: false, code: "delivery" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when save throws", async () => {
|
||||||
|
const result = await deliverInquiry(inquiry, {
|
||||||
|
save: () => {
|
||||||
|
throw new Error("disk");
|
||||||
|
},
|
||||||
|
send: async () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { ok: false, code: "error" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Inquiry } from "./schema";
|
||||||
|
|
||||||
|
export type InquiryActionResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; code: "invalid" | "rate" | "error" | "delivery"; field?: string };
|
||||||
|
|
||||||
|
export async function deliverInquiry(
|
||||||
|
inquiry: Inquiry,
|
||||||
|
deps: {
|
||||||
|
save: (inquiry: Inquiry) => string;
|
||||||
|
send: (inquiry: Inquiry) => Promise<boolean>;
|
||||||
|
},
|
||||||
|
): Promise<InquiryActionResult> {
|
||||||
|
try {
|
||||||
|
deps.save(inquiry);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, code: "error" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailed = await deps.send(inquiry);
|
||||||
|
if (!mailed) return { ok: false, code: "delivery" };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ async function smtpSend(config: SmtpConfig, inquiry: Inquiry): Promise<void> {
|
|||||||
`Name: ${inquiry.name}`,
|
`Name: ${inquiry.name}`,
|
||||||
`E-Mail: ${inquiry.email}`,
|
`E-Mail: ${inquiry.email}`,
|
||||||
`Unternehmen: ${inquiry.company || "—"}`,
|
`Unternehmen: ${inquiry.company || "—"}`,
|
||||||
|
`Thema: ${inquiry.topic || "—"}`,
|
||||||
`Sprache: ${inquiry.locale}`,
|
`Sprache: ${inquiry.locale}`,
|
||||||
"",
|
"",
|
||||||
inquiry.message,
|
inquiry.message,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { validateInquiry } from "./schema.ts";
|
import { validateInquiry } from "./schema.ts";
|
||||||
|
import { offerIds } from "../../content/offer-ids.ts";
|
||||||
|
|
||||||
describe("validateInquiry", () => {
|
describe("validateInquiry", () => {
|
||||||
const valid = {
|
const valid = {
|
||||||
@@ -37,4 +38,24 @@ describe("validateInquiry", () => {
|
|||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
if (result.ok) assert.equal(result.data.company, undefined);
|
if (result.ok) assert.equal(result.data.company, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts every published offer id", () => {
|
||||||
|
for (const topic of offerIds) {
|
||||||
|
const result = validateInquiry({ ...valid, topic });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
if (result.ok) assert.equal(result.data.topic, topic);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown topic", () => {
|
||||||
|
const result = validateInquiry({ ...valid, topic: "casino" });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (!result.ok) assert.equal(result.field, "topic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a blank topic as omitted", () => {
|
||||||
|
const result = validateInquiry({ ...valid, topic: " " });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
if (result.ok) assert.equal(result.data.topic, undefined);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,14 +5,23 @@ export type InquiryInput = {
|
|||||||
message: unknown;
|
message: unknown;
|
||||||
locale: unknown;
|
locale: unknown;
|
||||||
website?: unknown;
|
website?: unknown;
|
||||||
|
topic?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OFFER_TOPICS = ["process", "product", "collaboration"] as const;
|
||||||
|
export type InquiryTopic = (typeof OFFER_TOPICS)[number];
|
||||||
|
|
||||||
|
function isInquiryTopic(value: string): value is InquiryTopic {
|
||||||
|
return (OFFER_TOPICS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
export type Inquiry = {
|
export type Inquiry = {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
company?: string;
|
company?: string;
|
||||||
message: string;
|
message: string;
|
||||||
locale: "de" | "en";
|
locale: "de" | "en";
|
||||||
|
topic?: InquiryTopic;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ValidationResult =
|
export type ValidationResult =
|
||||||
@@ -30,6 +39,7 @@ export function validateInquiry(input: InquiryInput): ValidationResult {
|
|||||||
const email = typeof input.email === "string" ? input.email.trim() : "";
|
const email = typeof input.email === "string" ? input.email.trim() : "";
|
||||||
const message = typeof input.message === "string" ? input.message.trim() : "";
|
const message = typeof input.message === "string" ? input.message.trim() : "";
|
||||||
const companyRaw = typeof input.company === "string" ? input.company.trim() : "";
|
const companyRaw = typeof input.company === "string" ? input.company.trim() : "";
|
||||||
|
const topicRaw = typeof input.topic === "string" ? input.topic.trim() : "";
|
||||||
const locale = input.locale === "en" || input.locale === "de" ? input.locale : null;
|
const locale = input.locale === "en" || input.locale === "de" ? input.locale : null;
|
||||||
|
|
||||||
if (name.length < 2 || name.length > 80) return { ok: false, code: "invalid", field: "name" };
|
if (name.length < 2 || name.length > 80) return { ok: false, code: "invalid", field: "name" };
|
||||||
@@ -37,6 +47,8 @@ export function validateInquiry(input: InquiryInput): ValidationResult {
|
|||||||
if (message.length < 20 || message.length > 4000) return { ok: false, code: "invalid", field: "message" };
|
if (message.length < 20 || message.length > 4000) return { ok: false, code: "invalid", field: "message" };
|
||||||
if (!locale) return { ok: false, code: "invalid", field: "locale" };
|
if (!locale) return { ok: false, code: "invalid", field: "locale" };
|
||||||
if (companyRaw.length > 120) return { ok: false, code: "invalid", field: "company" };
|
if (companyRaw.length > 120) return { ok: false, code: "invalid", field: "company" };
|
||||||
|
if (topicRaw && !isInquiryTopic(topicRaw)) return { ok: false, code: "invalid", field: "topic" };
|
||||||
|
const topic: InquiryTopic | undefined = isInquiryTopic(topicRaw) ? topicRaw : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -46,6 +58,7 @@ export function validateInquiry(input: InquiryInput): ValidationResult {
|
|||||||
message,
|
message,
|
||||||
locale,
|
locale,
|
||||||
...(companyRaw ? { company: companyRaw } : {}),
|
...(companyRaw ? { company: companyRaw } : {}),
|
||||||
|
...(topic ? { topic } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,14 @@ function getDb(): DatabaseSync {
|
|||||||
company TEXT,
|
company TEXT,
|
||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
locale TEXT NOT NULL,
|
locale TEXT NOT NULL,
|
||||||
|
topic TEXT,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
)`);
|
)`);
|
||||||
|
try {
|
||||||
|
db.exec("ALTER TABLE inquiries ADD COLUMN topic TEXT");
|
||||||
|
} catch {
|
||||||
|
// Existing databases already have the column after the first migrate.
|
||||||
|
}
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +34,7 @@ export function saveInquiry(inquiry: Inquiry): string {
|
|||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
getDb()
|
getDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
"INSERT INTO inquiries (id, name, email, company, message, locale, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO inquiries (id, name, email, company, message, locale, topic, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
id,
|
id,
|
||||||
@@ -37,6 +43,7 @@ export function saveInquiry(inquiry: Inquiry): string {
|
|||||||
inquiry.company ?? null,
|
inquiry.company ?? null,
|
||||||
inquiry.message,
|
inquiry.message,
|
||||||
inquiry.locale,
|
inquiry.locale,
|
||||||
|
inquiry.topic ?? null,
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
);
|
);
|
||||||
return id;
|
return id;
|
||||||
|
|||||||
Reference in New Issue
Block a user