feat: contact topics per audience, room above the last sentence, a hero line that says what a client gets
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Canceled after 4m30s
Build & Deploy / 🚀 Deploy (push) Canceled after 0s
Build & Deploy / 🩺 Smoke Test (push) Canceled after 0s
Build & Deploy / 🔔 Notify (push) Canceled after 0s
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Canceled after 4m30s
Build & Deploy / 🚀 Deploy (push) Canceled after 0s
Build & Deploy / 🩺 Smoke Test (push) Canceled after 0s
Build & Deploy / 🔔 Notify (push) Canceled after 0s
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 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";
|
||||
@@ -255,7 +258,7 @@ export default async function FreelancePage({ params }: Props) {
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<ClosingCta contact={content.contact} />
|
||||
<ClosingCta contact={content.contact} topics={TEAM_TOPICS} />
|
||||
</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 BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
|
||||
const LIST_TITLE =
|
||||
@@ -270,7 +273,11 @@ export default async function LandingPage({ params }: Props) {
|
||||
</Reveal>
|
||||
</Block>
|
||||
|
||||
<ClosingCta contact={content.contact} story={story.contact} />
|
||||
<ClosingCta
|
||||
contact={content.contact}
|
||||
topics={HOME_TOPICS}
|
||||
story={story.contact}
|
||||
/>
|
||||
<StoryThread />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,9 +10,11 @@ import { ContactComposer } from "./ContactComposer";
|
||||
/** ClosingCta: the contact section. One address and a message starter, no form to submit. */
|
||||
export const ClosingCta: React.FC<{
|
||||
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. */
|
||||
story?: string;
|
||||
}> = ({ contact, story }) => (
|
||||
}> = ({ contact, topics, story }) => (
|
||||
<section
|
||||
id="contact"
|
||||
data-glow
|
||||
@@ -28,7 +30,7 @@ export const ClosingCta: React.FC<{
|
||||
/>
|
||||
<div className="bit-glow bit-glow-dots" aria-hidden="true" />
|
||||
<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">
|
||||
{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">
|
||||
@@ -55,7 +57,7 @@ export const ClosingCta: React.FC<{
|
||||
</Reveal>
|
||||
<Reveal delay={0.15} className="lg:col-span-6">
|
||||
<BitAura source="around" tone="dark">
|
||||
<ContactComposer />
|
||||
<ContactComposer topicIds={topics} />
|
||||
</BitAura>
|
||||
</Reveal>
|
||||
</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
|
||||
* 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 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 [address, setAddress] = React.useState<string | null>(null);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
@@ -43,35 +43,20 @@ export const HeroStatement: React.FC<{ hero: Content["hero"] }> = ({
|
||||
</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) => (
|
||||
<li
|
||||
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}
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -29,6 +29,8 @@ const CONTENT_WIDTH = 1400;
|
||||
/** Offset of the second, parallel trace. */
|
||||
const BUS_OFFSET = 5;
|
||||
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. */
|
||||
const FLIP_EVERY = 46;
|
||||
/** 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],
|
||||
stops,
|
||||
end: endBox ? [endBox.x, endBox.y] : null,
|
||||
endDrop: origin.width >= 768 ? END_DROP.wide : END_DROP.narrow,
|
||||
left,
|
||||
right,
|
||||
});
|
||||
|
||||
@@ -72,8 +72,6 @@ const POLYGONS: Readonly<Record<Exclude<Figure, "circle">, readonly Point[]>> =
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
/** 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 stops: readonly Stop[];
|
||||
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 right: number;
|
||||
}): Thread => {
|
||||
@@ -144,7 +144,7 @@ export const threadPath = (thread: {
|
||||
// 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.
|
||||
const inward = x < side ? -1 : 1;
|
||||
const across = round(y - END_DROP);
|
||||
const across = round(y - thread.endDrop);
|
||||
d +=
|
||||
`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)}`;
|
||||
|
||||
@@ -44,8 +44,12 @@ export const de: Content = {
|
||||
leadStrong: "Ich baue Web-Apps, Interfaces und Websites.",
|
||||
primaryCta: GET_IN_TOUCH,
|
||||
secondaryCta: "Was ich mache",
|
||||
trust: ["15 Jahre im Web", "Remote, EU-Kernzeiten"],
|
||||
builtAndRunning: "Gebaut und in Betrieb:",
|
||||
trust: [
|
||||
"Ein Ansprechpartner, der es selbst baut",
|
||||
"Ein Angebot, bevor etwas kostet",
|
||||
"Änderungen per E-Mail, kein System zu lernen",
|
||||
"Server in Deutschland",
|
||||
],
|
||||
},
|
||||
hello: {
|
||||
label: "Hallo",
|
||||
|
||||
@@ -43,8 +43,12 @@ export const en: Content = {
|
||||
leadStrong: "I build web apps, interfaces and websites.",
|
||||
primaryCta: GET_IN_TOUCH,
|
||||
secondaryCta: "What I do",
|
||||
trust: ["15 years in web", "Remote, EU core hours"],
|
||||
builtAndRunning: "Built and running:",
|
||||
trust: [
|
||||
"One contact, who builds it himself",
|
||||
"An offer before anything costs money",
|
||||
"Changes by email, no system to learn",
|
||||
"Servers in Germany",
|
||||
],
|
||||
},
|
||||
hello: {
|
||||
label: "Hello",
|
||||
|
||||
@@ -171,7 +171,6 @@ export type Content = {
|
||||
readonly primaryCta: string;
|
||||
readonly secondaryCta: string;
|
||||
readonly trust: readonly string[];
|
||||
readonly builtAndRunning: string;
|
||||
};
|
||||
readonly hello: {
|
||||
readonly label: string;
|
||||
|
||||
Reference in New Issue
Block a user