feat: contact topics per audience, room above the last sentence, a hero line that says what a client gets
Contact: the message starter offered every topic on both pages. The homepage now offers automation, a website, or something else; the freelance page a developer, a reliable app, or something else. Thread: where it crosses the page above the last sentence it ran too close to the text. It keeps more distance now, on phones too. Hero: the line under the hero listed years, working hours and two links, which says nothing to a client (owner). It now names four things a client gets: one contact who builds it himself, an offer before anything costs money, changes by email, servers in Germany. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,9 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a team may ask for. Clients have their own on the homepage. */
|
||||||
|
const TEAM_TOPICS = ["developer", "rescue", "other"] as const;
|
||||||
|
|
||||||
const MUTED = "text-slate-400";
|
const MUTED = "text-slate-400";
|
||||||
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
|
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
|
||||||
const LIST_TITLE = "m-0 font-sans text-xl font-bold tracking-tight md:text-2xl";
|
const LIST_TITLE = "m-0 font-sans text-xl font-bold tracking-tight md:text-2xl";
|
||||||
@@ -255,7 +258,7 @@ export default async function FreelancePage({ params }: Props) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
</Block>
|
</Block>
|
||||||
|
|
||||||
<ClosingCta contact={content.contact} />
|
<ClosingCta contact={content.contact} topics={TEAM_TOPICS} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a client of the homepage may ask for. Teams have their own on the freelance page. */
|
||||||
|
const HOME_TOPICS = ["automation", "website", "other"] as const;
|
||||||
|
|
||||||
const MUTED = "text-slate-400";
|
const MUTED = "text-slate-400";
|
||||||
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
|
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
|
||||||
const LIST_TITLE =
|
const LIST_TITLE =
|
||||||
@@ -270,7 +273,11 @@ export default async function LandingPage({ params }: Props) {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
</Block>
|
</Block>
|
||||||
|
|
||||||
<ClosingCta contact={content.contact} story={story.contact} />
|
<ClosingCta
|
||||||
|
contact={content.contact}
|
||||||
|
topics={HOME_TOPICS}
|
||||||
|
story={story.contact}
|
||||||
|
/>
|
||||||
<StoryThread />
|
<StoryThread />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import { ContactComposer } from "./ContactComposer";
|
|||||||
/** ClosingCta: the contact section. One address and a message starter, no form to submit. */
|
/** ClosingCta: the contact section. One address and a message starter, no form to submit. */
|
||||||
export const ClosingCta: React.FC<{
|
export const ClosingCta: React.FC<{
|
||||||
contact: Content["contact"];
|
contact: Content["contact"];
|
||||||
|
/** What visitors of this page may want: the topics of the message starter. */
|
||||||
|
topics: readonly string[];
|
||||||
/** The last sentence of the story; the story thread ends in front of it. */
|
/** The last sentence of the story; the story thread ends in front of it. */
|
||||||
story?: string;
|
story?: string;
|
||||||
}> = ({ contact, story }) => (
|
}> = ({ contact, topics, story }) => (
|
||||||
<section
|
<section
|
||||||
id="contact"
|
id="contact"
|
||||||
data-glow
|
data-glow
|
||||||
@@ -28,7 +30,7 @@ export const ClosingCta: React.FC<{
|
|||||||
/>
|
/>
|
||||||
<div className="bit-glow bit-glow-dots" aria-hidden="true" />
|
<div className="bit-glow bit-glow-dots" aria-hidden="true" />
|
||||||
<BitSeam />
|
<BitSeam />
|
||||||
<div className="relative mx-auto grid w-full max-w-[1400px] grid-cols-1 gap-12 px-5 py-16 md:px-12 md:py-40 lg:grid-cols-12 lg:items-center lg:gap-12">
|
<div className="relative mx-auto grid w-full max-w-[1400px] grid-cols-1 gap-12 px-5 pb-16 pt-28 md:px-12 md:py-40 lg:grid-cols-12 lg:items-center lg:gap-12">
|
||||||
<Reveal className="lg:col-span-6">
|
<Reveal className="lg:col-span-6">
|
||||||
{story && (
|
{story && (
|
||||||
<p className="relative m-0 mb-10 max-w-xl font-sans text-2xl font-bold leading-[1.1] tracking-tight text-white md:mb-14 md:text-5xl">
|
<p className="relative m-0 mb-10 max-w-xl font-sans text-2xl font-bold leading-[1.1] tracking-tight text-white md:mb-14 md:text-5xl">
|
||||||
@@ -55,7 +57,7 @@ export const ClosingCta: React.FC<{
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={0.15} className="lg:col-span-6">
|
<Reveal delay={0.15} className="lg:col-span-6">
|
||||||
<BitAura source="around" tone="dark">
|
<BitAura source="around" tone="dark">
|
||||||
<ContactComposer />
|
<ContactComposer topicIds={topics} />
|
||||||
</BitAura>
|
</BitAura>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,9 +51,16 @@ const withBlanks = (text: string): React.ReactNode =>
|
|||||||
* opens it in the visitor's own mail app, ready to finish and send. The address
|
* opens it in the visitor's own mail app, ready to finish and send. The address
|
||||||
* is assembled in the browser, so it never appears in the page source.
|
* is assembled in the browser, so it never appears in the page source.
|
||||||
*/
|
*/
|
||||||
export const ContactComposer: React.FC = () => {
|
export const ContactComposer: React.FC<{
|
||||||
|
/** The topics this page offers, in this order. */
|
||||||
|
topicIds: readonly string[];
|
||||||
|
}> = ({ topicIds }) => {
|
||||||
const { contact } = useContent();
|
const { contact } = useContent();
|
||||||
const topics = contact.topics;
|
const topics = React.useMemo(
|
||||||
|
() =>
|
||||||
|
topicIds.flatMap((id) => contact.topics.filter((item) => item.id === id)),
|
||||||
|
[contact.topics, topicIds],
|
||||||
|
);
|
||||||
const [topicId, setTopicId] = React.useState(topics[0]?.id ?? "");
|
const [topicId, setTopicId] = React.useState(topics[0]?.id ?? "");
|
||||||
const [address, setAddress] = React.useState<string | null>(null);
|
const [address, setAddress] = React.useState<string | null>(null);
|
||||||
const [copied, setCopied] = React.useState(false);
|
const [copied, setCopied] = React.useState(false);
|
||||||
|
|||||||
@@ -43,35 +43,20 @@ export const HeroStatement: React.FC<{ hero: Content["hero"] }> = ({
|
|||||||
</div>
|
</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">
|
{/* What a client gets, in a few words. */}
|
||||||
|
<ul className="m-0 mt-14 grid list-none grid-cols-1 gap-x-10 gap-y-3 border-t border-slate-200 p-0 pt-6 sm:grid-cols-2 md:mt-20 lg:grid-cols-4">
|
||||||
{hero.trust.map((item) => (
|
{hero.trust.map((item) => (
|
||||||
<li
|
<li
|
||||||
key={item}
|
key={item}
|
||||||
className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]"
|
className="flex items-start gap-3 font-sans text-sm leading-snug text-slate-700 md:text-base"
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
className="mt-[0.45em] h-1.5 w-1.5 shrink-0 bg-indigo-600"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
{item}
|
{item}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
<li className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]">
|
|
||||||
{hero.builtAndRunning}{" "}
|
|
||||||
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const CONTENT_WIDTH = 1400;
|
|||||||
/** Offset of the second, parallel trace. */
|
/** Offset of the second, parallel trace. */
|
||||||
const BUS_OFFSET = 5;
|
const BUS_OFFSET = 5;
|
||||||
const PAD_RADIUS = 4;
|
const PAD_RADIUS = 4;
|
||||||
|
/** Room between the last crossing of the trace and the sentence it ends at. */
|
||||||
|
const END_DROP = { wide: 120, narrow: 84 } as const;
|
||||||
/** The bit at the head of the trace flips after this much travel, in px. */
|
/** The bit at the head of the trace flips after this much travel, in px. */
|
||||||
const FLIP_EVERY = 46;
|
const FLIP_EVERY = 46;
|
||||||
/** The first chapter with a figure is the second of the page. */
|
/** The first chapter with a figure is the second of the page. */
|
||||||
@@ -127,6 +129,7 @@ export const StoryThread: React.FC = () => {
|
|||||||
start: [left, boxOf(startSection).y + 1],
|
start: [left, boxOf(startSection).y + 1],
|
||||||
stops,
|
stops,
|
||||||
end: endBox ? [endBox.x, endBox.y] : null,
|
end: endBox ? [endBox.x, endBox.y] : null,
|
||||||
|
endDrop: origin.width >= 768 ? END_DROP.wide : END_DROP.narrow,
|
||||||
left,
|
left,
|
||||||
right,
|
right,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,8 +72,6 @@ const POLYGONS: Readonly<Record<Exclude<Figure, "circle">, readonly Point[]>> =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CORNER = 36;
|
const CORNER = 36;
|
||||||
/** How far above its end the trace crosses the page when it comes from the other side. */
|
|
||||||
const END_DROP = 64;
|
|
||||||
const round = (value: number): number => Math.round(value * 10) / 10;
|
const round = (value: number): number => Math.round(value * 10) / 10;
|
||||||
|
|
||||||
/** The figure as path commands. `direction` is 1 when the line arrives heading right, -1 heading left. */
|
/** The figure as path commands. `direction` is 1 when the line arrives heading right, -1 heading left. */
|
||||||
@@ -111,6 +109,8 @@ export const threadPath = (thread: {
|
|||||||
readonly start: Point;
|
readonly start: Point;
|
||||||
readonly stops: readonly Stop[];
|
readonly stops: readonly Stop[];
|
||||||
readonly end: Point | null;
|
readonly end: Point | null;
|
||||||
|
/** How far above its end the trace crosses the page when it comes from the other side. */
|
||||||
|
readonly endDrop: number;
|
||||||
readonly left: number;
|
readonly left: number;
|
||||||
readonly right: number;
|
readonly right: number;
|
||||||
}): Thread => {
|
}): Thread => {
|
||||||
@@ -144,7 +144,7 @@ export const threadPath = (thread: {
|
|||||||
// The end is on the other side: cross the page above it, then come
|
// The end is on the other side: cross the page above it, then come
|
||||||
// straight down to it, beside the text. One long diagonal instead would start far up the page.
|
// straight down to it, beside the text. One long diagonal instead would start far up the page.
|
||||||
const inward = x < side ? -1 : 1;
|
const inward = x < side ? -1 : 1;
|
||||||
const across = round(y - END_DROP);
|
const across = round(y - thread.endDrop);
|
||||||
d +=
|
d +=
|
||||||
`L${side} ${across - CORNER}L${side + inward * CORNER} ${across}` +
|
`L${side} ${across - CORNER}L${side + inward * CORNER} ${across}` +
|
||||||
`L${round(x - inward * CORNER)} ${across}L${round(x)} ${across + CORNER}L${round(x)} ${round(y)}`;
|
`L${round(x - inward * CORNER)} ${across}L${round(x)} ${across + CORNER}L${round(x)} ${round(y)}`;
|
||||||
|
|||||||
@@ -44,8 +44,12 @@ export const de: Content = {
|
|||||||
leadStrong: "Ich baue Web-Apps, Interfaces und Websites.",
|
leadStrong: "Ich baue Web-Apps, Interfaces und Websites.",
|
||||||
primaryCta: GET_IN_TOUCH,
|
primaryCta: GET_IN_TOUCH,
|
||||||
secondaryCta: "Was ich mache",
|
secondaryCta: "Was ich mache",
|
||||||
trust: ["15 Jahre im Web", "Remote, EU-Kernzeiten"],
|
trust: [
|
||||||
builtAndRunning: "Gebaut und in Betrieb:",
|
"Ein Ansprechpartner, der es selbst baut",
|
||||||
|
"Ein Angebot, bevor etwas kostet",
|
||||||
|
"Änderungen per E-Mail, kein System zu lernen",
|
||||||
|
"Server in Deutschland",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
hello: {
|
hello: {
|
||||||
label: "Hallo",
|
label: "Hallo",
|
||||||
|
|||||||
@@ -43,8 +43,12 @@ export const en: Content = {
|
|||||||
leadStrong: "I build web apps, interfaces and websites.",
|
leadStrong: "I build web apps, interfaces and websites.",
|
||||||
primaryCta: GET_IN_TOUCH,
|
primaryCta: GET_IN_TOUCH,
|
||||||
secondaryCta: "What I do",
|
secondaryCta: "What I do",
|
||||||
trust: ["15 years in web", "Remote, EU core hours"],
|
trust: [
|
||||||
builtAndRunning: "Built and running:",
|
"One contact, who builds it himself",
|
||||||
|
"An offer before anything costs money",
|
||||||
|
"Changes by email, no system to learn",
|
||||||
|
"Servers in Germany",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
hello: {
|
hello: {
|
||||||
label: "Hello",
|
label: "Hello",
|
||||||
|
|||||||
@@ -171,7 +171,6 @@ export type Content = {
|
|||||||
readonly primaryCta: string;
|
readonly primaryCta: string;
|
||||||
readonly secondaryCta: string;
|
readonly secondaryCta: string;
|
||||||
readonly trust: readonly string[];
|
readonly trust: readonly string[];
|
||||||
readonly builtAndRunning: string;
|
|
||||||
};
|
};
|
||||||
readonly hello: {
|
readonly hello: {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user